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,1643 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ts-proto.js — protocolo e criptografia do hub do Terminal Smart (versão 1).
|
|
3
|
+
*
|
|
4
|
+
* Um único arquivo UMD, sem dependências, que roda igual:
|
|
5
|
+
* - no Node 22+: const TsProto = require('./ts-proto.js') (usa globalThis.crypto.subtle)
|
|
6
|
+
* - no navegador: <script src="ts-proto.js"></script> (expõe globalThis.TsProto)
|
|
7
|
+
*
|
|
8
|
+
* Só WebCrypto: ECDSA P-256 (assinatura), ECDH P-256 (efêmero por sessão), HKDF-SHA256,
|
|
9
|
+
* AES-256-GCM, SHA-256 e getRandomValues. A especificação completa está em PROTOCOLO.md,
|
|
10
|
+
* na mesma pasta; este arquivo é a implementação de referência dela.
|
|
11
|
+
*
|
|
12
|
+
* Regras que o código segue em todo lugar:
|
|
13
|
+
* - Nada aqui escreve em log. Erros são TsProtoErro com um `codigo` estável e uma mensagem
|
|
14
|
+
* fixa, sem eco de dados recebidos nem de segredos.
|
|
15
|
+
* - Toda entrada externa é validada com esquema fechado (campo a mais ou a menos = recusa).
|
|
16
|
+
* - Segredos (segredo do convite, chaves de pareamento e de sessão) ficam em WeakMap ou
|
|
17
|
+
* em CryptoKey não exportável, para não vazarem por JSON.stringify ou console.log.
|
|
18
|
+
*/
|
|
19
|
+
(function (raiz, fabrica) {
|
|
20
|
+
'use strict';
|
|
21
|
+
var api = fabrica(raiz);
|
|
22
|
+
if (typeof module === 'object' && module && module.exports) module.exports = api;
|
|
23
|
+
else raiz.TsProto = api;
|
|
24
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this, function (g) {
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
// ===========================================================================================
|
|
28
|
+
// Constantes
|
|
29
|
+
// ===========================================================================================
|
|
30
|
+
|
|
31
|
+
const VERSAO = 1;
|
|
32
|
+
const PREFIXO = 'ts-hub/1/';
|
|
33
|
+
|
|
34
|
+
const LIMITES = Object.freeze({
|
|
35
|
+
QUADRO_MAX: 65536, // bytes UTF-8 de uma mensagem WebSocket inteira
|
|
36
|
+
DADOS_MAX: 61440, // caracteres do campo opaco `dados` dentro de um quadro do relay
|
|
37
|
+
TEXTO_CLARO_MAX: 32768, // bytes do texto claro de um quadro cifrado de sessão
|
|
38
|
+
CONVITE_VALIDADE_MS: 300000, // 5 min
|
|
39
|
+
DECISAO_MS: 300000, // tempo extra, depois do vencimento, para a confirmação no PC
|
|
40
|
+
CONVITES_PENDENTES_MAX: 4,
|
|
41
|
+
PAREAR_FALHAS_MAX: 5, // pedidos que não decifram até o convite ser descartado
|
|
42
|
+
TOLERANCIA_RELOGIO_MS: 120000, // ±120 s
|
|
43
|
+
HANDSHAKE_MS: 30000,
|
|
44
|
+
SESSAO_VIDA_MS: 12 * 3600 * 1000,
|
|
45
|
+
SESSAO_QUADROS_MAX: 0x7fffffff, // por direção; depois disso, novo aperto de mão
|
|
46
|
+
DESAFIO_MS: 30000,
|
|
47
|
+
APARELHOS_MAX: 50,
|
|
48
|
+
NOME_MAX: 40,
|
|
49
|
+
TAXA_QUADROS_S: 30,
|
|
50
|
+
RAJADA_QUADROS: 60,
|
|
51
|
+
TAXA_BYTES_S: 262144,
|
|
52
|
+
RAJADA_BYTES: 524288,
|
|
53
|
+
ANON_QUADROS_MIN: 6,
|
|
54
|
+
PROFUNDIDADE_JSON_MAX: 16,
|
|
55
|
+
HISTORICO_MAX: 200, // linhas de histórico que o celular pode pedir em terminal.watch
|
|
56
|
+
PAREAR_USADO_MAX: 4, // respostas "usado" (assinadas) por convite a outros aparelhos
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const MENSAGENS = Object.freeze({
|
|
60
|
+
E_FORMATO: 'quadro ou campo em formato inválido',
|
|
61
|
+
E_TAMANHO: 'quadro maior que o limite',
|
|
62
|
+
E_VERSAO: 'versão de protocolo não suportada',
|
|
63
|
+
E_ARGUMENTO: 'uso incorreto da API do protocolo',
|
|
64
|
+
E_CRIPTO_INDISPONIVEL: 'WebCrypto indisponível neste ambiente',
|
|
65
|
+
E_CHAVE: 'chave pública ou privada inválida',
|
|
66
|
+
E_CHAVE_EXPORTAVEL: 'chave privada do aparelho não pode ser exportável',
|
|
67
|
+
E_ID: 'identificador não corresponde à chave',
|
|
68
|
+
E_ASSINATURA: 'assinatura inválida',
|
|
69
|
+
E_DECIFRAR: 'falha ao decifrar ou autenticar',
|
|
70
|
+
E_CONVITE_INVALIDO: 'convite de pareamento inválido',
|
|
71
|
+
E_CONVITE_DESCONHECIDO: 'convite de pareamento desconhecido',
|
|
72
|
+
E_CONVITE_EXPIRADO: 'convite de pareamento expirado',
|
|
73
|
+
E_CONVITE_USADO: 'convite de pareamento já usado',
|
|
74
|
+
E_PAREAMENTO_ESTADO: 'pareamento fora de ordem ou encerrado',
|
|
75
|
+
E_NOME: 'nome do aparelho inválido',
|
|
76
|
+
E_LIMITE: 'limite de itens pendentes atingido',
|
|
77
|
+
E_HANDSHAKE_EXPIRADO: 'aperto de mão expirado',
|
|
78
|
+
E_APARELHO_NAO_AUTORIZADO: 'aparelho não autorizado ou revogado',
|
|
79
|
+
E_SESSAO: 'sessão inválida ou fora de ordem',
|
|
80
|
+
E_SESSAO_DERRUBADA: 'sessão derrubada',
|
|
81
|
+
E_SESSAO_EXPIRADA: 'sessão expirada',
|
|
82
|
+
E_CONTADOR: 'contador de quadro repetido ou fora de ordem',
|
|
83
|
+
E_LIMITE_SESSAO: 'limite de quadros da sessão atingido',
|
|
84
|
+
E_SEQ: 'sequência de comando repetida ou fora de ordem',
|
|
85
|
+
E_TEMPO: 'carimbo de tempo fora da janela',
|
|
86
|
+
E_METODO: 'método não permitido',
|
|
87
|
+
E_PARAMS: 'parâmetros inválidos',
|
|
88
|
+
E_DESAFIO: 'desafio inválido, vencido ou já usado',
|
|
89
|
+
E_LISTA_VERSAO: 'versão da lista de aparelhos não é maior que a atual',
|
|
90
|
+
E_LISTA_INVALIDA: 'lista de aparelhos inválida',
|
|
91
|
+
E_TAXA: 'limite de taxa excedido',
|
|
92
|
+
E_NO_NAO_AUTORIZADO: 'nó não autorizado neste relay',
|
|
93
|
+
E_TERMINAL_INEXISTENTE: 'terminal inexistente no nó',
|
|
94
|
+
E_PARAR_SEM_CONFIRMACAO: 'parar tudo não confirmado pelo consumidor do nó',
|
|
95
|
+
E_PARAR_INDISPONIVEL: 'parar tudo indisponível neste nó',
|
|
96
|
+
E_INTERNO: 'erro interno do protocolo',
|
|
97
|
+
});
|
|
98
|
+
const CODIGOS = Object.freeze(Object.keys(MENSAGENS));
|
|
99
|
+
|
|
100
|
+
class TsProtoErro extends Error {
|
|
101
|
+
constructor(codigo, extra) {
|
|
102
|
+
const c = Object.prototype.hasOwnProperty.call(MENSAGENS, codigo) ? codigo : 'E_INTERNO';
|
|
103
|
+
super(MENSAGENS[c]);
|
|
104
|
+
this.name = 'TsProtoErro';
|
|
105
|
+
this.codigo = c;
|
|
106
|
+
// Único dado extra permitido: o número de sequência do comando (não é sigiloso), para o
|
|
107
|
+
// hub conseguir responder o erro ao comando certo.
|
|
108
|
+
if (extra && Number.isSafeInteger(extra.seq)) this.seq = extra.seq;
|
|
109
|
+
}
|
|
110
|
+
toJSON() { return this.seq === undefined ? { codigo: this.codigo } : { codigo: this.codigo, seq: this.seq }; }
|
|
111
|
+
}
|
|
112
|
+
function falha(codigo, extra) { throw new TsProtoErro(codigo, extra); }
|
|
113
|
+
|
|
114
|
+
// ===========================================================================================
|
|
115
|
+
// Bytes, texto, base64url, JSON canônico
|
|
116
|
+
// ===========================================================================================
|
|
117
|
+
|
|
118
|
+
const codificador = new TextEncoder();
|
|
119
|
+
const decodificador = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
120
|
+
|
|
121
|
+
function comoBytes(x) {
|
|
122
|
+
if (x instanceof Uint8Array) return x;
|
|
123
|
+
if (x instanceof ArrayBuffer) return new Uint8Array(x);
|
|
124
|
+
if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
|
|
125
|
+
falha('E_ARGUMENTO');
|
|
126
|
+
}
|
|
127
|
+
function concat() {
|
|
128
|
+
let total = 0;
|
|
129
|
+
const partes = [];
|
|
130
|
+
for (let i = 0; i < arguments.length; i++) { const b = comoBytes(arguments[i]); partes.push(b); total += b.length; }
|
|
131
|
+
const out = new Uint8Array(total);
|
|
132
|
+
let o = 0;
|
|
133
|
+
for (const b of partes) { out.set(b, o); o += b.length; }
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function utf8(s) {
|
|
137
|
+
if (typeof s !== 'string' || !textoBemFormado(s)) falha('E_FORMATO');
|
|
138
|
+
return codificador.encode(s);
|
|
139
|
+
}
|
|
140
|
+
function deUtf8(bytes) {
|
|
141
|
+
try { return decodificador.decode(comoBytes(bytes)); } catch (_) { falha('E_FORMATO'); }
|
|
142
|
+
}
|
|
143
|
+
function textoBemFormado(s) {
|
|
144
|
+
if (typeof s.isWellFormed === 'function') return s.isWellFormed();
|
|
145
|
+
return !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
146
|
+
}
|
|
147
|
+
function hex(bytes) {
|
|
148
|
+
const b = comoBytes(bytes);
|
|
149
|
+
let s = '';
|
|
150
|
+
for (let i = 0; i < b.length; i++) s += (b[i] < 16 ? '0' : '') + b[i].toString(16);
|
|
151
|
+
return s;
|
|
152
|
+
}
|
|
153
|
+
function deHex(s) {
|
|
154
|
+
if (typeof s !== 'string' || s.length % 2 || /[^0-9a-fA-F]/.test(s)) falha('E_FORMATO');
|
|
155
|
+
const out = new Uint8Array(s.length / 2);
|
|
156
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function iguaisBytes(a, b) {
|
|
160
|
+
if (a.length !== b.length) return false;
|
|
161
|
+
let d = 0;
|
|
162
|
+
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
|
|
163
|
+
return d === 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ALFABETO = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
167
|
+
const REVERSO = (function () {
|
|
168
|
+
const r = new Int16Array(128).fill(-1);
|
|
169
|
+
for (let i = 0; i < 64; i++) r[ALFABETO.charCodeAt(i)] = i;
|
|
170
|
+
return r;
|
|
171
|
+
})();
|
|
172
|
+
|
|
173
|
+
/** base64url sem preenchimento (RFC 4648 §5). */
|
|
174
|
+
function b64u(bytes) {
|
|
175
|
+
const b = comoBytes(bytes);
|
|
176
|
+
let s = '';
|
|
177
|
+
let i = 0;
|
|
178
|
+
for (; i + 2 < b.length; i += 3) {
|
|
179
|
+
const n = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2];
|
|
180
|
+
s += ALFABETO[(n >> 18) & 63] + ALFABETO[(n >> 12) & 63] + ALFABETO[(n >> 6) & 63] + ALFABETO[n & 63];
|
|
181
|
+
}
|
|
182
|
+
const resto = b.length - i;
|
|
183
|
+
if (resto === 1) {
|
|
184
|
+
const n = b[i] << 16;
|
|
185
|
+
s += ALFABETO[(n >> 18) & 63] + ALFABETO[(n >> 12) & 63];
|
|
186
|
+
} else if (resto === 2) {
|
|
187
|
+
const n = (b[i] << 16) | (b[i + 1] << 8);
|
|
188
|
+
s += ALFABETO[(n >> 18) & 63] + ALFABETO[(n >> 12) & 63] + ALFABETO[(n >> 6) & 63];
|
|
189
|
+
}
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Decodificação ESTRITA: sem '=', sem espaços, sem bits de sobra diferentes de zero. */
|
|
194
|
+
function deB64u(s, tamanhoEsperado) {
|
|
195
|
+
if (typeof s !== 'string' || s.length % 4 === 1) falha('E_FORMATO');
|
|
196
|
+
const out = new Uint8Array(Math.floor((s.length * 3) / 4));
|
|
197
|
+
let o = 0, acc = 0, bits = 0;
|
|
198
|
+
for (let i = 0; i < s.length; i++) {
|
|
199
|
+
const c = s.charCodeAt(i);
|
|
200
|
+
const v = c < 128 ? REVERSO[c] : -1;
|
|
201
|
+
if (v < 0) falha('E_FORMATO');
|
|
202
|
+
acc = (acc << 6) | v;
|
|
203
|
+
bits += 6;
|
|
204
|
+
if (bits >= 8) { bits -= 8; out[o++] = (acc >> bits) & 255; }
|
|
205
|
+
acc &= (1 << bits) - 1;
|
|
206
|
+
}
|
|
207
|
+
if (acc !== 0 || o !== out.length) falha('E_FORMATO');
|
|
208
|
+
if (tamanhoEsperado !== undefined && out.length !== tamanhoEsperado) falha('E_FORMATO');
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ehObjetoSimples(o) {
|
|
213
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) return false;
|
|
214
|
+
const p = Object.getPrototypeOf(o);
|
|
215
|
+
return p === Object.prototype || p === null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* JSON canônico: chaves ordenadas por unidade de código UTF-16 (ordem padrão de sort),
|
|
220
|
+
* sem espaços; números só inteiros seguros; nada de undefined, NaN, funções ou texto com
|
|
221
|
+
* substituto solitário. É o que se assina e o que entra nos hashes de transcript.
|
|
222
|
+
*/
|
|
223
|
+
function canonico(v, prof) {
|
|
224
|
+
prof = prof || 0;
|
|
225
|
+
if (prof > LIMITES.PROFUNDIDADE_JSON_MAX) falha('E_FORMATO');
|
|
226
|
+
if (v === null) return 'null';
|
|
227
|
+
switch (typeof v) {
|
|
228
|
+
case 'boolean': return v ? 'true' : 'false';
|
|
229
|
+
case 'number':
|
|
230
|
+
if (!Number.isSafeInteger(v)) falha('E_FORMATO');
|
|
231
|
+
return String(v === 0 ? 0 : v);
|
|
232
|
+
case 'string':
|
|
233
|
+
if (!textoBemFormado(v)) falha('E_FORMATO');
|
|
234
|
+
return JSON.stringify(v);
|
|
235
|
+
case 'object': {
|
|
236
|
+
if (Array.isArray(v)) {
|
|
237
|
+
let s = '[';
|
|
238
|
+
for (let i = 0; i < v.length; i++) s += (i ? ',' : '') + canonico(v[i], prof + 1);
|
|
239
|
+
return s + ']';
|
|
240
|
+
}
|
|
241
|
+
if (!ehObjetoSimples(v)) falha('E_FORMATO');
|
|
242
|
+
const ks = Object.keys(v).sort();
|
|
243
|
+
let s = '{';
|
|
244
|
+
for (let i = 0; i < ks.length; i++) {
|
|
245
|
+
if (!textoBemFormado(ks[i])) falha('E_FORMATO');
|
|
246
|
+
s += (i ? ',' : '') + JSON.stringify(ks[i]) + ':' + canonico(v[ks[i]], prof + 1);
|
|
247
|
+
}
|
|
248
|
+
return s + '}';
|
|
249
|
+
}
|
|
250
|
+
default: falha('E_FORMATO');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function profundidade(v, prof) {
|
|
255
|
+
if (prof > LIMITES.PROFUNDIDADE_JSON_MAX) return false;
|
|
256
|
+
if (v === null || typeof v !== 'object') return true;
|
|
257
|
+
const vals = Array.isArray(v) ? v : Object.values(v);
|
|
258
|
+
for (const x of vals) if (!profundidade(x, prof + 1)) return false;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function lerJsonTexto(texto) {
|
|
263
|
+
let o;
|
|
264
|
+
try { o = JSON.parse(texto); } catch (_) { falha('E_FORMATO'); }
|
|
265
|
+
if (!ehObjetoSimples(o) || !profundidade(o, 0)) falha('E_FORMATO');
|
|
266
|
+
return o;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function tamanhoUtf8(s) {
|
|
270
|
+
let n = 0;
|
|
271
|
+
for (let i = 0; i < s.length; i++) {
|
|
272
|
+
const c = s.charCodeAt(i);
|
|
273
|
+
if (c < 0x80) n += 1;
|
|
274
|
+
else if (c < 0x800) n += 2;
|
|
275
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length && (s.charCodeAt(i + 1) & 0xfc00) === 0xdc00) { n += 4; i++; }
|
|
276
|
+
else n += 3;
|
|
277
|
+
}
|
|
278
|
+
return n;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Recusa (E_TAMANHO) texto ou bytes acima de QUADRO_MAX. Rápido para textos enormes. */
|
|
282
|
+
function verificarTamanhoQuadro(entrada) {
|
|
283
|
+
let n;
|
|
284
|
+
if (typeof entrada === 'string') {
|
|
285
|
+
if (entrada.length > LIMITES.QUADRO_MAX) falha('E_TAMANHO');
|
|
286
|
+
n = tamanhoUtf8(entrada);
|
|
287
|
+
} else {
|
|
288
|
+
n = comoBytes(entrada).length;
|
|
289
|
+
}
|
|
290
|
+
if (n > LIMITES.QUADRO_MAX) falha('E_TAMANHO');
|
|
291
|
+
return n;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ===========================================================================================
|
|
295
|
+
// Validação de campos
|
|
296
|
+
// ===========================================================================================
|
|
297
|
+
|
|
298
|
+
const RX_ID = /^[A-Za-z0-9_-]{22}$/;
|
|
299
|
+
const RX_TERMINAL = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
300
|
+
const RX_CODIGO = /^E_[A-Z_]{1,40}$/;
|
|
301
|
+
const RX_EVENTO = /^[a-z][a-zA-Z0-9._]{0,63}$/;
|
|
302
|
+
|
|
303
|
+
function ehId(s) { return typeof s === 'string' && RX_ID.test(s); }
|
|
304
|
+
function inteiro(n, min, max) { return Number.isSafeInteger(n) && n >= min && n <= max; }
|
|
305
|
+
|
|
306
|
+
function exigirCampos(o, obrigatorios, opcionais) {
|
|
307
|
+
if (!ehObjetoSimples(o)) falha('E_FORMATO');
|
|
308
|
+
const ok = opcionais ? obrigatorios.concat(opcionais) : obrigatorios;
|
|
309
|
+
const ks = Object.keys(o);
|
|
310
|
+
for (const k of ks) if (ok.indexOf(k) < 0) falha('E_FORMATO');
|
|
311
|
+
for (const k of obrigatorios) if (!Object.prototype.hasOwnProperty.call(o, k)) falha('E_FORMATO');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function relogio(agora) {
|
|
315
|
+
if (agora === undefined) return Date.now();
|
|
316
|
+
if (!inteiro(agora, 0, Number.MAX_SAFE_INTEGER)) falha('E_ARGUMENTO');
|
|
317
|
+
return agora;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function exigirTs(ts, agora) {
|
|
321
|
+
if (!inteiro(ts, 0, Number.MAX_SAFE_INTEGER)) falha('E_FORMATO');
|
|
322
|
+
if (Math.abs(agora - ts) > LIMITES.TOLERANCIA_RELOGIO_MS) falha('E_TEMPO');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Controles, formatação invisível e sobreposição bidi: não entram no nome exibido no PC.
|
|
326
|
+
const RX_NOME_PROIBIDO = /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff\ufff9-\ufffb]/;
|
|
327
|
+
function normalizarNome(nome) {
|
|
328
|
+
if (typeof nome !== 'string') falha('E_NOME');
|
|
329
|
+
const n = nome.normalize('NFC').replace(/\s+/g, ' ').trim();
|
|
330
|
+
validarNome(n);
|
|
331
|
+
return n;
|
|
332
|
+
}
|
|
333
|
+
function validarNome(nome) {
|
|
334
|
+
if (typeof nome !== 'string' || !textoBemFormado(nome)) falha('E_NOME');
|
|
335
|
+
if (nome !== nome.normalize('NFC') || nome !== nome.trim() || /\s{2,}/.test(nome)) falha('E_NOME');
|
|
336
|
+
const tam = Array.from(nome).length;
|
|
337
|
+
if (tam < 1 || tam > LIMITES.NOME_MAX || RX_NOME_PROIBIDO.test(nome)) falha('E_NOME');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ===========================================================================================
|
|
341
|
+
// Primitivas WebCrypto
|
|
342
|
+
// ===========================================================================================
|
|
343
|
+
|
|
344
|
+
function subtle() {
|
|
345
|
+
const c = g.crypto;
|
|
346
|
+
if (!c || !c.subtle || typeof c.getRandomValues !== 'function') falha('E_CRIPTO_INDISPONIVEL');
|
|
347
|
+
return c.subtle;
|
|
348
|
+
}
|
|
349
|
+
function aleatorio(n) {
|
|
350
|
+
subtle();
|
|
351
|
+
const b = new Uint8Array(n);
|
|
352
|
+
g.crypto.getRandomValues(b);
|
|
353
|
+
return b;
|
|
354
|
+
}
|
|
355
|
+
async function sha256(bytes) {
|
|
356
|
+
return new Uint8Array(await subtle().digest('SHA-256', comoBytes(bytes)));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** HKDF-SHA256 (RFC 5869) devolvendo bytes. */
|
|
360
|
+
async function hkdf(ikm, salt, info, tamanho) {
|
|
361
|
+
if (!inteiro(tamanho, 1, 255 * 32)) falha('E_ARGUMENTO');
|
|
362
|
+
const base = await subtle().importKey('raw', comoBytes(ikm), 'HKDF', false, ['deriveBits']);
|
|
363
|
+
const bits = await subtle().deriveBits(
|
|
364
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: comoBytes(salt), info: comoBytes(info) }, base, tamanho * 8);
|
|
365
|
+
return new Uint8Array(bits);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Importa 32 bytes como chave AES-256-GCM NÃO exportável e zera a cópia em memória. */
|
|
369
|
+
async function chaveAes(bytes, usos) {
|
|
370
|
+
try {
|
|
371
|
+
return await subtle().importKey('raw', bytes, { name: 'AES-GCM', length: 256 }, false, usos);
|
|
372
|
+
} finally {
|
|
373
|
+
bytes.fill(0);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function aesSelar(chave, iv, claro, aad) {
|
|
378
|
+
const ct = await subtle().encrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, chave, claro);
|
|
379
|
+
return new Uint8Array(ct);
|
|
380
|
+
}
|
|
381
|
+
async function aesAbrir(chave, iv, ct, aad) {
|
|
382
|
+
if (ct.length < 16) falha('E_DECIFRAR');
|
|
383
|
+
try {
|
|
384
|
+
return new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, chave, ct));
|
|
385
|
+
} catch (_) {
|
|
386
|
+
falha('E_DECIFRAR');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const ALG_ECDSA = Object.freeze({ name: 'ECDSA', namedCurve: 'P-256' });
|
|
391
|
+
const ALG_ASSINATURA = Object.freeze({ name: 'ECDSA', hash: 'SHA-256' });
|
|
392
|
+
const ALG_ECDH = Object.freeze({ name: 'ECDH', namedCurve: 'P-256' });
|
|
393
|
+
|
|
394
|
+
// SPKI DER de uma chave P-256 em ponto não comprimido: prefixo fixo de 27 bytes + X + Y.
|
|
395
|
+
const PREFIXO_SPKI = deHex('3059301306072a8648ce3d020106082a8648ce3d030107034200' + '04');
|
|
396
|
+
|
|
397
|
+
function rotulo(nome) { return utf8(PREFIXO + nome + '\u0000'); }
|
|
398
|
+
|
|
399
|
+
function validarSpki(spki) {
|
|
400
|
+
if (spki.length !== 91 || !iguaisBytes(spki.subarray(0, 27), PREFIXO_SPKI)) falha('E_CHAVE');
|
|
401
|
+
}
|
|
402
|
+
async function importarSpki(spki) {
|
|
403
|
+
validarSpki(spki);
|
|
404
|
+
try {
|
|
405
|
+
return await subtle().importKey('spki', spki, ALG_ECDSA, true, ['verify']);
|
|
406
|
+
} catch (_) {
|
|
407
|
+
falha('E_CHAVE');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Ponto comprimido SEC1 (0x02/0x03 || X, 33 bytes) da P-256: y² = x³ − 3x + b (mod p).
|
|
412
|
+
// Usado só pelo convite v2 (§3.1). BigInt é criado sob demanda (nada de literal `123n`).
|
|
413
|
+
let CURVA_P256 = null;
|
|
414
|
+
function curvaP256() {
|
|
415
|
+
if (!CURVA_P256) {
|
|
416
|
+
if (typeof BigInt !== 'function') falha('E_CRIPTO_INDISPONIVEL');
|
|
417
|
+
const p = BigInt('0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff');
|
|
418
|
+
CURVA_P256 = Object.freeze({
|
|
419
|
+
p,
|
|
420
|
+
b: BigInt('0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b'),
|
|
421
|
+
raiz: (p + BigInt(1)) / BigInt(4), // p ≡ 3 (mod 4): √a = a^((p+1)/4)
|
|
422
|
+
zero: BigInt(0), um: BigInt(1), tres: BigInt(3),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return CURVA_P256;
|
|
426
|
+
}
|
|
427
|
+
function bigDeBytes(b) { return BigInt('0x' + (hex(b) || '0')); }
|
|
428
|
+
function bytesDeBig(n, tamanho) {
|
|
429
|
+
const h = n.toString(16);
|
|
430
|
+
if (h.length > tamanho * 2) falha('E_CHAVE');
|
|
431
|
+
return deHex(h.padStart(tamanho * 2, '0'));
|
|
432
|
+
}
|
|
433
|
+
function potMod(base, e, m) {
|
|
434
|
+
const c = curvaP256();
|
|
435
|
+
let r = c.um;
|
|
436
|
+
base %= m;
|
|
437
|
+
while (e > c.zero) {
|
|
438
|
+
if (e & c.um) r = (r * base) % m;
|
|
439
|
+
base = (base * base) % m;
|
|
440
|
+
e >>= c.um;
|
|
441
|
+
}
|
|
442
|
+
return r;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** SPKI P-256 (91 bytes, ponto não comprimido) → ponto comprimido SEC1 (33 bytes). */
|
|
446
|
+
function comprimirSpki(spki) {
|
|
447
|
+
const b = comoBytes(spki);
|
|
448
|
+
validarSpki(b);
|
|
449
|
+
return concat(new Uint8Array([0x02 | (b[90] & 1)]), b.subarray(27, 59));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Ponto comprimido SEC1 (33 bytes) → SPKI P-256 (91 bytes). Recusa (E_CHAVE) prefixo que não
|
|
454
|
+
* seja 0x02/0x03, x ≥ p e x sem raiz (fora da curva). A importação WebCrypto continua sendo a
|
|
455
|
+
* validação final do ponto (importarSpki).
|
|
456
|
+
*/
|
|
457
|
+
function descomprimirSpki(ponto) {
|
|
458
|
+
const b = comoBytes(ponto);
|
|
459
|
+
if (b.length !== 33 || (b[0] !== 0x02 && b[0] !== 0x03)) falha('E_CHAVE');
|
|
460
|
+
const c = curvaP256();
|
|
461
|
+
const x = bigDeBytes(b.subarray(1));
|
|
462
|
+
if (x >= c.p) falha('E_CHAVE');
|
|
463
|
+
const rhs = (((x * x) % c.p * x - c.tres * x + c.b) % c.p + c.p) % c.p;
|
|
464
|
+
let y = potMod(rhs, c.raiz, c.p);
|
|
465
|
+
if ((y * y) % c.p !== rhs) falha('E_CHAVE');
|
|
466
|
+
if ((y & c.um) !== BigInt(b[0] & 1)) y = (c.p - y) % c.p;
|
|
467
|
+
if ((y & c.um) !== BigInt(b[0] & 1)) falha('E_CHAVE'); // y = 0 não tem as duas paridades
|
|
468
|
+
return concat(PREFIXO_SPKI, b.subarray(1), bytesDeBig(y, 32));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Assina PREFIXO+nome+NUL || dados; devolve a assinatura P1363 (r||s, 64 bytes) em base64url. */
|
|
472
|
+
async function assinar(chavePrivada, nome, dados) {
|
|
473
|
+
const sig = await subtle().sign(ALG_ASSINATURA, chavePrivada, concat(rotulo(nome), dados));
|
|
474
|
+
return b64u(new Uint8Array(sig));
|
|
475
|
+
}
|
|
476
|
+
async function verificarAssinatura(chavePublica, nome, dados, assinaturaB64) {
|
|
477
|
+
let sig;
|
|
478
|
+
try { sig = deB64u(assinaturaB64, 64); } catch (_) { return false; }
|
|
479
|
+
try {
|
|
480
|
+
return await subtle().verify(ALG_ASSINATURA, chavePublica, sig, concat(rotulo(nome), dados));
|
|
481
|
+
} catch (_) {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ===========================================================================================
|
|
487
|
+
// Identidades
|
|
488
|
+
// ===========================================================================================
|
|
489
|
+
|
|
490
|
+
function idDeHash(h) { return b64u(h).slice(0, 22); }
|
|
491
|
+
function impressaoDeHash(h) {
|
|
492
|
+
const x = hex(h.subarray(0, 8)).toUpperCase();
|
|
493
|
+
return x.slice(0, 4) + '-' + x.slice(4, 8) + '-' + x.slice(8, 12) + '-' + x.slice(12, 16);
|
|
494
|
+
}
|
|
495
|
+
async function idDeSpki(spki) { const b = comoBytes(spki); validarSpki(b); return idDeHash(await sha256(b)); }
|
|
496
|
+
async function impressaoDeSpki(spki) { const b = comoBytes(spki); validarSpki(b); return impressaoDeHash(await sha256(b)); }
|
|
497
|
+
|
|
498
|
+
const IDENTIDADES = new WeakSet();
|
|
499
|
+
|
|
500
|
+
async function montarIdentidade(tipo, chavePrivada, chavePublica) {
|
|
501
|
+
let spki;
|
|
502
|
+
try { spki = new Uint8Array(await subtle().exportKey('spki', chavePublica)); } catch (_) { falha('E_CHAVE'); }
|
|
503
|
+
validarSpki(spki);
|
|
504
|
+
const h = await sha256(spki);
|
|
505
|
+
const ident = Object.freeze({
|
|
506
|
+
tipo,
|
|
507
|
+
chavePrivada,
|
|
508
|
+
chavePublica,
|
|
509
|
+
spki,
|
|
510
|
+
spkiB64: b64u(spki),
|
|
511
|
+
id: idDeHash(h),
|
|
512
|
+
impressao: impressaoDeHash(h),
|
|
513
|
+
});
|
|
514
|
+
IDENTIDADES.add(ident);
|
|
515
|
+
return ident;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function exigirIdentidade(ident, tipo) {
|
|
519
|
+
if (!ident || !IDENTIDADES.has(ident) || ident.tipo !== tipo) falha('E_ARGUMENTO');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Gera identidade de longo prazo.
|
|
524
|
+
* tipo 'aparelho': chave privada SEMPRE não exportável (extractable:false). A PWA guarda o
|
|
525
|
+
* CryptoKey no IndexedDB (clone estruturado) e restaura com identidadeDeChaves.
|
|
526
|
+
* tipo 'no': chave privada exportável, porque o hub precisa gravá-la cifrada (DPAPI no
|
|
527
|
+
* Windows, arquivo 0600 do usuário tshub na VPS). Ver exportarIdentidadeNo.
|
|
528
|
+
*/
|
|
529
|
+
async function gerarIdentidade(opcoes) {
|
|
530
|
+
const tipo = opcoes && opcoes.tipo;
|
|
531
|
+
if (tipo !== 'no' && tipo !== 'aparelho') falha('E_ARGUMENTO');
|
|
532
|
+
const par = await subtle().generateKey(ALG_ECDSA, tipo === 'no', ['sign', 'verify']);
|
|
533
|
+
if (tipo === 'aparelho' && par.privateKey.extractable !== false) falha('E_CHAVE_EXPORTAVEL');
|
|
534
|
+
return montarIdentidade(tipo, par.privateKey, par.publicKey);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Reconstrói a identidade a partir dos CryptoKey guardados (uso típico: aparelho + IndexedDB). */
|
|
538
|
+
async function identidadeDeChaves(opcoes) {
|
|
539
|
+
const o = opcoes || {};
|
|
540
|
+
const tipo = o.tipo;
|
|
541
|
+
if (tipo !== 'no' && tipo !== 'aparelho') falha('E_ARGUMENTO');
|
|
542
|
+
const priv = o.chavePrivada, pub = o.chavePublica;
|
|
543
|
+
const ehChave = k => k && typeof k === 'object' && k.algorithm && k.algorithm.name === 'ECDSA' && k.algorithm.namedCurve === 'P-256';
|
|
544
|
+
if (!ehChave(priv) || !ehChave(pub) || priv.type !== 'private' || pub.type !== 'public') falha('E_CHAVE');
|
|
545
|
+
if (tipo === 'aparelho' && priv.extractable !== false) falha('E_CHAVE_EXPORTAVEL');
|
|
546
|
+
// Confere que as duas metades são do mesmo par.
|
|
547
|
+
const teste = aleatorio(32);
|
|
548
|
+
let ok = false;
|
|
549
|
+
try {
|
|
550
|
+
const sig = await subtle().sign(ALG_ASSINATURA, priv, teste);
|
|
551
|
+
ok = await subtle().verify(ALG_ASSINATURA, pub, sig, teste);
|
|
552
|
+
} catch (_) { ok = false; }
|
|
553
|
+
if (!ok) falha('E_CHAVE');
|
|
554
|
+
return montarIdentidade(tipo, priv, pub);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Só para o nó: { pkcs8, spki } em base64url. O hub deve cifrar isso antes de gravar. */
|
|
558
|
+
async function exportarIdentidadeNo(ident) {
|
|
559
|
+
exigirIdentidade(ident, 'no');
|
|
560
|
+
const pkcs8 = new Uint8Array(await subtle().exportKey('pkcs8', ident.chavePrivada));
|
|
561
|
+
const r = { pkcs8: b64u(pkcs8), spki: ident.spkiB64 };
|
|
562
|
+
pkcs8.fill(0);
|
|
563
|
+
return r;
|
|
564
|
+
}
|
|
565
|
+
async function importarIdentidadeNo(dados) {
|
|
566
|
+
exigirCampos(dados, ['pkcs8', 'spki']);
|
|
567
|
+
const pkcs8 = deB64u(dados.pkcs8);
|
|
568
|
+
const spki = deB64u(dados.spki);
|
|
569
|
+
let priv, pub;
|
|
570
|
+
try {
|
|
571
|
+
priv = await subtle().importKey('pkcs8', pkcs8, ALG_ECDSA, true, ['sign']);
|
|
572
|
+
} catch (_) { falha('E_CHAVE'); } finally { pkcs8.fill(0); }
|
|
573
|
+
pub = await importarSpki(spki);
|
|
574
|
+
return identidadeDeChaves({ tipo: 'no', chavePrivada: priv, chavePublica: pub });
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ===========================================================================================
|
|
578
|
+
// Endereço do relay
|
|
579
|
+
// ===========================================================================================
|
|
580
|
+
|
|
581
|
+
function ehHostLocal(hostname) {
|
|
582
|
+
const h = String(hostname).toLowerCase();
|
|
583
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h.endsWith('.localhost');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* `relay` é uma ORIGEM WebSocket canônica: "wss://host[:porta]" (sem caminho, consulta ou
|
|
588
|
+
* fragmento). "ws://" só para host local e só com permitirLocal (testes e etapa 6).
|
|
589
|
+
*/
|
|
590
|
+
function validarRelay(relay, permitirLocal) {
|
|
591
|
+
if (typeof relay !== 'string' || relay.length > 255) falha('E_ARGUMENTO');
|
|
592
|
+
let u;
|
|
593
|
+
try { u = new URL(relay); } catch (_) { falha('E_ARGUMENTO'); }
|
|
594
|
+
const local = ehHostLocal(u.hostname);
|
|
595
|
+
if (!(u.protocol === 'wss:' || (u.protocol === 'ws:' && permitirLocal && local))) falha('E_ARGUMENTO');
|
|
596
|
+
if (u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) falha('E_ARGUMENTO');
|
|
597
|
+
if (relay !== u.protocol + '//' + u.host) falha('E_ARGUMENTO');
|
|
598
|
+
return {
|
|
599
|
+
relay,
|
|
600
|
+
host: u.host,
|
|
601
|
+
origemHttp: (u.protocol === 'wss:' ? 'https://' : 'http://') + u.host,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ===========================================================================================
|
|
606
|
+
// Pareamento
|
|
607
|
+
// ===========================================================================================
|
|
608
|
+
|
|
609
|
+
async function derivarChavesPareamento(segredo, noSpki, papel) {
|
|
610
|
+
const salt = await sha256(noSpki);
|
|
611
|
+
const pid = idDeHash(await hkdf(segredo, salt, utf8(PREFIXO + 'parear/id'), 16));
|
|
612
|
+
const a2n = await hkdf(segredo, salt, utf8(PREFIXO + 'parear/a2n'), 32);
|
|
613
|
+
const n2a = await hkdf(segredo, salt, utf8(PREFIXO + 'parear/n2a'), 32);
|
|
614
|
+
const ehNo = papel === 'no';
|
|
615
|
+
return {
|
|
616
|
+
pid,
|
|
617
|
+
enviar: await chaveAes(ehNo ? n2a : a2n, ['encrypt']),
|
|
618
|
+
receber: await chaveAes(ehNo ? a2n : n2a, ['decrypt']),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function aadPareamento(tipo, pid) { return concat(rotulo('parear/' + tipo), utf8(pid)); }
|
|
622
|
+
|
|
623
|
+
async function selarPareamento(chave, tipo, pid, obj) {
|
|
624
|
+
const iv = aleatorio(12);
|
|
625
|
+
const ct = await aesSelar(chave, iv, utf8(JSON.stringify(obj)), aadPareamento(tipo, pid));
|
|
626
|
+
return { t: 'parear.' + tipo, v: VERSAO, pid, iv: b64u(iv), ct: b64u(ct) };
|
|
627
|
+
}
|
|
628
|
+
function lerQuadroPareamento(quadro, tipo) {
|
|
629
|
+
exigirCampos(quadro, ['t', 'v', 'pid', 'iv', 'ct']);
|
|
630
|
+
if (quadro.t !== 'parear.' + tipo) falha('E_FORMATO');
|
|
631
|
+
if (quadro.v !== VERSAO) falha('E_VERSAO');
|
|
632
|
+
if (!ehId(quadro.pid) || typeof quadro.ct !== 'string' || quadro.ct.length > LIMITES.DADOS_MAX) falha('E_FORMATO');
|
|
633
|
+
return { iv: deB64u(quadro.iv, 12), ct: deB64u(quadro.ct) };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const ESTADOS_PAREAMENTO = Object.freeze(['pendente', 'aceito', 'recusado', 'usado']);
|
|
637
|
+
|
|
638
|
+
// Convite v2: ponto comprimido (33) || segredo (32) || exp em ms, sem sinal, big-endian (6).
|
|
639
|
+
const CONVITE_V2_TAMANHO = 71;
|
|
640
|
+
const CONVITE_V2_EXP_MAX = 0xffffffffffff; // 2^48 − 1 ms (ano 10889)
|
|
641
|
+
function u48(n) {
|
|
642
|
+
const out = new Uint8Array(6);
|
|
643
|
+
for (let i = 5; i >= 0; i--) { out[i] = n % 256; n = Math.floor(n / 256); }
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
function deU48(b) {
|
|
647
|
+
let n = 0;
|
|
648
|
+
for (let i = 0; i < 6; i++) n = n * 256 + b[i];
|
|
649
|
+
return n;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Origem HTTP da PWA que abre o convite: "https://host[:porta]" (sem caminho), ou
|
|
654
|
+
* "http://" só para host local com permitirLocal. Pode ser diferente do relay (§3.1).
|
|
655
|
+
*/
|
|
656
|
+
function validarOrigemPwa(origem, permitirLocal) {
|
|
657
|
+
if (typeof origem !== 'string' || origem.length > 255) falha('E_ARGUMENTO');
|
|
658
|
+
let u;
|
|
659
|
+
try { u = new URL(origem); } catch (_) { falha('E_ARGUMENTO'); }
|
|
660
|
+
const local = ehHostLocal(u.hostname);
|
|
661
|
+
if (!(u.protocol === 'https:' || (u.protocol === 'http:' && permitirLocal && local))) falha('E_ARGUMENTO');
|
|
662
|
+
if (u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) falha('E_ARGUMENTO');
|
|
663
|
+
if (origem !== u.protocol + '//' + u.host) falha('E_ARGUMENTO');
|
|
664
|
+
return origem;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Lado do NÓ. Guarda os convites abertos em memória (nunca em disco) e aplica uso único,
|
|
669
|
+
* validade e o limite de tentativas. A decisão humana é da etapa do hub: ela chama
|
|
670
|
+
* responder(pid, 'aceito') só depois da confirmação na tela do PC.
|
|
671
|
+
*/
|
|
672
|
+
class RegistroPareamentos {
|
|
673
|
+
constructor(opcoes) {
|
|
674
|
+
const ident = opcoes && opcoes.identidade;
|
|
675
|
+
exigirIdentidade(ident, 'no');
|
|
676
|
+
this._ident = ident;
|
|
677
|
+
this._mapa = new Map();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Convites ainda não usados e dentro da validade. */
|
|
681
|
+
pendentes(agora) {
|
|
682
|
+
const t = relogio(agora);
|
|
683
|
+
let n = 0;
|
|
684
|
+
for (const e of this._mapa.values()) if (!e.usado && t <= e.exp) n++;
|
|
685
|
+
return n;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
_descartar(e) { e.chaves = null; e.encerrado = true; this._mapa.delete(e.pid); }
|
|
689
|
+
_limpar(agora) { for (const e of Array.from(this._mapa.values())) if (agora > e.fimMs) this._descartar(e); }
|
|
690
|
+
|
|
691
|
+
/** Devolve { url, pid, exp }. A URL contém o segredo: mostrar só no QR, nunca em log. */
|
|
692
|
+
async criarConvite(opcoes) {
|
|
693
|
+
const o = opcoes || {};
|
|
694
|
+
const agora = relogio(o.agora);
|
|
695
|
+
const relay = validarRelay(o.relay, !!o.permitirLocal);
|
|
696
|
+
const pwa = o.pwa === undefined ? relay.origemHttp : validarOrigemPwa(o.pwa, !!o.permitirLocal);
|
|
697
|
+
const validade = o.validadeMs === undefined ? LIMITES.CONVITE_VALIDADE_MS : o.validadeMs;
|
|
698
|
+
if (!inteiro(validade, 1000, LIMITES.CONVITE_VALIDADE_MS)) falha('E_ARGUMENTO');
|
|
699
|
+
this._limpar(agora);
|
|
700
|
+
if (this.pendentes(agora) >= LIMITES.CONVITES_PENDENTES_MAX) falha('E_LIMITE');
|
|
701
|
+
|
|
702
|
+
const exp = agora + validade;
|
|
703
|
+
if (exp > CONVITE_V2_EXP_MAX) falha('E_ARGUMENTO');
|
|
704
|
+
const segredo = aleatorio(32);
|
|
705
|
+
const chaves = await derivarChavesPareamento(segredo, this._ident.spki, 'no');
|
|
706
|
+
// Convite v2 (§3.1): ponto comprimido do nó || segredo || exp. O relay não vai no QR.
|
|
707
|
+
const carga = concat(comprimirSpki(this._ident.spki), segredo, u48(exp));
|
|
708
|
+
segredo.fill(0);
|
|
709
|
+
const url = pwa + '/parear#v2.' + b64u(carga);
|
|
710
|
+
carga.fill(0);
|
|
711
|
+
this._mapa.set(chaves.pid, {
|
|
712
|
+
pid: chaves.pid, exp, fimMs: exp + LIMITES.DECISAO_MS,
|
|
713
|
+
usado: false, encerrado: false, falhas: 0, chaves, aparelho: null, ultimoEstado: null, avisosUsado: 0,
|
|
714
|
+
});
|
|
715
|
+
return { url, pid: chaves.pid, exp };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
cancelar(pid) { const e = this._mapa.get(pid); if (e) this._descartar(e); }
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Abre o pedido cifrado do aparelho. Consome o convite no primeiro pedido que decifra.
|
|
722
|
+
* Devolve { pid, aparelho: { id, spki, nome, impressao } } para a tela de confirmação.
|
|
723
|
+
*/
|
|
724
|
+
async abrirPedido(quadro, opcoes) {
|
|
725
|
+
const agora = relogio(opcoes && opcoes.agora);
|
|
726
|
+
const bruto = lerQuadroPareamento(quadro, 'pedido');
|
|
727
|
+
this._limpar(agora);
|
|
728
|
+
const e = this._mapa.get(quadro.pid);
|
|
729
|
+
if (!e) falha('E_CONVITE_DESCONHECIDO');
|
|
730
|
+
if (e.usado) falha('E_CONVITE_USADO');
|
|
731
|
+
if (agora > e.exp) { this._descartar(e); falha('E_CONVITE_EXPIRADO'); }
|
|
732
|
+
|
|
733
|
+
let claro;
|
|
734
|
+
try {
|
|
735
|
+
claro = await aesAbrir(e.chaves.receber, bruto.iv, bruto.ct, aadPareamento('pedido', e.pid));
|
|
736
|
+
} catch (err) {
|
|
737
|
+
if (++e.falhas >= LIMITES.PAREAR_FALHAS_MAX) this._descartar(e);
|
|
738
|
+
throw err;
|
|
739
|
+
}
|
|
740
|
+
// Checar e marcar sem await no meio: dois pedidos simultâneos não consomem o mesmo convite.
|
|
741
|
+
if (e.usado || !e.chaves) falha('E_CONVITE_USADO');
|
|
742
|
+
e.usado = true;
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
e.aparelho = await this._lerPedidoClaro(e, claro, agora);
|
|
746
|
+
return { pid: e.pid, aparelho: e.aparelho };
|
|
747
|
+
} catch (err) {
|
|
748
|
+
// Convite já consumido; pedido defeituoso encerra (quem tentar de novo recebe E_CONVITE_USADO).
|
|
749
|
+
e.encerrado = true;
|
|
750
|
+
e.chaves = null;
|
|
751
|
+
throw err;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Confere o texto claro de um pedido (passo 5 de §3.4) e devolve o aparelho. */
|
|
756
|
+
async _lerPedidoClaro(e, claro, agora) {
|
|
757
|
+
const p = lerJsonTexto(deUtf8(claro));
|
|
758
|
+
exigirCampos(p, ['t', 'pid', 'no', 'aparelhoSpki', 'nome', 'ts', 'assinatura']);
|
|
759
|
+
if (p.t !== 'parear.pedido' || p.pid !== e.pid || p.no !== this._ident.id) falha('E_FORMATO');
|
|
760
|
+
exigirTs(p.ts, agora);
|
|
761
|
+
validarNome(p.nome);
|
|
762
|
+
const spki = deB64u(p.aparelhoSpki);
|
|
763
|
+
const pub = await importarSpki(spki);
|
|
764
|
+
const semAss = Object.assign({}, p);
|
|
765
|
+
delete semAss.assinatura;
|
|
766
|
+
if (!(await verificarAssinatura(pub, 'parear/pedido', utf8(canonico(semAss)), p.assinatura))) falha('E_ASSINATURA');
|
|
767
|
+
const h = await sha256(spki);
|
|
768
|
+
return Object.freeze({ id: idDeHash(h), spki: p.aparelhoSpki, nome: p.nome, impressao: impressaoDeHash(h) });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Segundo pedido para um convite JÁ consumido, enquanto a decisão do primeiro ainda está
|
|
773
|
+
* aberta (§3.4 e §3.6): decifra e confere o pedido como de costume (só quem tem o segredo
|
|
774
|
+
* do QR chega aqui) e devolve a resposta assinada com estado "usado", endereçada ao
|
|
775
|
+
* aparelho que mandou ESTE pedido. Não muda a decisão do convite. No máximo
|
|
776
|
+
* PAREAR_USADO_MAX tentativas por convite. Devolve { quadro, aparelho:{ id, nome, impressao } }.
|
|
777
|
+
*/
|
|
778
|
+
async responderUsado(quadro, opcoes) {
|
|
779
|
+
const agora = relogio(opcoes && opcoes.agora);
|
|
780
|
+
const bruto = lerQuadroPareamento(quadro, 'pedido');
|
|
781
|
+
const e = this._mapa.get(quadro.pid);
|
|
782
|
+
if (!e) falha('E_CONVITE_DESCONHECIDO');
|
|
783
|
+
if (!e.usado || e.encerrado || !e.chaves || !e.aparelho) falha('E_PAREAMENTO_ESTADO');
|
|
784
|
+
if (agora > e.fimMs) falha('E_CONVITE_EXPIRADO');
|
|
785
|
+
if (e.avisosUsado >= LIMITES.PAREAR_USADO_MAX) falha('E_LIMITE');
|
|
786
|
+
e.avisosUsado++;
|
|
787
|
+
const chaves = e.chaves;
|
|
788
|
+
const claro = await aesAbrir(chaves.receber, bruto.iv, bruto.ct, aadPareamento('pedido', e.pid));
|
|
789
|
+
const outro = await this._lerPedidoClaro(e, claro, agora);
|
|
790
|
+
if (outro.id === e.aparelho.id) falha('E_CONVITE_USADO'); // o mesmo aparelho de novo: nada a avisar
|
|
791
|
+
if (!e.chaves || e.encerrado) falha('E_PAREAMENTO_ESTADO'); // decidido durante a conferência
|
|
792
|
+
const p = { t: 'parear.resposta', pid: e.pid, no: this._ident.id, aparelho: outro.id, estado: 'usado', ts: agora };
|
|
793
|
+
p.assinatura = await assinar(this._ident.chavePrivada, 'parear/resposta', utf8(canonico(p)));
|
|
794
|
+
return {
|
|
795
|
+
quadro: await selarPareamento(chaves.enviar, 'resposta', e.pid, p),
|
|
796
|
+
aparelho: Object.freeze({ id: outro.id, nome: outro.nome, impressao: outro.impressao }),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Resposta cifrada e assinada ao aparelho. estado: 'pendente' | 'aceito' | 'recusado'. */
|
|
801
|
+
async responder(pid, estado, opcoes) {
|
|
802
|
+
const agora = relogio(opcoes && opcoes.agora);
|
|
803
|
+
if (ESTADOS_PAREAMENTO.indexOf(estado) < 0 || estado === 'usado') falha('E_ARGUMENTO');
|
|
804
|
+
const e = this._mapa.get(pid);
|
|
805
|
+
if (!e || !e.usado || e.encerrado || !e.aparelho || !e.chaves) falha('E_PAREAMENTO_ESTADO');
|
|
806
|
+
if (agora > e.fimMs) { this._descartar(e); falha('E_CONVITE_EXPIRADO'); }
|
|
807
|
+
if (estado === 'pendente' && e.ultimoEstado !== null) falha('E_PAREAMENTO_ESTADO');
|
|
808
|
+
const chave = e.chaves.enviar;
|
|
809
|
+
e.ultimoEstado = estado;
|
|
810
|
+
if (estado !== 'pendente') { e.encerrado = true; e.chaves = null; }
|
|
811
|
+
const p = { t: 'parear.resposta', pid, no: this._ident.id, aparelho: e.aparelho.id, estado, ts: agora };
|
|
812
|
+
p.assinatura = await assinar(this._ident.chavePrivada, 'parear/resposta', utf8(canonico(p)));
|
|
813
|
+
return selarPareamento(chave, 'resposta', pid, p);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const SEGREDOS_CONVITE = new WeakMap();
|
|
818
|
+
const CONTEXTOS_PAREAMENTO = new WeakMap();
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Lado do APARELHO. Lê a URL do QR (§3.1):
|
|
822
|
+
* v2 (gerado hoje): https://<origem da PWA>/parear#v2.<base64url(ponto || segredo || exp)>
|
|
823
|
+
* v1 (só leitura): https://<origem da PWA>/parear#v1.<base64url(JSON)>
|
|
824
|
+
* opcoes: { agora, permitirLocal, origemPwa, relay }. `relay` só vale no v2 (que não leva o
|
|
825
|
+
* relay): origem wss:// que o aparelho vai usar; sem ela, a origem WebSocket da própria URL.
|
|
826
|
+
* No v1 o relay vem do convite e quem chama confere com o seu.
|
|
827
|
+
* O segredo não aparece no objeto devolvido (fica num WeakMap).
|
|
828
|
+
*/
|
|
829
|
+
async function lerConvite(url, opcoes) {
|
|
830
|
+
const o = opcoes || {};
|
|
831
|
+
const agora = relogio(o.agora);
|
|
832
|
+
const permitirLocal = !!o.permitirLocal;
|
|
833
|
+
if (typeof url !== 'string' || url.length > 2048) falha('E_CONVITE_INVALIDO');
|
|
834
|
+
let u;
|
|
835
|
+
try { u = new URL(url); } catch (_) { falha('E_CONVITE_INVALIDO'); }
|
|
836
|
+
const local = ehHostLocal(u.hostname);
|
|
837
|
+
if (!(u.protocol === 'https:' || (u.protocol === 'http:' && permitirLocal && local))) falha('E_CONVITE_INVALIDO');
|
|
838
|
+
if (u.username || u.password || u.search || u.pathname !== '/parear') falha('E_CONVITE_INVALIDO');
|
|
839
|
+
const frag = u.hash;
|
|
840
|
+
if (!/^#v\d+\./.test(frag)) falha('E_CONVITE_INVALIDO');
|
|
841
|
+
const versao = frag.slice(0, 4);
|
|
842
|
+
if (versao !== '#v1.' && versao !== '#v2.') falha('E_VERSAO');
|
|
843
|
+
const origemUrl = u.protocol + '//' + u.host;
|
|
844
|
+
|
|
845
|
+
let relay, nodeId, segredo, spki, exp;
|
|
846
|
+
if (versao === '#v1.') {
|
|
847
|
+
let c;
|
|
848
|
+
try {
|
|
849
|
+
c = lerJsonTexto(deUtf8(deB64u(frag.slice(4))));
|
|
850
|
+
exigirCampos(c, ['relay', 'nodeId', 'nodePubSpki', 'segredo', 'exp']);
|
|
851
|
+
} catch (_) { falha('E_CONVITE_INVALIDO'); }
|
|
852
|
+
try { relay = validarRelay(c.relay, permitirLocal); } catch (_) { falha('E_CONVITE_INVALIDO'); }
|
|
853
|
+
if (!ehId(c.nodeId) || !inteiro(c.exp, 0, Number.MAX_SAFE_INTEGER)) falha('E_CONVITE_INVALIDO');
|
|
854
|
+
try { segredo = deB64u(c.segredo, 32); spki = deB64u(c.nodePubSpki); validarSpki(spki); } catch (_) { falha('E_CONVITE_INVALIDO'); }
|
|
855
|
+
nodeId = c.nodeId;
|
|
856
|
+
exp = c.exp;
|
|
857
|
+
} else {
|
|
858
|
+
// O relay vem de fora (configuração do aparelho), nunca do QR.
|
|
859
|
+
relay = o.relay !== undefined ? validarRelay(o.relay, permitirLocal) : null;
|
|
860
|
+
let b;
|
|
861
|
+
try { b = deB64u(frag.slice(4), CONVITE_V2_TAMANHO); } catch (_) { falha('E_CONVITE_INVALIDO'); }
|
|
862
|
+
try { spki = descomprimirSpki(b.subarray(0, 33)); } catch (_) { b.fill(0); falha('E_CONVITE_INVALIDO'); }
|
|
863
|
+
segredo = b.slice(33, 65);
|
|
864
|
+
exp = deU48(b.subarray(65, 71));
|
|
865
|
+
b.fill(0);
|
|
866
|
+
if (!relay) {
|
|
867
|
+
try { relay = validarRelay((u.protocol === 'https:' ? 'wss://' : 'ws://') + u.host, permitirLocal); } catch (_) { falha('E_CONVITE_INVALIDO'); }
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
let h;
|
|
871
|
+
try {
|
|
872
|
+
// A URL é aberta na origem da PWA. Sem `origemPwa`, a PWA é servida pelo próprio relay.
|
|
873
|
+
if (o.origemPwa !== undefined) {
|
|
874
|
+
if (o.origemPwa !== origemUrl) falha('E_CONVITE_INVALIDO');
|
|
875
|
+
} else if (relay.origemHttp !== origemUrl) falha('E_CONVITE_INVALIDO');
|
|
876
|
+
// Validação final do ponto: a chave do nó tem de importar no WebCrypto.
|
|
877
|
+
try { await importarSpki(spki); } catch (_) { falha('E_CONVITE_INVALIDO'); }
|
|
878
|
+
h = await sha256(spki);
|
|
879
|
+
if (nodeId === undefined) nodeId = idDeHash(h); // v2: o ID sai da chave
|
|
880
|
+
else if (idDeHash(h) !== nodeId) falha('E_ID'); // v1: o ID do QR tem de ser o da chave
|
|
881
|
+
// O relógio do nó é quem manda; aqui só se recusa o que já está claramente vencido.
|
|
882
|
+
if (agora > exp + LIMITES.TOLERANCIA_RELOGIO_MS) falha('E_CONVITE_EXPIRADO');
|
|
883
|
+
if (exp - agora > LIMITES.CONVITE_VALIDADE_MS + LIMITES.TOLERANCIA_RELOGIO_MS) falha('E_CONVITE_INVALIDO');
|
|
884
|
+
} catch (e) {
|
|
885
|
+
segredo.fill(0);
|
|
886
|
+
throw e;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const convite = Object.freeze({
|
|
890
|
+
relay: relay.relay, nodeId, nodePubSpki: b64u(spki), exp, impressaoNo: impressaoDeHash(h),
|
|
891
|
+
});
|
|
892
|
+
SEGREDOS_CONVITE.set(convite, segredo);
|
|
893
|
+
return convite;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Monta o pedido cifrado. O convite só serve uma vez também deste lado. */
|
|
897
|
+
async function criarPedidoPareamento(opcoes) {
|
|
898
|
+
const o = opcoes || {};
|
|
899
|
+
const agora = relogio(o.agora);
|
|
900
|
+
exigirIdentidade(o.identidade, 'aparelho');
|
|
901
|
+
const segredo = o.convite ? SEGREDOS_CONVITE.get(o.convite) : undefined;
|
|
902
|
+
if (!segredo) falha('E_ARGUMENTO');
|
|
903
|
+
if (agora > o.convite.exp + LIMITES.TOLERANCIA_RELOGIO_MS) falha('E_CONVITE_EXPIRADO');
|
|
904
|
+
const nome = normalizarNome(o.nome);
|
|
905
|
+
const noSpki = deB64u(o.convite.nodePubSpki);
|
|
906
|
+
SEGREDOS_CONVITE.delete(o.convite);
|
|
907
|
+
const chaves = await derivarChavesPareamento(segredo, noSpki, 'aparelho');
|
|
908
|
+
segredo.fill(0);
|
|
909
|
+
|
|
910
|
+
const p = { t: 'parear.pedido', pid: chaves.pid, no: o.convite.nodeId, aparelhoSpki: o.identidade.spkiB64, nome, ts: agora };
|
|
911
|
+
p.assinatura = await assinar(o.identidade.chavePrivada, 'parear/pedido', utf8(canonico(p)));
|
|
912
|
+
const quadro = await selarPareamento(chaves.enviar, 'pedido', chaves.pid, p);
|
|
913
|
+
|
|
914
|
+
const contexto = Object.freeze({
|
|
915
|
+
pid: chaves.pid, nodeId: o.convite.nodeId, aparelhoId: o.identidade.id,
|
|
916
|
+
impressaoAparelho: o.identidade.impressao, impressaoNo: o.convite.impressaoNo,
|
|
917
|
+
});
|
|
918
|
+
CONTEXTOS_PAREAMENTO.set(contexto, {
|
|
919
|
+
receber: chaves.receber, noPub: await importarSpki(noSpki), ultimo: null, fimMs: o.convite.exp + LIMITES.DECISAO_MS,
|
|
920
|
+
});
|
|
921
|
+
return { quadro, contexto };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Abre a resposta do nó. Ordem aceita: [pendente] → aceito | recusado, ou "usado" sozinho. */
|
|
925
|
+
async function abrirRespostaPareamento(contexto, quadro, opcoes) {
|
|
926
|
+
const agora = relogio(opcoes && opcoes.agora);
|
|
927
|
+
const c = contexto ? CONTEXTOS_PAREAMENTO.get(contexto) : undefined;
|
|
928
|
+
if (!c) falha('E_PAREAMENTO_ESTADO');
|
|
929
|
+
if (agora > c.fimMs + LIMITES.TOLERANCIA_RELOGIO_MS) { CONTEXTOS_PAREAMENTO.delete(contexto); falha('E_CONVITE_EXPIRADO'); }
|
|
930
|
+
const bruto = lerQuadroPareamento(quadro, 'resposta');
|
|
931
|
+
if (quadro.pid !== contexto.pid) falha('E_FORMATO');
|
|
932
|
+
const claro = await aesAbrir(c.receber, bruto.iv, bruto.ct, aadPareamento('resposta', contexto.pid));
|
|
933
|
+
const p = lerJsonTexto(deUtf8(claro));
|
|
934
|
+
exigirCampos(p, ['t', 'pid', 'no', 'aparelho', 'estado', 'ts', 'assinatura']);
|
|
935
|
+
if (p.t !== 'parear.resposta' || p.pid !== contexto.pid || p.no !== contexto.nodeId || p.aparelho !== contexto.aparelhoId) falha('E_FORMATO');
|
|
936
|
+
if (ESTADOS_PAREAMENTO.indexOf(p.estado) < 0) falha('E_FORMATO');
|
|
937
|
+
exigirTs(p.ts, agora);
|
|
938
|
+
const semAss = Object.assign({}, p);
|
|
939
|
+
delete semAss.assinatura;
|
|
940
|
+
if (!(await verificarAssinatura(c.noPub, 'parear/resposta', utf8(canonico(semAss)), p.assinatura))) falha('E_ASSINATURA');
|
|
941
|
+
// Ordem: [pendente] → aceito | recusado; ou "usado" sozinho (outro aparelho chegou antes).
|
|
942
|
+
if (c.ultimo !== null && !(c.ultimo === 'pendente' && (p.estado === 'aceito' || p.estado === 'recusado'))) falha('E_PAREAMENTO_ESTADO');
|
|
943
|
+
c.ultimo = p.estado;
|
|
944
|
+
if (p.estado !== 'pendente') CONTEXTOS_PAREAMENTO.delete(contexto);
|
|
945
|
+
return {
|
|
946
|
+
estado: p.estado, nodeId: contexto.nodeId,
|
|
947
|
+
impressaoAparelho: contexto.impressaoAparelho, impressaoNo: contexto.impressaoNo,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ===========================================================================================
|
|
952
|
+
// Sessão: aperto de mão e cifra dos quadros
|
|
953
|
+
// ===========================================================================================
|
|
954
|
+
|
|
955
|
+
const DIR_A2N = 1;
|
|
956
|
+
const DIR_N2A = 2;
|
|
957
|
+
|
|
958
|
+
function u64be(n) {
|
|
959
|
+
const b = new Uint8Array(8);
|
|
960
|
+
const dv = new DataView(b.buffer);
|
|
961
|
+
dv.setUint32(0, Math.floor(n / 0x100000000));
|
|
962
|
+
dv.setUint32(4, n >>> 0);
|
|
963
|
+
return b;
|
|
964
|
+
}
|
|
965
|
+
function nonceQuadro(dir, n) {
|
|
966
|
+
const b = new Uint8Array(12);
|
|
967
|
+
new DataView(b.buffer).setUint32(0, dir);
|
|
968
|
+
b.set(u64be(n), 4);
|
|
969
|
+
return b;
|
|
970
|
+
}
|
|
971
|
+
function aadQuadro(sidBytes, dir, n) { return concat(rotulo('dados'), sidBytes, new Uint8Array([dir]), u64be(n)); }
|
|
972
|
+
|
|
973
|
+
async function derivarChavesSessaoBrutas(segredoEcdh, hashTranscript) {
|
|
974
|
+
return {
|
|
975
|
+
a2n: await hkdf(segredoEcdh, hashTranscript, utf8(PREFIXO + 'sessao/a2n'), 32),
|
|
976
|
+
n2a: await hkdf(segredoEcdh, hashTranscript, utf8(PREFIXO + 'sessao/n2a'), 32),
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const CHAVES_SESSAO = new WeakMap();
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Sessão estabelecida. Um contador por direção, começando em 0 e exigido EXATO no
|
|
984
|
+
* recebimento (o WebSocket entrega em ordem): repetição, salto, reordenação, adulteração
|
|
985
|
+
* ou qualquer quadro malformado derrubam a sessão de vez.
|
|
986
|
+
*/
|
|
987
|
+
class Sessao {
|
|
988
|
+
constructor(p) {
|
|
989
|
+
this.sid = p.sid;
|
|
990
|
+
this.papel = p.papel;
|
|
991
|
+
this.parId = p.parId;
|
|
992
|
+
this.proprioId = p.proprioId;
|
|
993
|
+
this.criadaEm = p.criadaEm;
|
|
994
|
+
this._sidBytes = deB64u(p.sid, 16);
|
|
995
|
+
this._dirEnvio = p.papel === 'aparelho' ? DIR_A2N : DIR_N2A;
|
|
996
|
+
this._dirReceb = p.papel === 'aparelho' ? DIR_N2A : DIR_A2N;
|
|
997
|
+
this._nEnvio = 0;
|
|
998
|
+
this._nReceb = 0;
|
|
999
|
+
this._seqComando = 0; // aparelho: último seq enviado
|
|
1000
|
+
this._ultimoSeq = 0; // nó: último seq aceito
|
|
1001
|
+
this._derrubada = false;
|
|
1002
|
+
this._fila = Promise.resolve();
|
|
1003
|
+
this._filaComandos = Promise.resolve(); // aparelho: criação de comandos, um por vez
|
|
1004
|
+
CHAVES_SESSAO.set(this, { envio: p.envio, receb: p.receb, parPub: p.parPub });
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
get derrubada() { return this._derrubada; }
|
|
1008
|
+
get chavePublicaPar() { const k = CHAVES_SESSAO.get(this); return k ? k.parPub : null; }
|
|
1009
|
+
|
|
1010
|
+
/** Descarta as chaves. Irreversível. */
|
|
1011
|
+
derrubar() { this._derrubada = true; CHAVES_SESSAO.delete(this); }
|
|
1012
|
+
|
|
1013
|
+
_falhar(codigo, extra) { this.derrubar(); falha(codigo, extra); }
|
|
1014
|
+
|
|
1015
|
+
_vigente(agora) {
|
|
1016
|
+
if (this._derrubada) falha('E_SESSAO_DERRUBADA');
|
|
1017
|
+
if (agora - this.criadaEm > LIMITES.SESSAO_VIDA_MS) this._falhar('E_SESSAO_EXPIRADA');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
_enfileirar(fn) {
|
|
1021
|
+
const p = this._fila.then(fn);
|
|
1022
|
+
this._fila = p.then(() => {}, () => {});
|
|
1023
|
+
return p;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Cifra um objeto JSON e devolve o quadro { t:'dados', sid, n, ct }. */
|
|
1027
|
+
cifrar(obj, opcoes) {
|
|
1028
|
+
const agora = relogio(opcoes && opcoes.agora);
|
|
1029
|
+
return this._enfileirar(async () => {
|
|
1030
|
+
this._vigente(agora);
|
|
1031
|
+
let claro;
|
|
1032
|
+
try { claro = utf8(JSON.stringify(obj)); } catch (_) { falha('E_ARGUMENTO'); }
|
|
1033
|
+
if (!ehObjetoSimples(obj) || claro.length > LIMITES.TEXTO_CLARO_MAX) falha('E_TAMANHO');
|
|
1034
|
+
const n = this._nEnvio;
|
|
1035
|
+
if (n >= LIMITES.SESSAO_QUADROS_MAX) this._falhar('E_LIMITE_SESSAO');
|
|
1036
|
+
this._nEnvio = n + 1;
|
|
1037
|
+
const k = CHAVES_SESSAO.get(this);
|
|
1038
|
+
const ct = await aesSelar(k.envio, nonceQuadro(this._dirEnvio, n), claro, aadQuadro(this._sidBytes, this._dirEnvio, n));
|
|
1039
|
+
return { t: 'dados', sid: this.sid, n, ct: b64u(ct) };
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Decifra um quadro recebido e devolve o objeto. Qualquer defeito derruba a sessão. */
|
|
1044
|
+
decifrar(quadro, opcoes) {
|
|
1045
|
+
const agora = relogio(opcoes && opcoes.agora);
|
|
1046
|
+
return this._enfileirar(async () => {
|
|
1047
|
+
this._vigente(agora);
|
|
1048
|
+
let bruto;
|
|
1049
|
+
try {
|
|
1050
|
+
exigirCampos(quadro, ['t', 'sid', 'n', 'ct']);
|
|
1051
|
+
if (quadro.t !== 'dados' || quadro.sid !== this.sid || !inteiro(quadro.n, 0, LIMITES.SESSAO_QUADROS_MAX)) falha('E_FORMATO');
|
|
1052
|
+
if (typeof quadro.ct !== 'string' || quadro.ct.length > LIMITES.DADOS_MAX) falha('E_TAMANHO');
|
|
1053
|
+
bruto = deB64u(quadro.ct);
|
|
1054
|
+
} catch (err) { this._falhar(err.codigo || 'E_FORMATO'); }
|
|
1055
|
+
const n = this._nReceb;
|
|
1056
|
+
if (quadro.n !== n) this._falhar('E_CONTADOR');
|
|
1057
|
+
this._nReceb = n + 1;
|
|
1058
|
+
const k = CHAVES_SESSAO.get(this);
|
|
1059
|
+
let claro;
|
|
1060
|
+
try {
|
|
1061
|
+
claro = await aesAbrir(k.receb, nonceQuadro(this._dirReceb, n), bruto, aadQuadro(this._sidBytes, this._dirReceb, n));
|
|
1062
|
+
} catch (_) { this._falhar('E_DECIFRAR'); }
|
|
1063
|
+
try { return lerJsonTexto(deUtf8(claro)); } catch (_) { this._falhar('E_FORMATO'); }
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
async function montarSessao(p, brutas) {
|
|
1069
|
+
const ehAparelho = p.papel === 'aparelho';
|
|
1070
|
+
const envio = await chaveAes(ehAparelho ? brutas.a2n : brutas.n2a, ['encrypt']);
|
|
1071
|
+
const receb = await chaveAes(ehAparelho ? brutas.n2a : brutas.a2n, ['decrypt']);
|
|
1072
|
+
return new Sessao(Object.assign({}, p, { envio, receb }));
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function gerarEfemera() {
|
|
1076
|
+
const par = await subtle().generateKey(ALG_ECDH, false, ['deriveBits']);
|
|
1077
|
+
const pub = new Uint8Array(await subtle().exportKey('raw', par.publicKey));
|
|
1078
|
+
return { priv: par.privateKey, pubB64: b64u(pub) };
|
|
1079
|
+
}
|
|
1080
|
+
async function importarEfemera(b64) {
|
|
1081
|
+
const raw = deB64u(b64, 65);
|
|
1082
|
+
if (raw[0] !== 4) falha('E_CHAVE');
|
|
1083
|
+
try { return await subtle().importKey('raw', raw, ALG_ECDH, true, []); } catch (_) { falha('E_CHAVE'); }
|
|
1084
|
+
}
|
|
1085
|
+
async function segredoEcdh(priv, pub) {
|
|
1086
|
+
try { return new Uint8Array(await subtle().deriveBits({ name: 'ECDH', public: pub }, priv, 256)); } catch (_) { falha('E_CHAVE'); }
|
|
1087
|
+
}
|
|
1088
|
+
async function hashTranscript(t) {
|
|
1089
|
+
return sha256(utf8(canonico({
|
|
1090
|
+
t: PREFIXO + 'sessao', v: VERSAO, sid: t.sid,
|
|
1091
|
+
aparelho: t.aparelho, aparelhoSpki: t.aparelhoSpki, no: t.no, noSpki: t.noSpki, eA: t.eA, eN: t.eN,
|
|
1092
|
+
})));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const ESTADOS_HANDSHAKE = new WeakMap();
|
|
1096
|
+
|
|
1097
|
+
/** Aparelho, passo 1: quadro sessao.ola. */
|
|
1098
|
+
async function aparelhoIniciarSessao(opcoes) {
|
|
1099
|
+
const o = opcoes || {};
|
|
1100
|
+
const agora = relogio(o.agora);
|
|
1101
|
+
exigirIdentidade(o.identidade, 'aparelho');
|
|
1102
|
+
if (!ehId(o.noId)) falha('E_ARGUMENTO');
|
|
1103
|
+
const noSpki = deB64u(o.noSpki);
|
|
1104
|
+
const noPub = await importarSpki(noSpki);
|
|
1105
|
+
if (idDeHash(await sha256(noSpki)) !== o.noId) falha('E_ID');
|
|
1106
|
+
const sid = b64u(aleatorio(16));
|
|
1107
|
+
const ef = await gerarEfemera();
|
|
1108
|
+
const quadro = { t: 'sessao.ola', v: VERSAO, sid, aparelho: o.identidade.id, no: o.noId, eA: ef.pubB64 };
|
|
1109
|
+
const estado = Object.freeze({ sid, papel: 'aparelho' });
|
|
1110
|
+
ESTADOS_HANDSHAKE.set(estado, { ident: o.identidade, noId: o.noId, noSpki: o.noSpki, noPub, ef, criadoEm: agora });
|
|
1111
|
+
return { quadro, estado };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Nó, passo 2: recebe sessao.ola e devolve sessao.resposta (assinada pelo nó). */
|
|
1115
|
+
async function noResponderSessao(opcoes) {
|
|
1116
|
+
const o = opcoes || {};
|
|
1117
|
+
const agora = relogio(o.agora);
|
|
1118
|
+
exigirIdentidade(o.identidade, 'no');
|
|
1119
|
+
if (typeof o.buscarAparelho !== 'function') falha('E_ARGUMENTO');
|
|
1120
|
+
const q = o.quadro;
|
|
1121
|
+
exigirCampos(q, ['t', 'v', 'sid', 'aparelho', 'no', 'eA']);
|
|
1122
|
+
if (q.t !== 'sessao.ola') falha('E_FORMATO');
|
|
1123
|
+
if (q.v !== VERSAO) falha('E_VERSAO');
|
|
1124
|
+
if (!ehId(q.sid) || !ehId(q.aparelho)) falha('E_FORMATO');
|
|
1125
|
+
deB64u(q.sid, 16);
|
|
1126
|
+
if (q.no !== o.identidade.id) falha('E_SESSAO');
|
|
1127
|
+
if (o.origem !== undefined && o.origem !== q.aparelho) falha('E_SESSAO');
|
|
1128
|
+
const spkiB64 = await o.buscarAparelho(q.aparelho);
|
|
1129
|
+
if (typeof spkiB64 !== 'string') falha('E_APARELHO_NAO_AUTORIZADO');
|
|
1130
|
+
const spki = deB64u(spkiB64);
|
|
1131
|
+
const parPub = await importarSpki(spki);
|
|
1132
|
+
if (idDeHash(await sha256(spki)) !== q.aparelho) falha('E_ID');
|
|
1133
|
+
const eA = await importarEfemera(q.eA);
|
|
1134
|
+
|
|
1135
|
+
const ef = await gerarEfemera();
|
|
1136
|
+
const compartilhado = await segredoEcdh(ef.priv, eA);
|
|
1137
|
+
const th = await hashTranscript({
|
|
1138
|
+
sid: q.sid, aparelho: q.aparelho, aparelhoSpki: spkiB64, no: o.identidade.id, noSpki: o.identidade.spkiB64, eA: q.eA, eN: ef.pubB64,
|
|
1139
|
+
});
|
|
1140
|
+
const assinatura = await assinar(o.identidade.chavePrivada, 'sessao/no', th);
|
|
1141
|
+
const brutas = await derivarChavesSessaoBrutas(compartilhado, th);
|
|
1142
|
+
compartilhado.fill(0);
|
|
1143
|
+
// A efêmera do nó sai de escopo aqui: só as chaves de sessão derivadas seguem.
|
|
1144
|
+
const estado = Object.freeze({ sid: q.sid, papel: 'no', aparelhoId: q.aparelho });
|
|
1145
|
+
ESTADOS_HANDSHAKE.set(estado, { th, brutas, parPub, criadoEm: agora, proprioId: o.identidade.id });
|
|
1146
|
+
return { quadro: { t: 'sessao.resposta', v: VERSAO, sid: q.sid, eN: ef.pubB64, assinatura }, estado };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Aparelho, passo 3: confere a assinatura do nó, devolve sessao.confirma e a Sessao. */
|
|
1150
|
+
async function aparelhoConcluirSessao(estado, quadro, opcoes) {
|
|
1151
|
+
const agora = relogio(opcoes && opcoes.agora);
|
|
1152
|
+
const h = estado ? ESTADOS_HANDSHAKE.get(estado) : undefined;
|
|
1153
|
+
if (!h || estado.papel !== 'aparelho') falha('E_SESSAO');
|
|
1154
|
+
ESTADOS_HANDSHAKE.delete(estado); // uso único, dê certo ou não
|
|
1155
|
+
if (agora - h.criadoEm > LIMITES.HANDSHAKE_MS) falha('E_HANDSHAKE_EXPIRADO');
|
|
1156
|
+
exigirCampos(quadro, ['t', 'v', 'sid', 'eN', 'assinatura']);
|
|
1157
|
+
if (quadro.t !== 'sessao.resposta') falha('E_FORMATO');
|
|
1158
|
+
if (quadro.v !== VERSAO) falha('E_VERSAO');
|
|
1159
|
+
if (quadro.sid !== estado.sid) falha('E_SESSAO');
|
|
1160
|
+
const eN = await importarEfemera(quadro.eN);
|
|
1161
|
+
const th = await hashTranscript({
|
|
1162
|
+
sid: estado.sid, aparelho: h.ident.id, aparelhoSpki: h.ident.spkiB64, no: h.noId, noSpki: h.noSpki, eA: h.ef.pubB64, eN: quadro.eN,
|
|
1163
|
+
});
|
|
1164
|
+
if (!(await verificarAssinatura(h.noPub, 'sessao/no', th, quadro.assinatura))) falha('E_ASSINATURA');
|
|
1165
|
+
const compartilhado = await segredoEcdh(h.ef.priv, eN);
|
|
1166
|
+
h.ef = null;
|
|
1167
|
+
const brutas = await derivarChavesSessaoBrutas(compartilhado, th);
|
|
1168
|
+
compartilhado.fill(0);
|
|
1169
|
+
const assinatura = await assinar(h.ident.chavePrivada, 'sessao/aparelho', th);
|
|
1170
|
+
const sessao = await montarSessao({
|
|
1171
|
+
sid: estado.sid, papel: 'aparelho', parId: h.noId, proprioId: h.ident.id, criadaEm: agora, parPub: h.noPub,
|
|
1172
|
+
}, brutas);
|
|
1173
|
+
return { quadro: { t: 'sessao.confirma', v: VERSAO, sid: estado.sid, assinatura }, sessao };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** Nó, passo 4: confere a assinatura do aparelho (e se ele ainda está autorizado). */
|
|
1177
|
+
async function noConcluirSessao(estado, quadro, opcoes) {
|
|
1178
|
+
const o = opcoes || {};
|
|
1179
|
+
const agora = relogio(o.agora);
|
|
1180
|
+
const h = estado ? ESTADOS_HANDSHAKE.get(estado) : undefined;
|
|
1181
|
+
if (!h || estado.papel !== 'no') falha('E_SESSAO');
|
|
1182
|
+
ESTADOS_HANDSHAKE.delete(estado);
|
|
1183
|
+
const zerar = () => { h.brutas.a2n.fill(0); h.brutas.n2a.fill(0); };
|
|
1184
|
+
try {
|
|
1185
|
+
if (agora - h.criadoEm > LIMITES.HANDSHAKE_MS) falha('E_HANDSHAKE_EXPIRADO');
|
|
1186
|
+
exigirCampos(quadro, ['t', 'v', 'sid', 'assinatura']);
|
|
1187
|
+
if (quadro.t !== 'sessao.confirma') falha('E_FORMATO');
|
|
1188
|
+
if (quadro.v !== VERSAO) falha('E_VERSAO');
|
|
1189
|
+
if (quadro.sid !== estado.sid) falha('E_SESSAO');
|
|
1190
|
+
if (typeof o.buscarAparelho === 'function') {
|
|
1191
|
+
const ainda = await o.buscarAparelho(estado.aparelhoId);
|
|
1192
|
+
if (typeof ainda !== 'string') falha('E_APARELHO_NAO_AUTORIZADO');
|
|
1193
|
+
}
|
|
1194
|
+
if (!(await verificarAssinatura(h.parPub, 'sessao/aparelho', h.th, quadro.assinatura))) falha('E_ASSINATURA');
|
|
1195
|
+
} catch (err) { zerar(); throw err; }
|
|
1196
|
+
return montarSessao({
|
|
1197
|
+
sid: estado.sid, papel: 'no', parId: estado.aparelhoId, proprioId: h.proprioId, criadaEm: agora, parPub: h.parPub,
|
|
1198
|
+
}, h.brutas);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// ===========================================================================================
|
|
1202
|
+
// Comandos (lista fechada da entrega 1)
|
|
1203
|
+
// ===========================================================================================
|
|
1204
|
+
|
|
1205
|
+
function paramsVazios(p) { exigirCampos(p, []); }
|
|
1206
|
+
function paramsTerminal(p, opcionais) {
|
|
1207
|
+
exigirCampos(p, ['terminalId'], opcionais);
|
|
1208
|
+
if (typeof p.terminalId !== 'string' || !RX_TERMINAL.test(p.terminalId)) falha('E_PARAMS');
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const VALIDADORES = Object.freeze({
|
|
1212
|
+
'hub.status': paramsVazios,
|
|
1213
|
+
'terminals.list': paramsVazios,
|
|
1214
|
+
'terminal.watch': function (p) {
|
|
1215
|
+
paramsTerminal(p, ['historico']);
|
|
1216
|
+
if (p.historico !== undefined && !inteiro(p.historico, 0, LIMITES.HISTORICO_MAX)) falha('E_PARAMS');
|
|
1217
|
+
},
|
|
1218
|
+
'terminal.unwatch': function (p) { paramsTerminal(p); },
|
|
1219
|
+
'hub.stopAll': function (p) {
|
|
1220
|
+
exigirCampos(p, ['confirmar']);
|
|
1221
|
+
if (p.confirmar !== true) falha('E_PARAMS');
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
const METODOS = Object.freeze(Object.keys(VALIDADORES));
|
|
1225
|
+
|
|
1226
|
+
/** Lança E_METODO (fora da lista) ou E_PARAMS (formato errado). */
|
|
1227
|
+
function validarParametros(metodo, params) {
|
|
1228
|
+
if (typeof metodo !== 'string' || !Object.prototype.hasOwnProperty.call(VALIDADORES, metodo)) falha('E_METODO');
|
|
1229
|
+
try { VALIDADORES[metodo](params); } catch (_) { falha('E_PARAMS'); }
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Aparelho: monta, assina e cifra um comando. Devolve Promise<{ quadro, seq }>.
|
|
1235
|
+
* Serializado por sessão: reservar o `seq`, assinar e cifrar acontecem um comando de cada
|
|
1236
|
+
* vez, na ordem das chamadas. Assim a ordem dos `seq` é sempre a ordem dos contadores `n`
|
|
1237
|
+
* dos quadros (sem isso, chamadas simultâneas trocavam a ordem e o nó derrubava a sessão
|
|
1238
|
+
* com E_SEQ). As promessas resolvem na ordem das chamadas: quem enviar cada quadro assim
|
|
1239
|
+
* que a sua promessa resolver envia na ordem certa (§5).
|
|
1240
|
+
*/
|
|
1241
|
+
function aparelhoCriarComando(sessao, identidade, metodo, params, opcoes) {
|
|
1242
|
+
let agora;
|
|
1243
|
+
try {
|
|
1244
|
+
agora = relogio(opcoes && opcoes.agora);
|
|
1245
|
+
if (!(sessao instanceof Sessao) || sessao.papel !== 'aparelho') falha('E_ARGUMENTO');
|
|
1246
|
+
exigirIdentidade(identidade, 'aparelho');
|
|
1247
|
+
if (identidade.id !== sessao.proprioId) falha('E_ARGUMENTO');
|
|
1248
|
+
validarParametros(metodo, params);
|
|
1249
|
+
} catch (err) { return Promise.reject(err); }
|
|
1250
|
+
const p = sessao._filaComandos.then(async () => {
|
|
1251
|
+
sessao._vigente(agora);
|
|
1252
|
+
const seq = sessao._seqComando + 1;
|
|
1253
|
+
// `no` amarra o comando ao nó desta sessão (além do sid): a assinatura do aparelho só
|
|
1254
|
+
// vale para este nó.
|
|
1255
|
+
const env = { sessao: sessao.sid, no: sessao.parId, seq, metodo, params, ts: agora };
|
|
1256
|
+
const assinatura = await assinar(identidade.chavePrivada, 'comando', utf8(canonico(env)));
|
|
1257
|
+
const quadro = await sessao.cifrar({ k: 'cmd', env, assinatura }, { agora });
|
|
1258
|
+
// Só conta o seq depois que o quadro existe: comando que falhou não abre buraco.
|
|
1259
|
+
sessao._seqComando = seq;
|
|
1260
|
+
return { quadro, seq };
|
|
1261
|
+
});
|
|
1262
|
+
sessao._filaComandos = p.then(() => {}, () => {});
|
|
1263
|
+
return p;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Nó: decifra e confere um comando. Devolve { seq, metodo, params }.
|
|
1268
|
+
* Derrubam a sessão: falha de cifra, de assinatura, sessão trocada, seq não crescente,
|
|
1269
|
+
* aparelho revogado. Só recusam o comando (sessão segue): E_TEMPO, E_METODO, E_PARAMS.
|
|
1270
|
+
*/
|
|
1271
|
+
async function noAbrirComando(sessao, quadro, opcoes) {
|
|
1272
|
+
const o = opcoes || {};
|
|
1273
|
+
const agora = relogio(o.agora);
|
|
1274
|
+
if (!(sessao instanceof Sessao) || sessao.papel !== 'no') falha('E_ARGUMENTO');
|
|
1275
|
+
if (typeof o.aparelhoAutorizado === 'function' && !(await o.aparelhoAutorizado(sessao.parId))) {
|
|
1276
|
+
sessao._falhar('E_APARELHO_NAO_AUTORIZADO');
|
|
1277
|
+
}
|
|
1278
|
+
const msg = await sessao.decifrar(quadro, { agora });
|
|
1279
|
+
let env, dadosAssinados;
|
|
1280
|
+
try {
|
|
1281
|
+
exigirCampos(msg, ['k', 'env', 'assinatura']);
|
|
1282
|
+
if (msg.k !== 'cmd') falha('E_FORMATO');
|
|
1283
|
+
env = msg.env;
|
|
1284
|
+
exigirCampos(env, ['sessao', 'no', 'seq', 'metodo', 'params', 'ts']);
|
|
1285
|
+
dadosAssinados = utf8(canonico(env));
|
|
1286
|
+
} catch (_) { sessao._falhar('E_FORMATO'); }
|
|
1287
|
+
if (env.sessao !== sessao.sid || env.no !== sessao.proprioId) sessao._falhar('E_SESSAO');
|
|
1288
|
+
if (!(await verificarAssinatura(sessao.chavePublicaPar, 'comando', dadosAssinados, msg.assinatura))) sessao._falhar('E_ASSINATURA');
|
|
1289
|
+
if (!inteiro(env.seq, 1, Number.MAX_SAFE_INTEGER) || env.seq <= sessao._ultimoSeq) sessao._falhar('E_SEQ');
|
|
1290
|
+
sessao._ultimoSeq = env.seq;
|
|
1291
|
+
const seq = env.seq;
|
|
1292
|
+
try { exigirTs(env.ts, agora); } catch (err) { falha(err.codigo, { seq }); }
|
|
1293
|
+
try { validarParametros(env.metodo, env.params); } catch (err) { falha(err.codigo, { seq }); }
|
|
1294
|
+
return { seq, metodo: env.metodo, params: env.params };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** Nó: resposta a um comando. `erro` é só um código E_*; nada de texto livre. */
|
|
1298
|
+
async function noResponder(sessao, seq, r, opcoes) {
|
|
1299
|
+
if (!(sessao instanceof Sessao) || sessao.papel !== 'no') falha('E_ARGUMENTO');
|
|
1300
|
+
if (!inteiro(seq, 1, Number.MAX_SAFE_INTEGER) || !ehObjetoSimples(r)) falha('E_ARGUMENTO');
|
|
1301
|
+
let msg;
|
|
1302
|
+
if (Object.prototype.hasOwnProperty.call(r, 'erro')) {
|
|
1303
|
+
if (typeof r.erro !== 'string' || !RX_CODIGO.test(r.erro)) falha('E_ARGUMENTO');
|
|
1304
|
+
msg = { k: 'resp', seq, ok: false, erro: { codigo: r.erro } };
|
|
1305
|
+
} else {
|
|
1306
|
+
msg = { k: 'resp', seq, ok: true, resultado: r.resultado === undefined ? null : r.resultado };
|
|
1307
|
+
}
|
|
1308
|
+
return sessao.cifrar(msg, opcoes);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/** Nó: evento assíncrono (ex.: 'terminal.tela', 'hub.estado'). */
|
|
1312
|
+
async function noEnviarEvento(sessao, tipo, dados, opcoes) {
|
|
1313
|
+
if (!(sessao instanceof Sessao) || sessao.papel !== 'no') falha('E_ARGUMENTO');
|
|
1314
|
+
if (typeof tipo !== 'string' || !RX_EVENTO.test(tipo)) falha('E_ARGUMENTO');
|
|
1315
|
+
return sessao.cifrar({ k: 'evento', tipo, dados: dados === undefined ? null : dados }, opcoes);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Aparelho: abre resposta ou evento do nó. */
|
|
1319
|
+
async function aparelhoAbrirMensagem(sessao, quadro, opcoes) {
|
|
1320
|
+
if (!(sessao instanceof Sessao) || sessao.papel !== 'aparelho') falha('E_ARGUMENTO');
|
|
1321
|
+
const msg = await sessao.decifrar(quadro, opcoes);
|
|
1322
|
+
try {
|
|
1323
|
+
if (msg.k === 'resp') {
|
|
1324
|
+
if (msg.ok === true) exigirCampos(msg, ['k', 'seq', 'ok', 'resultado']);
|
|
1325
|
+
else if (msg.ok === false) {
|
|
1326
|
+
exigirCampos(msg, ['k', 'seq', 'ok', 'erro']);
|
|
1327
|
+
exigirCampos(msg.erro, ['codigo']);
|
|
1328
|
+
if (typeof msg.erro.codigo !== 'string' || !RX_CODIGO.test(msg.erro.codigo)) falha('E_FORMATO');
|
|
1329
|
+
} else falha('E_FORMATO');
|
|
1330
|
+
if (!inteiro(msg.seq, 1, sessao._seqComando)) falha('E_FORMATO');
|
|
1331
|
+
} else if (msg.k === 'evento') {
|
|
1332
|
+
exigirCampos(msg, ['k', 'tipo', 'dados']);
|
|
1333
|
+
if (typeof msg.tipo !== 'string' || !RX_EVENTO.test(msg.tipo)) falha('E_FORMATO');
|
|
1334
|
+
} else falha('E_FORMATO');
|
|
1335
|
+
} catch (_) { sessao._falhar('E_FORMATO'); }
|
|
1336
|
+
return msg;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// ===========================================================================================
|
|
1340
|
+
// Controle com o relay
|
|
1341
|
+
// ===========================================================================================
|
|
1342
|
+
|
|
1343
|
+
const DESAFIOS = new WeakMap();
|
|
1344
|
+
const PAPEIS_RELAY = Object.freeze(['no', 'aparelho']);
|
|
1345
|
+
|
|
1346
|
+
/** Relay: cria o desafio enviado logo que a conexão abre. */
|
|
1347
|
+
function relayCriarDesafio(opcoes) {
|
|
1348
|
+
const o = opcoes || {};
|
|
1349
|
+
const agora = relogio(o.agora);
|
|
1350
|
+
const relay = validarRelay(o.relay, !!o.permitirLocal);
|
|
1351
|
+
const nonce = b64u(aleatorio(32));
|
|
1352
|
+
const quadro = { t: 'relay.desafio', v: VERSAO, nonce, relay: relay.relay };
|
|
1353
|
+
const estado = Object.freeze({ nonce });
|
|
1354
|
+
DESAFIOS.set(estado, { relay: relay.relay, criadoEm: agora, usado: false });
|
|
1355
|
+
return { quadro, estado };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function dadosAuthRelay(papel, id, nonce, relay) {
|
|
1359
|
+
return utf8(canonico({ t: 'relay.auth', v: VERSAO, papel, id, nonce, relay }));
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Nó ou aparelho: responde o desafio do relay, conferindo que é o relay esperado. */
|
|
1363
|
+
async function clienteResponderDesafio(opcoes) {
|
|
1364
|
+
const o = opcoes || {};
|
|
1365
|
+
const ident = o.identidade;
|
|
1366
|
+
if (!ident || !IDENTIDADES.has(ident)) falha('E_ARGUMENTO');
|
|
1367
|
+
const q = o.quadro;
|
|
1368
|
+
exigirCampos(q, ['t', 'v', 'nonce', 'relay']);
|
|
1369
|
+
if (q.t !== 'relay.desafio') falha('E_FORMATO');
|
|
1370
|
+
if (q.v !== VERSAO) falha('E_VERSAO');
|
|
1371
|
+
deB64u(q.nonce, 32);
|
|
1372
|
+
if (typeof o.relayEsperado !== 'string' || q.relay !== o.relayEsperado) falha('E_DESAFIO');
|
|
1373
|
+
const assinatura = await assinar(ident.chavePrivada, 'relay/auth', dadosAuthRelay(ident.tipo, ident.id, q.nonce, q.relay));
|
|
1374
|
+
return { t: 'relay.auth', v: VERSAO, papel: ident.tipo, id: ident.id, spki: ident.spkiB64, assinatura };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Relay: confere a resposta. Devolve { papel, id, spki }. Para papel 'aparelho', o relay
|
|
1379
|
+
* ainda precisa conferir que o id está em alguma lista vigente (etapa do relay).
|
|
1380
|
+
*/
|
|
1381
|
+
async function relayVerificarAuth(estado, quadro, opcoes) {
|
|
1382
|
+
const agora = relogio(opcoes && opcoes.agora);
|
|
1383
|
+
const d = estado ? DESAFIOS.get(estado) : undefined;
|
|
1384
|
+
if (!d || d.usado) falha('E_DESAFIO');
|
|
1385
|
+
d.usado = true;
|
|
1386
|
+
DESAFIOS.delete(estado);
|
|
1387
|
+
if (agora - d.criadoEm > LIMITES.DESAFIO_MS || agora < d.criadoEm) falha('E_DESAFIO');
|
|
1388
|
+
exigirCampos(quadro, ['t', 'v', 'papel', 'id', 'spki', 'assinatura']);
|
|
1389
|
+
if (quadro.t !== 'relay.auth') falha('E_FORMATO');
|
|
1390
|
+
if (quadro.v !== VERSAO) falha('E_VERSAO');
|
|
1391
|
+
if (PAPEIS_RELAY.indexOf(quadro.papel) < 0 || !ehId(quadro.id)) falha('E_FORMATO');
|
|
1392
|
+
const spki = deB64u(quadro.spki);
|
|
1393
|
+
const pub = await importarSpki(spki);
|
|
1394
|
+
if (idDeHash(await sha256(spki)) !== quadro.id) falha('E_ID');
|
|
1395
|
+
if (!(await verificarAssinatura(pub, 'relay/auth', dadosAuthRelay(quadro.papel, quadro.id, estado.nonce, d.relay), quadro.assinatura))) {
|
|
1396
|
+
falha('E_ASSINATURA');
|
|
1397
|
+
}
|
|
1398
|
+
return { papel: quadro.papel, id: quadro.id, spki: quadro.spki };
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
async function validarEntradasLista(aparelhos) {
|
|
1402
|
+
if (!Array.isArray(aparelhos) || aparelhos.length > LIMITES.APARELHOS_MAX) falha('E_LISTA_INVALIDA');
|
|
1403
|
+
let anterior = '';
|
|
1404
|
+
for (const a of aparelhos) {
|
|
1405
|
+
try { exigirCampos(a, ['id', 'spki']); } catch (_) { falha('E_LISTA_INVALIDA'); }
|
|
1406
|
+
if (!ehId(a.id) || a.id <= anterior) falha('E_LISTA_INVALIDA'); // ordenada por id, sem repetição
|
|
1407
|
+
let spki;
|
|
1408
|
+
try { spki = deB64u(a.spki); validarSpki(spki); } catch (_) { falha('E_LISTA_INVALIDA'); }
|
|
1409
|
+
if (idDeHash(await sha256(spki)) !== a.id) falha('E_LISTA_INVALIDA');
|
|
1410
|
+
anterior = a.id;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** Nó: assina a lista de aparelhos autorizados. Revogar = nova versão sem o aparelho. */
|
|
1415
|
+
async function noAssinarListaAparelhos(identidade, opcoes) {
|
|
1416
|
+
exigirIdentidade(identidade, 'no');
|
|
1417
|
+
const o = opcoes || {};
|
|
1418
|
+
const agora = relogio(o.agora);
|
|
1419
|
+
if (!inteiro(o.versao, 1, Number.MAX_SAFE_INTEGER) || !Array.isArray(o.aparelhos)) falha('E_ARGUMENTO');
|
|
1420
|
+
const aparelhos = o.aparelhos.map(a => ({ id: a && a.id, spki: a && a.spki }))
|
|
1421
|
+
.sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
|
|
1422
|
+
await validarEntradasLista(aparelhos);
|
|
1423
|
+
const lista = { no: identidade.id, versao: o.versao, emitidaEm: agora, aparelhos };
|
|
1424
|
+
const assinatura = await assinar(identidade.chavePrivada, 'lista-aparelhos', utf8(canonico(lista)));
|
|
1425
|
+
return { t: 'relay.lista', lista, assinatura };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Relay (e hub): confere assinatura, dono e versão estritamente maior que `versaoAtual`.
|
|
1430
|
+
* Devolve a lista validada.
|
|
1431
|
+
*/
|
|
1432
|
+
async function verificarListaAparelhos(quadro, opcoes) {
|
|
1433
|
+
const o = opcoes || {};
|
|
1434
|
+
const versaoAtual = o.versaoAtual === undefined ? 0 : o.versaoAtual;
|
|
1435
|
+
if (!inteiro(versaoAtual, 0, Number.MAX_SAFE_INTEGER)) falha('E_ARGUMENTO');
|
|
1436
|
+
exigirCampos(quadro, ['t', 'lista', 'assinatura']);
|
|
1437
|
+
if (quadro.t !== 'relay.lista') falha('E_FORMATO');
|
|
1438
|
+
const l = quadro.lista;
|
|
1439
|
+
try { exigirCampos(l, ['no', 'versao', 'emitidaEm', 'aparelhos']); } catch (_) { falha('E_LISTA_INVALIDA'); }
|
|
1440
|
+
if (!ehId(l.no) || !inteiro(l.versao, 1, Number.MAX_SAFE_INTEGER) || !inteiro(l.emitidaEm, 0, Number.MAX_SAFE_INTEGER)) falha('E_LISTA_INVALIDA');
|
|
1441
|
+
const noSpki = deB64u(o.noSpki);
|
|
1442
|
+
const noPub = await importarSpki(noSpki);
|
|
1443
|
+
if (idDeHash(await sha256(noSpki)) !== l.no) falha('E_ID');
|
|
1444
|
+
let dados;
|
|
1445
|
+
try { dados = utf8(canonico(l)); } catch (_) { falha('E_LISTA_INVALIDA'); }
|
|
1446
|
+
if (!(await verificarAssinatura(noPub, 'lista-aparelhos', dados, quadro.assinatura))) falha('E_ASSINATURA');
|
|
1447
|
+
if (l.versao <= versaoAtual) falha('E_LISTA_VERSAO');
|
|
1448
|
+
await validarEntradasLista(l.aparelhos);
|
|
1449
|
+
return l;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Esquemas fechados dos quadros de controle. `dados` é sempre texto opaco para o relay.
|
|
1453
|
+
const ESQUEMAS_RELAY = Object.freeze({
|
|
1454
|
+
'para-relay': {
|
|
1455
|
+
'relay.auth': [['v', 'papel', 'id', 'spki', 'assinatura']],
|
|
1456
|
+
'relay.lista': [['lista', 'assinatura']],
|
|
1457
|
+
'relay.parear.abrir': [['pid', 'exp']],
|
|
1458
|
+
'relay.parear.fechar': [['pid']],
|
|
1459
|
+
'parear.enviar': [['pid', 'dados']],
|
|
1460
|
+
'parear.responder': [['canal', 'dados']],
|
|
1461
|
+
'rota': [['para', 'dados']],
|
|
1462
|
+
'relay.ping': [[]],
|
|
1463
|
+
},
|
|
1464
|
+
'do-relay': {
|
|
1465
|
+
'relay.desafio': [['v', 'nonce', 'relay']],
|
|
1466
|
+
'relay.ok': [['papel', 'id']],
|
|
1467
|
+
'relay.erro': [['codigo']],
|
|
1468
|
+
'relay.presenca': [['no', 'online']],
|
|
1469
|
+
'rota': [['de', 'dados']],
|
|
1470
|
+
'parear.chegou': [['pid', 'dados'], ['canal']],
|
|
1471
|
+
'relay.pong': [[]],
|
|
1472
|
+
},
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
/** Valida um quadro de controle (já analisado). direcao: 'para-relay' | 'do-relay'. */
|
|
1476
|
+
function validarQuadroRelay(q, direcao) {
|
|
1477
|
+
const tabela = ESQUEMAS_RELAY[direcao];
|
|
1478
|
+
if (!tabela) falha('E_ARGUMENTO');
|
|
1479
|
+
if (!ehObjetoSimples(q) || typeof q.t !== 'string' || !Object.prototype.hasOwnProperty.call(tabela, q.t)) falha('E_FORMATO');
|
|
1480
|
+
const esq = tabela[q.t];
|
|
1481
|
+
exigirCampos(q, ['t'].concat(esq[0]), esq[1]);
|
|
1482
|
+
for (const k of Object.keys(q)) {
|
|
1483
|
+
const v = q[k];
|
|
1484
|
+
switch (k) {
|
|
1485
|
+
case 't': break;
|
|
1486
|
+
case 'v': if (v !== VERSAO) falha('E_VERSAO'); break;
|
|
1487
|
+
case 'id': case 'para': case 'de': case 'no': case 'pid': case 'canal':
|
|
1488
|
+
if (!ehId(v)) falha('E_FORMATO'); break;
|
|
1489
|
+
case 'dados':
|
|
1490
|
+
if (typeof v !== 'string' || v.length > LIMITES.DADOS_MAX) falha(typeof v === 'string' ? 'E_TAMANHO' : 'E_FORMATO');
|
|
1491
|
+
break;
|
|
1492
|
+
case 'papel': if (PAPEIS_RELAY.indexOf(v) < 0) falha('E_FORMATO'); break;
|
|
1493
|
+
case 'exp': if (!inteiro(v, 0, Number.MAX_SAFE_INTEGER)) falha('E_FORMATO'); break;
|
|
1494
|
+
case 'online': if (typeof v !== 'boolean') falha('E_FORMATO'); break;
|
|
1495
|
+
case 'codigo': if (typeof v !== 'string' || !RX_CODIGO.test(v)) falha('E_FORMATO'); break;
|
|
1496
|
+
case 'nonce': deB64u(v, 32); break;
|
|
1497
|
+
case 'assinatura': deB64u(v, 64); break;
|
|
1498
|
+
case 'spki': if (typeof v !== 'string' || v.length > 200) falha('E_FORMATO'); break;
|
|
1499
|
+
case 'relay': if (typeof v !== 'string' || v.length > 255) falha('E_FORMATO'); break;
|
|
1500
|
+
case 'lista': if (!ehObjetoSimples(v)) falha('E_FORMATO'); break;
|
|
1501
|
+
default: falha('E_FORMATO');
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return q;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** Tamanho + JSON + esquema, numa chamada. É o que o relay faz com cada mensagem. */
|
|
1508
|
+
function lerQuadroRelay(texto, direcao) {
|
|
1509
|
+
if (typeof texto !== 'string') falha('E_FORMATO');
|
|
1510
|
+
verificarTamanhoQuadro(texto);
|
|
1511
|
+
return validarQuadroRelay(lerJsonTexto(texto), direcao);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/** Lê o quadro fim a fim que viaja em `dados` (só nas pontas; o relay não chama isto). */
|
|
1515
|
+
function lerQuadroFimAFim(texto) {
|
|
1516
|
+
if (typeof texto !== 'string' || texto.length > LIMITES.DADOS_MAX) falha('E_TAMANHO');
|
|
1517
|
+
return lerJsonTexto(texto);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Balde de fichas. `taxa` fichas por segundo, até `rajada` acumuladas. */
|
|
1521
|
+
class LimitadorTaxa {
|
|
1522
|
+
constructor(opcoes) {
|
|
1523
|
+
const o = opcoes || {};
|
|
1524
|
+
if (!(o.taxa > 0) || !(o.rajada > 0)) falha('E_ARGUMENTO');
|
|
1525
|
+
this.taxa = o.taxa;
|
|
1526
|
+
this.rajada = o.rajada;
|
|
1527
|
+
this._fichas = o.rajada;
|
|
1528
|
+
this._t = relogio(o.agora);
|
|
1529
|
+
}
|
|
1530
|
+
permitir(custo, agora) {
|
|
1531
|
+
const c = custo === undefined ? 1 : custo;
|
|
1532
|
+
const t = relogio(agora);
|
|
1533
|
+
if (t > this._t) {
|
|
1534
|
+
this._fichas = Math.min(this.rajada, this._fichas + ((t - this._t) / 1000) * this.taxa);
|
|
1535
|
+
this._t = t;
|
|
1536
|
+
}
|
|
1537
|
+
if (c > this._fichas) return false;
|
|
1538
|
+
this._fichas -= c;
|
|
1539
|
+
return true;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** Limites padrão por conexão. papel 'anonimo' = conexão de pareamento sem identidade. */
|
|
1544
|
+
function criarLimitesConexao(papel, agora) {
|
|
1545
|
+
if (papel === 'anonimo') {
|
|
1546
|
+
return {
|
|
1547
|
+
quadros: new LimitadorTaxa({ taxa: LIMITES.ANON_QUADROS_MIN / 60, rajada: LIMITES.ANON_QUADROS_MIN, agora }),
|
|
1548
|
+
bytes: new LimitadorTaxa({ taxa: 8192, rajada: 2 * LIMITES.QUADRO_MAX, agora }),
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
return {
|
|
1552
|
+
quadros: new LimitadorTaxa({ taxa: LIMITES.TAXA_QUADROS_S, rajada: LIMITES.RAJADA_QUADROS, agora }),
|
|
1553
|
+
bytes: new LimitadorTaxa({ taxa: LIMITES.TAXA_BYTES_S, rajada: LIMITES.RAJADA_BYTES, agora }),
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
/** Lança E_TAXA se a mensagem (tamanho em bytes) estoura qualquer um dos dois baldes. */
|
|
1557
|
+
function exigirTaxa(limites, tamanho, agora) {
|
|
1558
|
+
const t = relogio(agora);
|
|
1559
|
+
if (!limites.quadros.permitir(1, t) || !limites.bytes.permitir(tamanho, t)) falha('E_TAXA');
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// ===========================================================================================
|
|
1563
|
+
// API pública
|
|
1564
|
+
// ===========================================================================================
|
|
1565
|
+
|
|
1566
|
+
return Object.freeze({
|
|
1567
|
+
VERSAO,
|
|
1568
|
+
LIMITES,
|
|
1569
|
+
CODIGOS,
|
|
1570
|
+
METODOS,
|
|
1571
|
+
TsProtoErro,
|
|
1572
|
+
|
|
1573
|
+
// codificação
|
|
1574
|
+
b64u,
|
|
1575
|
+
deB64u,
|
|
1576
|
+
hex,
|
|
1577
|
+
canonico,
|
|
1578
|
+
verificarTamanhoQuadro,
|
|
1579
|
+
lerQuadroFimAFim,
|
|
1580
|
+
|
|
1581
|
+
// primitivas
|
|
1582
|
+
sha256,
|
|
1583
|
+
hkdf,
|
|
1584
|
+
|
|
1585
|
+
// identidades
|
|
1586
|
+
gerarIdentidade,
|
|
1587
|
+
identidadeDeChaves,
|
|
1588
|
+
exportarIdentidadeNo,
|
|
1589
|
+
importarIdentidadeNo,
|
|
1590
|
+
idDeSpki,
|
|
1591
|
+
impressaoDeSpki,
|
|
1592
|
+
comprimirSpki,
|
|
1593
|
+
descomprimirSpki,
|
|
1594
|
+
|
|
1595
|
+
// pareamento
|
|
1596
|
+
RegistroPareamentos,
|
|
1597
|
+
lerConvite,
|
|
1598
|
+
validarOrigemPwa,
|
|
1599
|
+
criarPedidoPareamento,
|
|
1600
|
+
abrirRespostaPareamento,
|
|
1601
|
+
normalizarNome,
|
|
1602
|
+
|
|
1603
|
+
// sessão
|
|
1604
|
+
Sessao,
|
|
1605
|
+
aparelhoIniciarSessao,
|
|
1606
|
+
noResponderSessao,
|
|
1607
|
+
aparelhoConcluirSessao,
|
|
1608
|
+
noConcluirSessao,
|
|
1609
|
+
|
|
1610
|
+
// comandos
|
|
1611
|
+
validarParametros,
|
|
1612
|
+
aparelhoCriarComando,
|
|
1613
|
+
noAbrirComando,
|
|
1614
|
+
noResponder,
|
|
1615
|
+
noEnviarEvento,
|
|
1616
|
+
aparelhoAbrirMensagem,
|
|
1617
|
+
|
|
1618
|
+
// relay
|
|
1619
|
+
relayCriarDesafio,
|
|
1620
|
+
clienteResponderDesafio,
|
|
1621
|
+
relayVerificarAuth,
|
|
1622
|
+
noAssinarListaAparelhos,
|
|
1623
|
+
verificarListaAparelhos,
|
|
1624
|
+
validarQuadroRelay,
|
|
1625
|
+
lerQuadroRelay,
|
|
1626
|
+
LimitadorTaxa,
|
|
1627
|
+
criarLimitesConexao,
|
|
1628
|
+
exigirTaxa,
|
|
1629
|
+
|
|
1630
|
+
// só para testes de vetores (mesmo caminho de código da produção)
|
|
1631
|
+
_interno: Object.freeze({
|
|
1632
|
+
derivarChavesSessaoBrutas,
|
|
1633
|
+
nonceQuadro,
|
|
1634
|
+
aadQuadro,
|
|
1635
|
+
rotulo,
|
|
1636
|
+
montarSessao: function (p, brutas) {
|
|
1637
|
+
return montarSessao({
|
|
1638
|
+
sid: p.sid, papel: p.papel, parId: p.parId, proprioId: p.proprioId, criadaEm: relogio(p.criadaEm), parPub: p.parPub || null,
|
|
1639
|
+
}, { a2n: comoBytes(brutas.a2n).slice(), n2a: comoBytes(brutas.n2a).slice() });
|
|
1640
|
+
},
|
|
1641
|
+
}),
|
|
1642
|
+
});
|
|
1643
|
+
});
|