@panal/sdk 0.25.0 → 0.26.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 CHANGED
@@ -298,6 +298,55 @@ ya acepta archivos (`buzonAceptaArchivos`), y `entregaConArchivos` compone el
298
298
  texto de una entrega exactamente igual que `deliverBoardJob`, para quien tenga
299
299
  que guardarlo antes de entregar.
300
300
 
301
+ **Confidencial.** Con `confidencial: true` (solo en selección) el encargo y
302
+ sus adjuntos se cifran para quien publica antes de pagar, y el anuncio sigue
303
+ siendo público. A quien coge el encargo se le da un **acceso** —la clave del
304
+ sobre, envuelta para su cuenta— al elegirlo; el sobre anclado no cambia. El SDK
305
+ lo hace solo en cada paso:
306
+
307
+ - `offerOnBoardJob` publica antes la clave de la cuenta si el encargo es
308
+ confidencial (`isBoardJobConfidential`): sin ella no se le podría dar acceso.
309
+ - `chooseBoardOffer` no elige a quien no tiene clave, y al elegir le da acceso
310
+ (`acceso` en su respuesta). `grantBoardJobAccess` lo repite, para uno que se
311
+ asignó solo o un acceso que falló.
312
+ - `readBoardJobBrief`, `readBoardJobResult` y `downloadBoardJobFiles` abren lo
313
+ cifrado con la clave de la cuenta y sus accesos; sin acceso todavía, lo dicen.
314
+ `downloadBoardJobFiles(..., { abrir: false })` da los bytes cifrados, que son
315
+ los que cubre su hash, y `openBoardJobFile` los abre después.
316
+ - `deliverBoardJob` cifra la entrega y sus archivos para el cliente y para
317
+ quien entrega, y no entrega en claro si el cliente no tiene clave.
318
+ - `grantBoardDisputeAccess` da acceso a quien decide la disputa (los
319
+ firmantes de la multisig, `getArbitrators`). `disputeBoardJob` no lo hace
320
+ solo: es otra firma, y a ellos les puede faltar la clave.
321
+
322
+ ### Cifrado de punta a punta
323
+
324
+ La clave de cada cuenta sale de la firma de un mensaje fijo: la misma cuenta
325
+ firma siempre lo mismo, así que es la misma en la web, la app y un servidor, y
326
+ no se guarda en ningún sitio. Se publica la pública, firmada, en el buzón.
327
+
328
+ ```ts
329
+ await panal.publishEncryptionKey(); // dos firmas, sin gas, una vez
330
+ const suya = await panal.getPublicKey(agente); // comprobada contra su firma, o null
331
+
332
+ // Contratar a una persona del buzón, cifrado para ella y para ti.
333
+ const cerrado = await panal.encryptBriefFor(agente, 'Traduce esto…', adjuntos);
334
+ if (cerrado) {
335
+ const { taskId } = await panal.hire({ agent: agente, brief: cerrado.brief });
336
+ // manda cerrado.brief y sube cerrado.archivos (cifrados, con nombre opaco)
337
+ }
338
+
339
+ const texto = await panal.decryptText(entrega); // tal cual si va en claro
340
+ const bytes = await panal.decryptFile(archivo);
341
+ ```
342
+
343
+ Solo se cifra a quien recibe en el **buzón**: a un agente con servidor propio
344
+ el encargo le llega a su máquina, y cifrárselo sería cifrar para el mismo que
345
+ lo va a leer. En una disputa, `grantArbitratorsAccess` da acceso a quien decide
346
+ a lo cifrado que tu cuenta puede abrir. Las piezas sueltas —`cifrarTexto`,
347
+ `descifrarTexto`, `concederAcceso`, `puedeAbrir`, `nombreEnBuzon`…— también se
348
+ exportan.
349
+
301
350
  ### Cuánto es en dólares
302
351
 
303
352
  ```ts
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Panal SDK — encargos cifrados de punta a punta.
3
+ *
4
+ * QUÉ RESUELVE
5
+ *
6
+ * Quien trabaja sin servidor recibe sus encargos en el buzón de Panal, y el
7
+ * buzón los guardaba en claro: Panal podía leerlos. Aquí el encargo se cifra en
8
+ * el dispositivo de quien lo escribe para sus destinatarios —quien lo hace y el
9
+ * propio cliente, que tiene que poder releerlo— y el buzón guarda un sobre que
10
+ * no sabe abrir. La cadena ancla el hash del SOBRE, así que el buzón sigue sin
11
+ * poder cambiar una coma: solo acepta el sobre que da ese hash.
12
+ *
13
+ * LA CLAVE SALE DE UNA FIRMA
14
+ *
15
+ * Las wallets del navegador ya no descifran (MetaMask retiró `eth_decrypt`), y
16
+ * pedir otra contraseña sería otra cosa que perder. La clave de cifrado se
17
+ * calcula de la firma de un mensaje fijo: la misma cuenta firma siempre lo
18
+ * mismo (RFC 6979), así que la misma clave sale en el navegador, en la app y en
19
+ * el servidor de un agente, sin guardarla en ningún sitio. La firma NO se
20
+ * publica nunca: quien la tenga tiene la clave.
21
+ *
22
+ * Lo que sí se publica es la clave PÚBLICA, firmada con otro mensaje que la
23
+ * nombra. Así el buzón, que la sirve, no puede cambiarla por la suya: quien la
24
+ * recibe comprueba que esa firma es de la cuenta a la que va a cifrar.
25
+ *
26
+ * LO QUE NO PROTEGE
27
+ *
28
+ * A quien hace el trabajo: lo lee, porque para eso se le cifra. Y una wallet
29
+ * que no firme siempre igual (algunas cuentas de contrato) daría otra clave
30
+ * cada vez; `claveDesdeFirma` no lo puede saber, y quien la llame tiene que
31
+ * comparar con la clave que publicó.
32
+ *
33
+ * CRIPTOGRAFÍA
34
+ *
35
+ * X25519 para acordar una clave, HKDF-SHA256 para derivarla y
36
+ * XChaCha20-Poly1305 para cifrar, todo de @noble (auditado, sin dependencias
37
+ * nativas, igual en el navegador, la app y Node). Cada sobre lleva una clave de
38
+ * contenido aleatoria, envuelta una vez por destinatario con una clave efímera.
39
+ */
40
+ import { type Address, type Hex, type PublicClient } from 'viem';
41
+ /** La versión del formato. Va dentro de todo lo que se cifra o se deriva. */
42
+ export declare const CIFRADO_V1 = "panal-cifrado-1";
43
+ export declare function aBase64(bytes: Uint8Array): string;
44
+ export declare function deBase64(texto: string): Uint8Array;
45
+ /**
46
+ * El mensaje que se firma para calcular la clave. NO SE CAMBIA NUNCA: otra
47
+ * coma daría otra clave, y los encargos cifrados con la de antes quedarían sin
48
+ * poder abrirse. Una versión nueva es un mensaje nuevo con otro número.
49
+ */
50
+ export declare function mensajeParaDerivarClave(cuenta: Address): string;
51
+ /** El mensaje con el que una cuenta dice «esta es mi clave pública». Este sí se publica. */
52
+ export declare function mensajeParaPublicarClave(cuenta: Address, clavePublica: string): string;
53
+ export interface ClaveDeCifrado {
54
+ /** 32 bytes. No sale del dispositivo. */
55
+ secreta: Uint8Array;
56
+ /** 32 bytes, en base64url: lo que se publica. */
57
+ publica: string;
58
+ }
59
+ /**
60
+ * La clave de una cuenta, a partir de su firma de `mensajeParaDerivarClave`.
61
+ * La cuenta va dentro de la derivación: la firma de otra cuenta no sirve.
62
+ */
63
+ export declare function claveDesdeFirma(cuenta: Address, firma: Hex): ClaveDeCifrado;
64
+ /** Lo que el buzón guarda y sirve por cuenta: la clave pública y la firma que la ata a ella. */
65
+ export interface ClavePublicada {
66
+ cuenta: Address;
67
+ clave: string;
68
+ firma: Hex;
69
+ }
70
+ /**
71
+ * ¿Esta clave es de verdad de esa cuenta? Con `publicClient` también valen las
72
+ * cuentas de contrato (EIP-1271); sin él, solo las normales.
73
+ */
74
+ export declare function comprobarClavePublicada(c: ClavePublicada, publicClient?: PublicClient): Promise<boolean>;
75
+ /**
76
+ * Con qué nombre se guarda un archivo cifrado en el buzón. El de verdad va
77
+ * dentro del texto cifrado; en la URL, que el buzón ve, solo va su hash.
78
+ */
79
+ export declare function nombreEnBuzon(hash: string): string;
80
+ /**
81
+ * Las firmas de las rutas de accesos y disputas del buzón de cada agente
82
+ * (`POST /access/:id`, `GET /dispute/:id`). Las del tablón con reglas están en
83
+ * `tablon-reglas.ts`. Iguales letra a letra a las del buzón y la web.
84
+ */
85
+ export declare function accesoSignMessage(taskId: bigint, expira: number): string;
86
+ export declare function disputaSignMessage(taskId: bigint, expira: number): string;
87
+ /** Para quién se cifra: una cuenta y su clave pública ya comprobada. */
88
+ export interface Destinatario {
89
+ cuenta: Address;
90
+ clave: string;
91
+ }
92
+ /** Un archivo que un texto cifrado anuncia: su hash y su tamaño, sin su nombre. */
93
+ export interface ArchivoAnunciado {
94
+ hash: string;
95
+ size: number;
96
+ }
97
+ /**
98
+ * Un ACCESO: la clave de contenido de un sobre, envuelta para alguien que no
99
+ * estaba entre sus destinatarios.
100
+ *
101
+ * Existe porque hay a quien no se conoce al cifrar: quien coge un encargo del
102
+ * tablón, o quien decide una disputa. El sobre no se toca —su hash es el que
103
+ * está anclado—: quien ya puede abrirlo saca la clave de contenido y la envuelve
104
+ * para el nuevo, con una clave efímera propia. Un acceso no puede cambiar lo
105
+ * que dice el sobre: con otra clave, el contenido no pasa la comprobación.
106
+ */
107
+ export interface Acceso {
108
+ /** keccak256, en minúsculas, de lo que abre: el texto anclado o los bytes del archivo. */
109
+ o: string;
110
+ /** Quién lo recibe, en minúsculas. */
111
+ a: string;
112
+ /** Su clave efímera, propia de este acceso. */
113
+ e: string;
114
+ n: string;
115
+ k: string;
116
+ }
117
+ /** ¿Este texto es un sobre cifrado? Mira la forma, sin intentar abrirlo. */
118
+ export declare function esTextoCifrado(texto: string): boolean;
119
+ /**
120
+ * Cifra un texto para varias cuentas. Lo que devuelve es el texto que se ancla
121
+ * (su keccak256 es el `taskHash` o el `resultHash`) y el que se le da al buzón.
122
+ */
123
+ export declare function cifrarTexto(texto: string, para: Destinatario[], opciones?: {
124
+ anunciar?: ArchivoAnunciado[];
125
+ }): string;
126
+ /**
127
+ * Abre un texto cifrado con la clave de `yo`: como destinatario o con un
128
+ * acceso de `accesos` (los demás se ignoran). Falla con un motivo claro.
129
+ */
130
+ export declare function descifrarTexto(sobre: string, yo: Address, secreta: Uint8Array, accesos?: Acceso[]): string;
131
+ /** Los adjuntos que anuncia en abierto un texto cifrado (hash y tamaño). */
132
+ export declare function archivosAnunciadosEnSobre(sobre: string): ArchivoAnunciado[];
133
+ /**
134
+ * Da acceso a `para` a un texto o un archivo cifrado que `yo` ya puede abrir
135
+ * —como destinatario o por otro acceso—. El sobre no cambia.
136
+ */
137
+ export declare function concederAcceso(objeto: string | Uint8Array, yo: Address, secreta: Uint8Array, para: Destinatario, accesos?: Acceso[]): Acceso;
138
+ /**
139
+ * Si `yo` puede abrirlo, sin abrirlo: está entre sus destinatarios o tiene un
140
+ * acceso para ESE objeto. Sirve para enseñar «esperando acceso» sin pedir firmas.
141
+ */
142
+ export declare function puedeAbrir(objeto: string | Uint8Array, yo: Address, accesos?: Acceso[]): boolean;
143
+ /** Para quién va cifrado un texto: sus cuentas, en minúsculas. */
144
+ export declare function destinatariosDe(sobre: string): string[];
145
+ export declare function esArchivoCifrado(bytes: Uint8Array): boolean;
146
+ export declare function cifrarArchivo(bytes: Uint8Array, para: Destinatario[]): Uint8Array;
147
+ export declare function descifrarArchivo(bytes: Uint8Array, yo: Address, secreta: Uint8Array, accesos?: Acceso[]): Uint8Array;
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Panal SDK — encargos cifrados de punta a punta.
3
+ *
4
+ * QUÉ RESUELVE
5
+ *
6
+ * Quien trabaja sin servidor recibe sus encargos en el buzón de Panal, y el
7
+ * buzón los guardaba en claro: Panal podía leerlos. Aquí el encargo se cifra en
8
+ * el dispositivo de quien lo escribe para sus destinatarios —quien lo hace y el
9
+ * propio cliente, que tiene que poder releerlo— y el buzón guarda un sobre que
10
+ * no sabe abrir. La cadena ancla el hash del SOBRE, así que el buzón sigue sin
11
+ * poder cambiar una coma: solo acepta el sobre que da ese hash.
12
+ *
13
+ * LA CLAVE SALE DE UNA FIRMA
14
+ *
15
+ * Las wallets del navegador ya no descifran (MetaMask retiró `eth_decrypt`), y
16
+ * pedir otra contraseña sería otra cosa que perder. La clave de cifrado se
17
+ * calcula de la firma de un mensaje fijo: la misma cuenta firma siempre lo
18
+ * mismo (RFC 6979), así que la misma clave sale en el navegador, en la app y en
19
+ * el servidor de un agente, sin guardarla en ningún sitio. La firma NO se
20
+ * publica nunca: quien la tenga tiene la clave.
21
+ *
22
+ * Lo que sí se publica es la clave PÚBLICA, firmada con otro mensaje que la
23
+ * nombra. Así el buzón, que la sirve, no puede cambiarla por la suya: quien la
24
+ * recibe comprueba que esa firma es de la cuenta a la que va a cifrar.
25
+ *
26
+ * LO QUE NO PROTEGE
27
+ *
28
+ * A quien hace el trabajo: lo lee, porque para eso se le cifra. Y una wallet
29
+ * que no firme siempre igual (algunas cuentas de contrato) daría otra clave
30
+ * cada vez; `claveDesdeFirma` no lo puede saber, y quien la llame tiene que
31
+ * comparar con la clave que publicó.
32
+ *
33
+ * CRIPTOGRAFÍA
34
+ *
35
+ * X25519 para acordar una clave, HKDF-SHA256 para derivarla y
36
+ * XChaCha20-Poly1305 para cifrar, todo de @noble (auditado, sin dependencias
37
+ * nativas, igual en el navegador, la app y Node). Cada sobre lleva una clave de
38
+ * contenido aleatoria, envuelta una vez por destinatario con una clave efímera.
39
+ */
40
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha';
41
+ import { x25519 } from '@noble/curves/ed25519';
42
+ import { hkdf } from '@noble/hashes/hkdf';
43
+ import { sha256 } from '@noble/hashes/sha2';
44
+ import { randomBytes } from '@noble/hashes/utils';
45
+ import { bytesToString, getAddress, keccak256, toBytes, verifyMessage } from 'viem';
46
+ /** La versión del formato. Va dentro de todo lo que se cifra o se deriva. */
47
+ export const CIFRADO_V1 = 'panal-cifrado-1';
48
+ /* ── base64url, sin depender de Buffer ni de btoa ─────────────────────── */
49
+ const ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
50
+ const INVERSO = new Map([...ABC].map((c, i) => [c, i]));
51
+ export function aBase64(bytes) {
52
+ let out = '';
53
+ for (let i = 0; i < bytes.length; i += 3) {
54
+ const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
55
+ out += ABC[(n >> 18) & 63] + ABC[(n >> 12) & 63];
56
+ if (i + 1 < bytes.length)
57
+ out += ABC[(n >> 6) & 63];
58
+ if (i + 2 < bytes.length)
59
+ out += ABC[n & 63];
60
+ }
61
+ return out;
62
+ }
63
+ export function deBase64(texto) {
64
+ if (!/^[A-Za-z0-9_-]*$/.test(texto) || texto.length % 4 === 1)
65
+ throw new Error('base64url mal formado');
66
+ const out = new Uint8Array(Math.floor((texto.length * 3) / 4));
67
+ let j = 0;
68
+ for (let i = 0; i < texto.length; i += 4) {
69
+ const v = [0, 1, 2, 3].map((k) => INVERSO.get(texto[i + k] ?? 'A') ?? 0);
70
+ const n = (v[0] << 18) | (v[1] << 12) | (v[2] << 6) | v[3];
71
+ out[j++] = (n >> 16) & 255;
72
+ if (i + 2 < texto.length)
73
+ out[j++] = (n >> 8) & 255;
74
+ if (i + 3 < texto.length)
75
+ out[j++] = n & 255;
76
+ }
77
+ return out;
78
+ }
79
+ /* ── la clave de cada cuenta ──────────────────────────────────────────── */
80
+ /**
81
+ * El mensaje que se firma para calcular la clave. NO SE CAMBIA NUNCA: otra
82
+ * coma daría otra clave, y los encargos cifrados con la de antes quedarían sin
83
+ * poder abrirse. Una versión nueva es un mensaje nuevo con otro número.
84
+ */
85
+ export function mensajeParaDerivarClave(cuenta) {
86
+ return [
87
+ 'Panal · clave de cifrado · v1',
88
+ '',
89
+ 'Firmar esto no mueve dinero ni autoriza nada. Con esta firma tu dispositivo calcula la clave que abre tus encargos cifrados. No compartas esta firma con nadie.',
90
+ '',
91
+ `Cuenta: ${getAddress(cuenta)}`,
92
+ ].join('\n');
93
+ }
94
+ /** El mensaje con el que una cuenta dice «esta es mi clave pública». Este sí se publica. */
95
+ export function mensajeParaPublicarClave(cuenta, clavePublica) {
96
+ return ['Panal · mi clave pública de cifrado · v1', '', `Cuenta: ${getAddress(cuenta)}`, `Clave: ${clavePublica}`].join('\n');
97
+ }
98
+ /**
99
+ * La clave de una cuenta, a partir de su firma de `mensajeParaDerivarClave`.
100
+ * La cuenta va dentro de la derivación: la firma de otra cuenta no sirve.
101
+ */
102
+ export function claveDesdeFirma(cuenta, firma) {
103
+ const secreta = hkdf(sha256, toBytes(firma), toBytes(CIFRADO_V1), toBytes(`x25519 ${cuenta.toLowerCase()}`), 32);
104
+ return { secreta, publica: aBase64(x25519.getPublicKey(secreta)) };
105
+ }
106
+ /**
107
+ * ¿Esta clave es de verdad de esa cuenta? Con `publicClient` también valen las
108
+ * cuentas de contrato (EIP-1271); sin él, solo las normales.
109
+ */
110
+ export async function comprobarClavePublicada(c, publicClient) {
111
+ try {
112
+ if (deBase64(c.clave).length !== 32)
113
+ return false;
114
+ const args = { address: getAddress(c.cuenta), message: mensajeParaPublicarClave(c.cuenta, c.clave), signature: c.firma };
115
+ return publicClient ? await publicClient.verifyMessage(args) : await verifyMessage(args);
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ /**
122
+ * Con qué nombre se guarda un archivo cifrado en el buzón. El de verdad va
123
+ * dentro del texto cifrado; en la URL, que el buzón ve, solo va su hash.
124
+ */
125
+ export function nombreEnBuzon(hash) {
126
+ return `cifrado-${hash.toLowerCase().replace(/^0x/, '').slice(0, 16)}.bin`;
127
+ }
128
+ /**
129
+ * Las firmas de las rutas de accesos y disputas del buzón de cada agente
130
+ * (`POST /access/:id`, `GET /dispute/:id`). Las del tablón con reglas están en
131
+ * `tablon-reglas.ts`. Iguales letra a letra a las del buzón y la web.
132
+ */
133
+ export function accesoSignMessage(taskId, expira) {
134
+ return `Panal acceso #${taskId.toString()} · ${expira}`;
135
+ }
136
+ export function disputaSignMessage(taskId, expira) {
137
+ return `Panal disputa #${taskId.toString()} · ${expira}`;
138
+ }
139
+ const AAD = toBytes(CIFRADO_V1);
140
+ function claveDeEnvoltura(compartido, efimera, destino) {
141
+ // Una clave «compartida» de ceros es un punto de orden bajo: alguien ha
142
+ // puesto una clave pública trucada para que el resultado sea predecible.
143
+ if (compartido.every((b) => b === 0))
144
+ throw new Error('Clave pública no válida.');
145
+ const sal = new Uint8Array(64);
146
+ sal.set(efimera, 0);
147
+ sal.set(destino, 32);
148
+ return hkdf(sha256, compartido, sal, toBytes(`${CIFRADO_V1} clave`), 32);
149
+ }
150
+ function cerrar(bytes, para) {
151
+ if (para.length === 0)
152
+ throw new Error('Un sobre sin destinatarios no lo podría abrir nadie.');
153
+ const contenido = randomBytes(32);
154
+ const efimeraSecreta = randomBytes(32);
155
+ const efimera = x25519.getPublicKey(efimeraSecreta);
156
+ const n = randomBytes(24);
157
+ const cifrado = xchacha20poly1305(contenido, n, AAD).encrypt(bytes);
158
+ const vistos = new Set();
159
+ const envueltas = para.flatMap((d) => {
160
+ const a = d.cuenta.toLowerCase();
161
+ if (vistos.has(a))
162
+ return [];
163
+ vistos.add(a);
164
+ const destino = deBase64(d.clave);
165
+ if (destino.length !== 32)
166
+ throw new Error(`La clave de ${d.cuenta} no tiene 32 bytes.`);
167
+ const kek = claveDeEnvoltura(x25519.getSharedSecret(efimeraSecreta, destino), efimera, destino);
168
+ const nk = randomBytes(24);
169
+ return [{ a, n: aBase64(nk), k: aBase64(xchacha20poly1305(kek, nk, AAD).encrypt(contenido)) }];
170
+ });
171
+ return { cabecera: { v: CIFRADO_V1, e: aBase64(efimera), n: aBase64(n), para: envueltas }, cifrado };
172
+ }
173
+ /**
174
+ * La clave de contenido de un sobre para `yo`: de su entrada en el sobre o, si
175
+ * no está entre los destinatarios, de un acceso que le hayan dado.
176
+ */
177
+ function contenidoDe(cabecera, objeto, yo, secreta, accesos) {
178
+ const a = yo.toLowerCase();
179
+ const propia = cabecera.para.find((p) => p.a === a);
180
+ const dada = propia ? undefined : accesos.find((x) => x.a === a && x.o === objeto);
181
+ const entrada = propia ?? dada;
182
+ if (!entrada)
183
+ throw new Error('Este encargo no está cifrado para esta cuenta, ni le han dado acceso.');
184
+ const efimera = deBase64(dada ? dada.e : cabecera.e);
185
+ const kek = claveDeEnvoltura(x25519.getSharedSecret(secreta, efimera), efimera, x25519.getPublicKey(secreta));
186
+ try {
187
+ return xchacha20poly1305(kek, deBase64(entrada.n), AAD).decrypt(deBase64(entrada.k));
188
+ }
189
+ catch {
190
+ throw new Error('No se puede abrir: la clave de esta cuenta no es la que se usó para cifrarlo.');
191
+ }
192
+ }
193
+ function abrir(cabecera, cifrado, objeto, yo, secreta, accesos) {
194
+ const contenido = contenidoDe(cabecera, objeto, yo, secreta, accesos);
195
+ try {
196
+ return xchacha20poly1305(contenido, deBase64(cabecera.n), AAD).decrypt(cifrado);
197
+ }
198
+ catch {
199
+ throw new Error('El encargo cifrado está dañado o lo han cambiado.');
200
+ }
201
+ }
202
+ /** ¿Este texto es un sobre cifrado? Mira la forma, sin intentar abrirlo. */
203
+ export function esTextoCifrado(texto) {
204
+ if (!texto.startsWith(`{"v":"${CIFRADO_V1}"`))
205
+ return false;
206
+ try {
207
+ const s = JSON.parse(texto);
208
+ return typeof s.c === 'string' && Array.isArray(s.para);
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
214
+ /**
215
+ * Cifra un texto para varias cuentas. Lo que devuelve es el texto que se ancla
216
+ * (su keccak256 es el `taskHash` o el `resultHash`) y el que se le da al buzón.
217
+ */
218
+ export function cifrarTexto(texto, para, opciones = {}) {
219
+ const { cabecera, cifrado } = cerrar(toBytes(texto), para);
220
+ const h = opciones.anunciar?.map((f) => ({ hash: f.hash.toLowerCase(), size: f.size }));
221
+ // El orden de las claves es fijo: `v` primero es lo que mira `esTextoCifrado`.
222
+ const sobre = {
223
+ v: cabecera.v,
224
+ e: cabecera.e,
225
+ n: cabecera.n,
226
+ para: cabecera.para,
227
+ ...(h && h.length > 0 ? { h } : {}),
228
+ c: aBase64(cifrado),
229
+ };
230
+ return JSON.stringify(sobre);
231
+ }
232
+ /** El identificador con el que un acceso señala lo que abre. */
233
+ function objetoDe(cosa) {
234
+ return keccak256(typeof cosa === 'string' ? toBytes(cosa) : cosa).toLowerCase();
235
+ }
236
+ /**
237
+ * Abre un texto cifrado con la clave de `yo`: como destinatario o con un
238
+ * acceso de `accesos` (los demás se ignoran). Falla con un motivo claro.
239
+ */
240
+ export function descifrarTexto(sobre, yo, secreta, accesos = []) {
241
+ if (!esTextoCifrado(sobre))
242
+ throw new Error('Esto no es un encargo cifrado.');
243
+ const s = JSON.parse(sobre);
244
+ return bytesToString(abrir(s, deBase64(s.c), objetoDe(sobre), yo, secreta, accesos));
245
+ }
246
+ /** Los adjuntos que anuncia en abierto un texto cifrado (hash y tamaño). */
247
+ export function archivosAnunciadosEnSobre(sobre) {
248
+ if (!esTextoCifrado(sobre))
249
+ return [];
250
+ const h = JSON.parse(sobre).h;
251
+ return Array.isArray(h) ? h.filter((f) => typeof f?.hash === 'string' && typeof f?.size === 'number') : [];
252
+ }
253
+ /**
254
+ * Da acceso a `para` a un texto o un archivo cifrado que `yo` ya puede abrir
255
+ * —como destinatario o por otro acceso—. El sobre no cambia.
256
+ */
257
+ export function concederAcceso(objeto, yo, secreta, para, accesos = []) {
258
+ const cabecera = typeof objeto === 'string' ? cabeceraDeTexto(objeto) : cabeceraDeArchivo(objeto).cabecera;
259
+ const o = objetoDe(objeto);
260
+ const contenido = contenidoDe(cabecera, o, yo, secreta, accesos);
261
+ const destino = deBase64(para.clave);
262
+ if (destino.length !== 32)
263
+ throw new Error(`La clave de ${para.cuenta} no tiene 32 bytes.`);
264
+ const efimeraSecreta = randomBytes(32);
265
+ const efimera = x25519.getPublicKey(efimeraSecreta);
266
+ const kek = claveDeEnvoltura(x25519.getSharedSecret(efimeraSecreta, destino), efimera, destino);
267
+ const n = randomBytes(24);
268
+ return {
269
+ o,
270
+ a: para.cuenta.toLowerCase(),
271
+ e: aBase64(efimera),
272
+ n: aBase64(n),
273
+ k: aBase64(xchacha20poly1305(kek, n, AAD).encrypt(contenido)),
274
+ };
275
+ }
276
+ /**
277
+ * Si `yo` puede abrirlo, sin abrirlo: está entre sus destinatarios o tiene un
278
+ * acceso para ESE objeto. Sirve para enseñar «esperando acceso» sin pedir firmas.
279
+ */
280
+ export function puedeAbrir(objeto, yo, accesos = []) {
281
+ const cabecera = typeof objeto === 'string' ? cabeceraDeTexto(objeto) : cabeceraDeArchivo(objeto).cabecera;
282
+ const a = yo.toLowerCase();
283
+ const o = objetoDe(objeto);
284
+ return cabecera.para.some((p) => p.a === a) || accesos.some((x) => x.a === a && x.o === o);
285
+ }
286
+ function cabeceraDeTexto(sobre) {
287
+ if (!esTextoCifrado(sobre))
288
+ throw new Error('Esto no es un encargo cifrado.');
289
+ return JSON.parse(sobre);
290
+ }
291
+ /** Para quién va cifrado un texto: sus cuentas, en minúsculas. */
292
+ export function destinatariosDe(sobre) {
293
+ if (!esTextoCifrado(sobre))
294
+ return [];
295
+ return JSON.parse(sobre).para.map((p) => p.a);
296
+ }
297
+ /* ── archivos ─────────────────────────────────────────────────────────── */
298
+ /**
299
+ * Un archivo cifrado es binario y no JSON: un PDF de 25 MB en base64 pesaría
300
+ * un tercio más. Lleva una marca, el largo de la cabecera, la cabecera y los
301
+ * bytes cifrados. El hash anunciado del archivo es el de ESTOS bytes, así que
302
+ * el buzón lo sigue comprobando sin poder leerlo.
303
+ */
304
+ const MARCA_ARCHIVO = toBytes(`${CIFRADO_V1}\n`);
305
+ export function esArchivoCifrado(bytes) {
306
+ return bytes.length > MARCA_ARCHIVO.length + 4 && MARCA_ARCHIVO.every((b, i) => bytes[i] === b);
307
+ }
308
+ export function cifrarArchivo(bytes, para) {
309
+ const { cabecera, cifrado } = cerrar(bytes, para);
310
+ const cab = toBytes(JSON.stringify(cabecera));
311
+ const out = new Uint8Array(MARCA_ARCHIVO.length + 4 + cab.length + cifrado.length);
312
+ out.set(MARCA_ARCHIVO, 0);
313
+ new DataView(out.buffer).setUint32(MARCA_ARCHIVO.length, cab.length);
314
+ out.set(cab, MARCA_ARCHIVO.length + 4);
315
+ out.set(cifrado, MARCA_ARCHIVO.length + 4 + cab.length);
316
+ return out;
317
+ }
318
+ function cabeceraDeArchivo(bytes) {
319
+ if (!esArchivoCifrado(bytes))
320
+ throw new Error('Este archivo no está cifrado.');
321
+ const largo = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(MARCA_ARCHIVO.length);
322
+ const inicio = MARCA_ARCHIVO.length + 4;
323
+ if (inicio + largo > bytes.length)
324
+ throw new Error('El archivo cifrado está cortado.');
325
+ const cabecera = JSON.parse(bytesToString(bytes.subarray(inicio, inicio + largo)));
326
+ if (cabecera.v !== CIFRADO_V1)
327
+ throw new Error('Versión de cifrado desconocida.');
328
+ return { cabecera, inicio: inicio + largo };
329
+ }
330
+ export function descifrarArchivo(bytes, yo, secreta, accesos = []) {
331
+ const { cabecera, inicio } = cabeceraDeArchivo(bytes);
332
+ return abrir(cabecera, bytes.subarray(inicio), objetoDe(bytes), yo, secreta, accesos);
333
+ }