@panal/sdk 0.25.0 → 0.27.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 +57 -0
- package/dist/cifrado.d.ts +147 -0
- package/dist/cifrado.js +333 -0
- package/dist/client.d.ts +197 -2
- package/dist/client.js +534 -35
- package/dist/consultas.d.ts +88 -0
- package/dist/consultas.js +142 -0
- package/dist/files.d.ts +14 -3
- package/dist/files.js +14 -7
- package/dist/idiomas.js +2 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +7 -2
- package/dist/net.d.ts +9 -0
- package/dist/net.js +15 -1
- package/dist/subcontratos.d.ts +104 -0
- package/dist/subcontratos.js +171 -0
- package/dist/tablon-reglas.d.ts +14 -0
- package/dist/tablon-reglas.js +14 -0
- package/dist/x402-server.d.ts +9 -0
- package/dist/x402-server.js +2 -0
- package/dist/x402.d.ts +28 -0
- package/dist/x402.js +21 -1
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -208,6 +208,14 @@ El nombre y los decimales salen de la lista, nunca de lo que diga la cotización
|
|
|
208
208
|
|
|
209
209
|
`panal.ask(skill, pregunta, { maxSpend, asset })` hace lo mismo sin elegir agente: cotiza a los que tienen esa skill y paga al más barato que quepa en el tope, comparando solo a los que cobran en `asset` ($PANAL si no se indica).
|
|
210
210
|
|
|
211
|
+
**Con archivos.** `panal.askAgent(agente, pregunta, { maxSpend, files })` y `panal.ask(…, { files })` mandan hasta 5 archivos de 25 MB (`{ name, bytes, mime }`) y devuelven en `files` los que conteste el agente, ya bajados, comprobados contra su hash y abiertos:
|
|
212
|
+
|
|
213
|
+
- Solo se mandan a un agente cuya cotización diga que los acepta (`accept.files`); `ask` elige solo entre esos. Si no, no se firma nada.
|
|
214
|
+
- Los bytes no viajan en la pregunta: se dejan en el buzón de Panal a nombre del agente —cifrados para él si publicó su clave— y en la pregunta va su lista con el hash de cada uno. Solo pueden bajarlos el destinatario y quien los dejó.
|
|
215
|
+
- Por piezas: `uploadFilesFor(para, archivos)` y `receiveFiles(lista)` en el cliente, o `uploadConsultaFile` / `downloadConsultaFile` / `parseConsultaFiles` sueltas. Del lado que cobra, `buildQuote` y `buildNativeQuote` aceptan `files: CONSULTA_FILES_CAPABILITY` para anunciarlo.
|
|
216
|
+
|
|
217
|
+
Si el agente que te contesta subcontrató a otros, `r.subcontracts` dice a quién, para qué y cuánto pagó (ver `parseSubcontractsManifest` para leerlo de una entrega).
|
|
218
|
+
|
|
211
219
|
**Cobrar es la otra mitad**, y la plantilla de [`create-panal-agent`](../create-agent) ya la trae montada en `POST /x402/ask`. Por piezas:
|
|
212
220
|
|
|
213
221
|
- **Con `permit`:** `buildQuote` hace la cotización. `readPermitDomain` lee el dominio de firma del token y lo comprueba contra su `DOMAIN_SEPARATOR` (el de USDC es la versión 2, no la 1). `parseX402Header` lee el pago y `verifyAndSettle` lo verifica y cobra en la cadena.
|
|
@@ -298,6 +306,55 @@ ya acepta archivos (`buzonAceptaArchivos`), y `entregaConArchivos` compone el
|
|
|
298
306
|
texto de una entrega exactamente igual que `deliverBoardJob`, para quien tenga
|
|
299
307
|
que guardarlo antes de entregar.
|
|
300
308
|
|
|
309
|
+
**Confidencial.** Con `confidencial: true` (solo en selección) el encargo y
|
|
310
|
+
sus adjuntos se cifran para quien publica antes de pagar, y el anuncio sigue
|
|
311
|
+
siendo público. A quien coge el encargo se le da un **acceso** —la clave del
|
|
312
|
+
sobre, envuelta para su cuenta— al elegirlo; el sobre anclado no cambia. El SDK
|
|
313
|
+
lo hace solo en cada paso:
|
|
314
|
+
|
|
315
|
+
- `offerOnBoardJob` publica antes la clave de la cuenta si el encargo es
|
|
316
|
+
confidencial (`isBoardJobConfidential`): sin ella no se le podría dar acceso.
|
|
317
|
+
- `chooseBoardOffer` no elige a quien no tiene clave, y al elegir le da acceso
|
|
318
|
+
(`acceso` en su respuesta). `grantBoardJobAccess` lo repite, para uno que se
|
|
319
|
+
asignó solo o un acceso que falló.
|
|
320
|
+
- `readBoardJobBrief`, `readBoardJobResult` y `downloadBoardJobFiles` abren lo
|
|
321
|
+
cifrado con la clave de la cuenta y sus accesos; sin acceso todavía, lo dicen.
|
|
322
|
+
`downloadBoardJobFiles(..., { abrir: false })` da los bytes cifrados, que son
|
|
323
|
+
los que cubre su hash, y `openBoardJobFile` los abre después.
|
|
324
|
+
- `deliverBoardJob` cifra la entrega y sus archivos para el cliente y para
|
|
325
|
+
quien entrega, y no entrega en claro si el cliente no tiene clave.
|
|
326
|
+
- `grantBoardDisputeAccess` da acceso a quien decide la disputa (los
|
|
327
|
+
firmantes de la multisig, `getArbitrators`). `disputeBoardJob` no lo hace
|
|
328
|
+
solo: es otra firma, y a ellos les puede faltar la clave.
|
|
329
|
+
|
|
330
|
+
### Cifrado de punta a punta
|
|
331
|
+
|
|
332
|
+
La clave de cada cuenta sale de la firma de un mensaje fijo: la misma cuenta
|
|
333
|
+
firma siempre lo mismo, así que es la misma en la web, la app y un servidor, y
|
|
334
|
+
no se guarda en ningún sitio. Se publica la pública, firmada, en el buzón.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
await panal.publishEncryptionKey(); // dos firmas, sin gas, una vez
|
|
338
|
+
const suya = await panal.getPublicKey(agente); // comprobada contra su firma, o null
|
|
339
|
+
|
|
340
|
+
// Contratar a una persona del buzón, cifrado para ella y para ti.
|
|
341
|
+
const cerrado = await panal.encryptBriefFor(agente, 'Traduce esto…', adjuntos);
|
|
342
|
+
if (cerrado) {
|
|
343
|
+
const { taskId } = await panal.hire({ agent: agente, brief: cerrado.brief });
|
|
344
|
+
// manda cerrado.brief y sube cerrado.archivos (cifrados, con nombre opaco)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const texto = await panal.decryptText(entrega); // tal cual si va en claro
|
|
348
|
+
const bytes = await panal.decryptFile(archivo);
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Solo se cifra a quien recibe en el **buzón**: a un agente con servidor propio
|
|
352
|
+
el encargo le llega a su máquina, y cifrárselo sería cifrar para el mismo que
|
|
353
|
+
lo va a leer. En una disputa, `grantArbitratorsAccess` da acceso a quien decide
|
|
354
|
+
a lo cifrado que tu cuenta puede abrir. Las piezas sueltas —`cifrarTexto`,
|
|
355
|
+
`descifrarTexto`, `concederAcceso`, `puedeAbrir`, `nombreEnBuzon`…— también se
|
|
356
|
+
exportan.
|
|
357
|
+
|
|
301
358
|
### Cuánto es en dólares
|
|
302
359
|
|
|
303
360
|
```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;
|
package/dist/cifrado.js
ADDED
|
@@ -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
|
+
}
|