@nyx5/nyx5 0.6.0 → 0.8.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 +50 -7
- package/bin/nyx5.js +81 -2
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-pagar.mjs +64 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +354 -0
- package/docs/SPEC.md +414 -15
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +370 -22
- package/src/correo/asistente.js +599 -0
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +1548 -58
- package/src/correo/ideas.js +154 -0
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +280 -2
- package/src/correo/resolver.js +35 -11
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +83 -12
- package/src/libro/notaria.js +74 -0
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +143 -14
- package/src/nucleo/almacen.js +137 -13
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +55 -12
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +28 -4
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +52 -7
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +315 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402-pagador.js +248 -0
- package/src/puentes/x402.js +266 -0
- package/src/version.js +4 -0
package/src/nucleo/almacen.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { filaDeIndice, buscarEnMemoria } from '../correo/indice.js';
|
|
18
19
|
|
|
19
20
|
const readJson = (p, fallback = null) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : fallback);
|
|
20
21
|
const writeJson = (p, v) => {
|
|
@@ -27,7 +28,7 @@ const writeJson = (p, v) => {
|
|
|
27
28
|
export class FileStore {
|
|
28
29
|
constructor(dir) {
|
|
29
30
|
this.dir = dir;
|
|
30
|
-
for (const d of ['agents', 'mailbox', 'queue', 'outbox', 'invitations', 'libro/diario', 'libro/contratos', 'libro/mandatos', 'libro/ops', 'eventos', 'indice/casas', 'indice/agentes']) fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
31
|
+
for (const d of ['agents', 'mailbox', 'queue', 'outbox', 'invitations', 'libro/diario', 'libro/contratos', 'libro/mandatos', 'libro/ops', 'libro/notaria', 'eventos', 'indice/casas', 'indice/agentes']) fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
31
32
|
this.nonces = new Map(); // anti-replay: en FileStore basta memoria (un proceso)
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -87,6 +88,15 @@ export class FileStore {
|
|
|
87
88
|
fs.renameSync(p, archived);
|
|
88
89
|
return true;
|
|
89
90
|
}
|
|
91
|
+
// Historial: lo pendiente y lo ya confirmado (archivado), en orden de llegada, los últimos N.
|
|
92
|
+
listMailHistory(local, { limit = 200 } = {}) {
|
|
93
|
+
const leer = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))) : []);
|
|
94
|
+
const vivos = leer(path.join(this.dir, 'mailbox', local));
|
|
95
|
+
const archivados = leer(path.join(this.dir, 'archive', local)).map((m) => ({ ...m, acked: m.acked || true }));
|
|
96
|
+
return [...vivos, ...archivados].sort((a, b) => String(a.received).localeCompare(String(b.received))).slice(-limit);
|
|
97
|
+
}
|
|
98
|
+
// Lo pendiente que llegó después de un momento dado (la espera en tiempo real pregunta esto).
|
|
99
|
+
listMailSince(local, sinceIso) { return this.listMail(local).filter((m) => !sinceIso || String(m.received) > sinceIso); }
|
|
90
100
|
|
|
91
101
|
// --- cola de salida (store-and-forward) ---
|
|
92
102
|
enqueue(job) { writeJson(path.join(this.dir, 'queue', `${job.id}.json`), job); }
|
|
@@ -112,6 +122,64 @@ export class FileStore {
|
|
|
112
122
|
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(dir, f)));
|
|
113
123
|
}
|
|
114
124
|
|
|
125
|
+
// --- kv con vencimiento: clientes OAuth, códigos de un uso, tokens (hash) y la bóveda ---
|
|
126
|
+
_kvPath(ns, key) { return path.join(this.dir, 'kv', ns, `${encodeURIComponent(key)}.json`); }
|
|
127
|
+
kvGet(ns, key, nowMs = Date.now()) {
|
|
128
|
+
const r = readJson(this._kvPath(ns, key));
|
|
129
|
+
if (!r || (r.expires != null && r.expires <= nowMs)) return null;
|
|
130
|
+
return r.doc;
|
|
131
|
+
}
|
|
132
|
+
kvPut(ns, key, doc, expires = null) { writeJson(this._kvPath(ns, key), { doc, expires, created: new Date().toISOString() }); }
|
|
133
|
+
kvPutIfAbsent(ns, key, doc, expires = null, nowMs = Date.now()) {
|
|
134
|
+
if (this.kvGet(ns, key, nowMs) !== null) return false;
|
|
135
|
+
this.kvPut(ns, key, doc, expires);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// Tomar y borrar en un paso: un código de autorización se usa UNA vez.
|
|
139
|
+
kvTake(ns, key, nowMs = Date.now()) {
|
|
140
|
+
const p = this._kvPath(ns, key);
|
|
141
|
+
const r = readJson(p);
|
|
142
|
+
if (!r) return null;
|
|
143
|
+
fs.unlinkSync(p);
|
|
144
|
+
return (r.expires != null && r.expires <= nowMs) ? null : r.doc;
|
|
145
|
+
}
|
|
146
|
+
// Contador atómico con vencimiento (límites de tasa durables). Suma `by` (1 por defecto; los
|
|
147
|
+
// bytes de un cupo diario, por ejemplo). Un contador vencido arranca en `by`.
|
|
148
|
+
kvIncrement(ns, key, expires = null, nowMs = Date.now(), by = 1) {
|
|
149
|
+
const n = (Number(this.kvGet(ns, key, nowMs)) || 0) + by;
|
|
150
|
+
this.kvPut(ns, key, n, expires);
|
|
151
|
+
return n;
|
|
152
|
+
}
|
|
153
|
+
kvDelete(ns, key) { const p = this._kvPath(ns, key); if (fs.existsSync(p)) fs.unlinkSync(p); }
|
|
154
|
+
// Las claves vivas de un espacio que empiezan por `prefix`, en orden de clave: [{ key, doc }].
|
|
155
|
+
// Nació para el registro de ideas@ (una fila por idea, clave con el número relleno de ceros).
|
|
156
|
+
// `after`: sólo claves estrictamente mayores (paginación por clave).
|
|
157
|
+
kvList(ns, { prefix = '', limit = 1000, after } = {}, nowMs = Date.now()) {
|
|
158
|
+
const dir = path.join(this.dir, 'kv', ns);
|
|
159
|
+
if (!fs.existsSync(dir)) return [];
|
|
160
|
+
const out = [];
|
|
161
|
+
const claves = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => [decodeURIComponent(f.slice(0, -5)), f]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
162
|
+
for (const [key, f] of claves) {
|
|
163
|
+
if (!key.startsWith(prefix) || (after != null && !(key > after))) continue;
|
|
164
|
+
const r = readJson(path.join(dir, f));
|
|
165
|
+
if (!r || (r.expires != null && r.expires <= nowMs)) continue;
|
|
166
|
+
out.push({ key, doc: r.doc });
|
|
167
|
+
if (out.length >= limit) break;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
kvPurge(nowMs = Date.now()) {
|
|
172
|
+
const raiz = path.join(this.dir, 'kv');
|
|
173
|
+
if (!fs.existsSync(raiz)) return;
|
|
174
|
+
for (const ns of fs.readdirSync(raiz)) {
|
|
175
|
+
for (const f of fs.readdirSync(path.join(raiz, ns))) {
|
|
176
|
+
const p = path.join(raiz, ns, f);
|
|
177
|
+
const r = readJson(p);
|
|
178
|
+
if (r?.expires != null && r.expires <= nowMs) fs.unlinkSync(p);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
115
183
|
// --- anti-replay de tokens de auth ---
|
|
116
184
|
useNonce(key, tsMs) {
|
|
117
185
|
if (this.nonces.has(key)) return false;
|
|
@@ -144,13 +212,39 @@ export class FileStore {
|
|
|
144
212
|
}
|
|
145
213
|
libroGetContract(id) { return readJson(path.join(this.dir, 'libro', 'contratos', `${id}.json`)); }
|
|
146
214
|
libroPutContract(c) { writeJson(path.join(this.dir, 'libro', 'contratos', `${c.id}.json`), c); }
|
|
147
|
-
|
|
215
|
+
// `state`: un estado o una lista de estados. Sin filtro devuelve todos. Los recorridos del reloj
|
|
216
|
+
// (verifica@, escrow que vence) piden sólo held/delivered: la tabla entera crecía con cada
|
|
217
|
+
// contrato cerrado y se leía completa en cada tick (revisión del 14-sep).
|
|
218
|
+
libroListContracts({ state = null } = {}) {
|
|
219
|
+
const d = path.join(this.dir, 'libro', 'contratos');
|
|
220
|
+
const todos = fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f)));
|
|
221
|
+
if (state == null) return todos;
|
|
222
|
+
const estados = Array.isArray(state) ? state : [state];
|
|
223
|
+
return todos.filter((c) => estados.includes(c.state));
|
|
224
|
+
}
|
|
148
225
|
libroFindContractByQuote(quoteId) { return this.libroListContracts().find((c) => c.quote_id === quoteId) || null; }
|
|
149
226
|
libroGetMandate(id) { return readJson(path.join(this.dir, 'libro', 'mandatos', `${id}.json`)); }
|
|
150
227
|
libroPutMandate(m) { writeJson(path.join(this.dir, 'libro', 'mandatos', `${m.id}.json`), m); }
|
|
151
228
|
libroListMandates() { const d = path.join(this.dir, 'libro', 'mandatos'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
|
|
152
229
|
libroGetOp(id) { return readJson(path.join(this.dir, 'libro', 'ops', `${id}.json`)); }
|
|
153
230
|
libroPutOp(id, v) { writeJson(path.join(this.dir, 'libro', 'ops', `${id}.json`), v); }
|
|
231
|
+
|
|
232
|
+
// --- notaría (NX-601): sellos de hash. Un archivo por sello; la lectura por hash recorre el
|
|
233
|
+
// directorio (FileStore es para pruebas y desarrollo; en D1 lo sirve el índice).
|
|
234
|
+
notariaGet(id) { return readJson(path.join(this.dir, 'libro', 'notaria', `${id}.json`)); }
|
|
235
|
+
_notariaTodos() { const d = path.join(this.dir, 'libro', 'notaria'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
|
|
236
|
+
notariaList(sha256) { return this._notariaTodos().filter((s) => s.sha256 === sha256).sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); }
|
|
237
|
+
notariaFind(sha256, by) { return this._notariaTodos().find((s) => s.sha256 === sha256 && s.by === by) || null; }
|
|
238
|
+
// Mismo candado que el índice único de D1: un segundo sello del mismo agente para el mismo hash
|
|
239
|
+
// falla cerrado con 421 (reintentable), así FileStore no es más permisivo que producción.
|
|
240
|
+
notariaPut(s) {
|
|
241
|
+
if (this.notariaFind(s.sha256, s.by)) {
|
|
242
|
+
const e = new Error(`ledger concurrency conflict: seal already exists for ${s.sha256} by ${s.by}`);
|
|
243
|
+
e.code = 421; e.transient = true;
|
|
244
|
+
throw e;
|
|
245
|
+
}
|
|
246
|
+
writeJson(path.join(this.dir, 'libro', 'notaria', `${s.id}.json`), s);
|
|
247
|
+
}
|
|
154
248
|
// ---------- instrumentación (nombre, fecha, actor y números; nunca contenido) ----------
|
|
155
249
|
putEvent(e) { writeJson(path.join(this.dir, 'eventos', `${e.id}.json`), e); }
|
|
156
250
|
listEvents({ name = null, since = null, limit = 500 } = {}) {
|
|
@@ -160,8 +254,20 @@ export class FileStore {
|
|
|
160
254
|
.filter((e) => e && (!name || e.name === name) && (!since || e.ts >= since))
|
|
161
255
|
.sort((a, b) => (a.ts < b.ts ? -1 : 1)).slice(-limit);
|
|
162
256
|
}
|
|
163
|
-
|
|
164
|
-
|
|
257
|
+
// Extracto por cuenta en el rango [since, until) sobre `at` (ISO ya normalizado por quien
|
|
258
|
+
// llama, ver estado.js): los `limit` asientos MÁS RECIENTES del rango, y `total` = cuántos hay
|
|
259
|
+
// en el rango, se muestren o no (el denominador viaja con el resultado).
|
|
260
|
+
libroStatementRange(account, { since = null, until = null, limit = 20 } = {}) {
|
|
261
|
+
const todos = this.libroJournal().filter((a) => a.lines.some((l) => l.account === account) && (!since || a.at >= since) && (!until || a.at < until));
|
|
262
|
+
return { entries: todos.slice(-limit), total: todos.length };
|
|
263
|
+
}
|
|
264
|
+
libroStatement(account, limit) { return this.libroStatementRange(account, { limit }).entries; }
|
|
265
|
+
// Saldo de una cuenta ANTES del asiento `n` y/o de la fecha `at`: la suma de sus deltas hasta
|
|
266
|
+
// ahí, leída del diario y no de saldos.json, para que un extracto cuadre por sí mismo.
|
|
267
|
+
libroBalanceBefore(account, { n = null, at = null } = {}) {
|
|
268
|
+
return this.libroJournal()
|
|
269
|
+
.filter((a) => (n == null || a.n < n) && (at == null || a.at < at))
|
|
270
|
+
.reduce((s, a) => s + a.lines.filter((l) => l.account === account).reduce((t, l) => t + l.delta, 0), 0);
|
|
165
271
|
}
|
|
166
272
|
|
|
167
273
|
// Un movimiento completo del Libro, junto. En D1: un batch atómico donde el PK del asiento (n)
|
|
@@ -183,6 +289,7 @@ export class FileStore {
|
|
|
183
289
|
if (bundle.state) this.libroPutState(bundle.state);
|
|
184
290
|
for (const c of bundle.contracts || []) this.libroPutContract(c);
|
|
185
291
|
for (const m of bundle.mandates || []) this.libroPutMandate(m);
|
|
292
|
+
for (const s of bundle.sellos || []) this.notariaPut(s);
|
|
186
293
|
if (bundle.op) this.libroPutOp(bundle.op.id, bundle.op.result);
|
|
187
294
|
}
|
|
188
295
|
|
|
@@ -198,15 +305,32 @@ export class FileStore {
|
|
|
198
305
|
indexGetHouse(domain) { return readJson(path.join(this.dir, 'indice', 'casas', `${domain}.json`)); }
|
|
199
306
|
indexPutHouse(h) { writeJson(path.join(this.dir, 'indice', 'casas', `${h.domain}.json`), h); }
|
|
200
307
|
indexListHouses() { const d = path.join(this.dir, 'indice', 'casas'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
|
|
201
|
-
|
|
202
|
-
|
|
308
|
+
// Cada casa rastreada es un archivo con FILAS: la tarjeta (doc) más las columnas que el índice
|
|
309
|
+
// extrae de ella (indice.js) y la generación, para que un recorrido por cursor sobrevida a un
|
|
310
|
+
// rastreo que cambie los puntajes (ver la cabecera de src/correo/indice.js).
|
|
311
|
+
indexReplaceAgents(domain, cards) {
|
|
312
|
+
const archivo = path.join(this.dir, 'indice', 'agentes', `${domain}.json`);
|
|
313
|
+
const previo = new Map(this._indexRows(domain).map((r) => [r.address, r]));
|
|
314
|
+
const gen = this._indexGen() + 1;
|
|
315
|
+
const rows = cards.map((c) => filaDeIndice(domain, c, gen, previo.get(c.address) || null));
|
|
316
|
+
writeJson(archivo, { domain, gen, rows, updated: new Date().toISOString() });
|
|
317
|
+
}
|
|
318
|
+
_indexRows(domain) {
|
|
319
|
+
const f = readJson(path.join(this.dir, 'indice', 'agentes', `${domain}.json`));
|
|
320
|
+
if (!f) return [];
|
|
321
|
+
// Archivo del formato anterior (sólo tarjetas): filas sin columnas ni generación.
|
|
322
|
+
if (!f.rows && Array.isArray(f.cards)) return f.cards.map((c) => filaDeIndice(domain, c, 0, null));
|
|
323
|
+
return f.rows || [];
|
|
324
|
+
}
|
|
325
|
+
_indexGen() {
|
|
326
|
+
const d = path.join(this.dir, 'indice', 'agentes');
|
|
327
|
+
return fs.readdirSync(d).filter((f) => f.endsWith('.json')).reduce((m, f) => Math.max(m, readJson(path.join(d, f))?.gen || 0), 0);
|
|
328
|
+
}
|
|
329
|
+
indexSearch(f = {}) {
|
|
203
330
|
const d = path.join(this.dir, 'indice', 'agentes');
|
|
204
|
-
|
|
205
|
-
.filter((
|
|
206
|
-
.flatMap((
|
|
207
|
-
|
|
208
|
-
if (accepts) cards = cards.filter((c) => c.capabilities?.accepts?.includes(accepts));
|
|
209
|
-
if (q) { const needle = String(q).toLowerCase(); cards = cards.filter((c) => c.address.includes(needle) || JSON.stringify(c.capabilities || {}).toLowerCase().includes(needle)); }
|
|
210
|
-
return { total: cards.length, offset, agents: cards.slice(offset, offset + limit) };
|
|
331
|
+
const filas = fs.readdirSync(d).filter((x) => x.endsWith('.json'))
|
|
332
|
+
.filter((x) => !f.house || x === `${f.house}.json`)
|
|
333
|
+
.flatMap((x) => this._indexRows(x.slice(0, -5)));
|
|
334
|
+
return buscarEnMemoria(filas, f);
|
|
211
335
|
}
|
|
212
336
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Nyx5/1 — La bóveda: las llaves de los subagentes delegados que la casa guarda para que un
|
|
2
|
+
// conector remoto (el Claude de un teléfono) pueda firmar en nombre de ese subagente.
|
|
3
|
+
//
|
|
4
|
+
// Qué se guarda aquí y qué NO: sólo llaves de subagentes `claude.<dueño>@<casa>`, de alcance
|
|
5
|
+
// limitado a mensajes, con vencimiento y revocables por el dueño. La llave raíz de una persona
|
|
6
|
+
// nunca pasa por la casa: vive en su navegador y es la que firma la delegación.
|
|
7
|
+
//
|
|
8
|
+
// Cifrado en reposo con AES-256-GCM y una llave que vive SÓLO en un secret del Worker
|
|
9
|
+
// (NYX5_VAULT_KEY). Un volcado de la base no entrega ninguna llave. La AAD amarra cada sello a
|
|
10
|
+
// su dueño: una fila copiada bajo otro nombre no abre.
|
|
11
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
12
|
+
import { b64u, unb64u } from './crypto.js';
|
|
13
|
+
|
|
14
|
+
const AAD = (dueno) => Buffer.from(`nyx5/1 boveda ${dueno}`);
|
|
15
|
+
|
|
16
|
+
export function abrirBoveda(secreto) {
|
|
17
|
+
if (!secreto) return null;
|
|
18
|
+
const llave = Buffer.from(String(secreto).trim(), 'base64');
|
|
19
|
+
if (llave.length !== 32) throw new Error('NYX5_VAULT_KEY must be exactly 32 bytes, base64-encoded');
|
|
20
|
+
return {
|
|
21
|
+
sellar(obj, dueno) {
|
|
22
|
+
const iv = randomBytes(12);
|
|
23
|
+
const c = createCipheriv('aes-256-gcm', llave, iv);
|
|
24
|
+
c.setAAD(AAD(dueno));
|
|
25
|
+
const ct = Buffer.concat([c.update(Buffer.from(JSON.stringify(obj))), c.final()]);
|
|
26
|
+
return `v1.${b64u(iv)}.${b64u(ct)}.${b64u(c.getAuthTag())}`;
|
|
27
|
+
},
|
|
28
|
+
abrir(sello, dueno) {
|
|
29
|
+
const [v, iv, ct, tag] = String(sello).split('.');
|
|
30
|
+
if (v !== 'v1' || !iv || !ct || !tag) throw new Error('unreadable vault seal');
|
|
31
|
+
const d = createDecipheriv('aes-256-gcm', llave, unb64u(iv));
|
|
32
|
+
d.setAAD(AAD(dueno));
|
|
33
|
+
d.setAuthTag(unb64u(tag));
|
|
34
|
+
return JSON.parse(Buffer.concat([d.update(unb64u(ct)), d.final()]).toString());
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/nucleo/crypto.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
generateKeyPairSync, createPrivateKey, createPublicKey,
|
|
10
10
|
sign as nodeSign, verify as nodeVerify,
|
|
11
|
-
randomBytes, randomUUID, createHash,
|
|
11
|
+
randomBytes, randomUUID, createHash, hkdfSync,
|
|
12
12
|
createCipheriv, createDecipheriv,
|
|
13
13
|
} from 'node:crypto';
|
|
14
14
|
|
|
@@ -40,8 +40,18 @@ export function generateKeys() {
|
|
|
40
40
|
|
|
41
41
|
const sigPrivateKey = (d, x) => createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', d, x }, format: 'jwk' });
|
|
42
42
|
const sigPublicKey = (x) => createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x }, format: 'jwk' });
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// El acuerdo de claves X25519 va por WebCrypto, NO por node:crypto. Medido el 10-sep-2026 en el
|
|
44
|
+
// runtime real de Cloudflare (workerd, scripts/sonda-workerd.mjs): `diffieHellman` no existe allá,
|
|
45
|
+
// ni con objetos de llave ni desde JWK, mientras que `crypto.subtle` X25519 sí, e importa las
|
|
46
|
+
// mismas JWK que genera Node. En Node y en los tests todo andaba: el defecto sólo se veía cuando
|
|
47
|
+
// el edge mismo cifraba, que es justo lo que hace el conector remoto. El secreto es el mismo
|
|
48
|
+
// (X25519 es estándar), así que el formato de los sobres no cambia y lo cifrado antes se abre igual.
|
|
49
|
+
async function x25519(d, x, publico) {
|
|
50
|
+
const s = globalThis.crypto.subtle;
|
|
51
|
+
const priv = await s.importKey('jwk', { kty: 'OKP', crv: 'X25519', x, d }, { name: 'X25519' }, false, ['deriveBits']);
|
|
52
|
+
const pub = await s.importKey('jwk', { kty: 'OKP', crv: 'X25519', x: publico }, { name: 'X25519' }, true, []);
|
|
53
|
+
return Buffer.from(await s.deriveBits({ name: 'X25519', public: pub }, priv, 256));
|
|
54
|
+
}
|
|
45
55
|
|
|
46
56
|
// ---------- Firma de objetos ----------
|
|
47
57
|
// Firma todo el objeto menos el campo indicado (por defecto "signature").
|
|
@@ -77,7 +87,7 @@ export function verifyBytes(data, signature, pub) {
|
|
|
77
87
|
// El AAD amarra el cifrado al sobre (id/from/to) para impedir reenvíos con otro remitente.
|
|
78
88
|
const INFO = Buffer.from('nyx5/1 cek-wrap');
|
|
79
89
|
|
|
80
|
-
export function encryptContent(content, recipients, aad) {
|
|
90
|
+
export async function encryptContent(content, recipients, aad) {
|
|
81
91
|
const cek = randomBytes(32);
|
|
82
92
|
const iv = randomBytes(12);
|
|
83
93
|
const cipher = createCipheriv('aes-256-gcm', cek, iv);
|
|
@@ -85,11 +95,11 @@ export function encryptContent(content, recipients, aad) {
|
|
|
85
95
|
const ct = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(content))), cipher.final()]);
|
|
86
96
|
const tag = cipher.getAuthTag();
|
|
87
97
|
|
|
88
|
-
const eph = generateKeyPairSync('x25519');
|
|
89
|
-
const epk = eph.
|
|
98
|
+
const eph = generateKeyPairSync('x25519').privateKey.export({ format: 'jwk' });
|
|
99
|
+
const epk = eph.x;
|
|
90
100
|
const keys = {};
|
|
91
101
|
for (const r of recipients) {
|
|
92
|
-
const shared =
|
|
102
|
+
const shared = await x25519(eph.d, eph.x, r.enc);
|
|
93
103
|
const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.from(epk), INFO, 32));
|
|
94
104
|
const wiv = randomBytes(12);
|
|
95
105
|
const c = createCipheriv('aes-256-gcm', kek, wiv);
|
|
@@ -99,13 +109,10 @@ export function encryptContent(content, recipients, aad) {
|
|
|
99
109
|
return { alg: 'X25519+HKDF-SHA256+A256GCM', epk, iv: b64u(iv), ct: b64u(ct), tag: b64u(tag), keys };
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
export function decryptContent(encrypted, address, keys, aad) {
|
|
112
|
+
export async function decryptContent(encrypted, address, keys, aad) {
|
|
103
113
|
const slot = encrypted?.keys?.[address];
|
|
104
114
|
if (!slot) throw new Error(`no wrapped key for ${address}`);
|
|
105
|
-
const shared =
|
|
106
|
-
privateKey: encPrivateKey(keys.encPriv, keys.enc),
|
|
107
|
-
publicKey: encPublicKey(encrypted.epk),
|
|
108
|
-
});
|
|
115
|
+
const shared = await x25519(keys.encPriv, keys.enc, encrypted.epk);
|
|
109
116
|
const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.from(encrypted.epk), INFO, 32));
|
|
110
117
|
const d = createDecipheriv('aes-256-gcm', kek, unb64u(slot.iv));
|
|
111
118
|
d.setAuthTag(unb64u(slot.tag));
|
|
@@ -117,6 +124,42 @@ export function decryptContent(encrypted, address, keys, aad) {
|
|
|
117
124
|
return JSON.parse(plain.toString());
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
// ---------- Adjuntos: un archivo cifrado que la casa guarda sin poder leer ----------
|
|
128
|
+
// Un adjunto NO viaja en el sobre: viaja al almacén de objetos de la casa, cifrado, y el sobre
|
|
129
|
+
// lleva su referencia. La llave es ALEATORIA Y POR ADJUNTO y viaja en `attachments[].key` DENTRO
|
|
130
|
+
// del bloque cifrado del sobre: la casa aloja el objeto y no tiene con qué abrirlo (invariante 8).
|
|
131
|
+
//
|
|
132
|
+
// El objeto es autocontenido: iv(12) || ciphertext || tag(16). No hay cabecera, ni nombre, ni
|
|
133
|
+
// tipo: todo eso es contenido y vive dentro del bloque cifrado del sobre.
|
|
134
|
+
//
|
|
135
|
+
// El AAD lo pone quien llama y es `nyx5:blob:<blobId>:<from>` (ver src/correo/adjuntos.js): ata
|
|
136
|
+
// el cifrado a ESTE objeto y a ESTE remitente, así que un adjunto no se reusa desde otra
|
|
137
|
+
// dirección ni se hace pasar por otro objeto.
|
|
138
|
+
export const IV_BLOB = 12;
|
|
139
|
+
export const TAG_BLOB = 16;
|
|
140
|
+
export const generateBlobKey = () => b64u(randomBytes(32));
|
|
141
|
+
|
|
142
|
+
export function encryptBlob(datos, key, aad) {
|
|
143
|
+
const k = unb64u(key);
|
|
144
|
+
if (k.length !== 32) throw new Error('an attachment key is 32 bytes in base64url');
|
|
145
|
+
const iv = randomBytes(IV_BLOB);
|
|
146
|
+
const c = createCipheriv('aes-256-gcm', k, iv);
|
|
147
|
+
c.setAAD(Buffer.from(aad));
|
|
148
|
+
const ct = Buffer.concat([c.update(Buffer.from(datos)), c.final()]);
|
|
149
|
+
return Buffer.concat([iv, ct, c.getAuthTag()]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function decryptBlob(objeto, key, aad) {
|
|
153
|
+
const buf = Buffer.from(objeto);
|
|
154
|
+
if (buf.length < IV_BLOB + TAG_BLOB) throw new Error('object too short to be an encrypted attachment');
|
|
155
|
+
const k = unb64u(key);
|
|
156
|
+
if (k.length !== 32) throw new Error('an attachment key is 32 bytes in base64url');
|
|
157
|
+
const d = createDecipheriv('aes-256-gcm', k, buf.subarray(0, IV_BLOB));
|
|
158
|
+
d.setAAD(Buffer.from(aad));
|
|
159
|
+
d.setAuthTag(buf.subarray(buf.length - TAG_BLOB));
|
|
160
|
+
return Buffer.concat([d.update(buf.subarray(IV_BLOB, buf.length - TAG_BLOB)), d.final()]);
|
|
161
|
+
}
|
|
162
|
+
|
|
120
163
|
// ---------- Proof-of-work (hashcash) ----------
|
|
121
164
|
function leadingZeroBits(hex) {
|
|
122
165
|
let bits = 0;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Nyx5/1 — Keccak-256 en JavaScript puro, sin dependencias.
|
|
2
|
+
//
|
|
3
|
+
// Por qué existe: Ethereum usa Keccak-256 (el Keccak ORIGINAL de la competencia SHA-3, con relleno
|
|
4
|
+
// pad10*1 puro), y Node sólo trae `sha3-256`, que es FIPS 202: mismo permutador, distinto relleno
|
|
5
|
+
// (FIPS 202 añade el sufijo 0x06 antes del pad10*1). Los dos dan resultados distintos para la
|
|
6
|
+
// misma entrada, y `crypto.createHash('sha3-256')` NO sirve para calcular una dirección, un
|
|
7
|
+
// dominio EIP-712 ni un digest que un contrato vaya a comprobar. Sin esto, un agente de Nyx5
|
|
8
|
+
// puede COBRAR por x402 pero no PAGAR (ver src/puentes/x402-pagador.js).
|
|
9
|
+
//
|
|
10
|
+
// Parámetros de Keccak-256: estado de 1600 bits (25 carriles de 64 bits), tasa r = 1088 bits
|
|
11
|
+
// (136 bytes por bloque), capacidad c = 512, 24 rondas, salida de 32 bytes.
|
|
12
|
+
//
|
|
13
|
+
// Los carriles son BigInt de 64 bits. Es más lento que partir cada carril en dos enteros de 32
|
|
14
|
+
// bits, pero aquí se hashean decenas de bytes por pago, no megabytes, y la claridad vale más que
|
|
15
|
+
// el ciclo. Verificado contra los vectores oficiales del equipo Keccak (ver test/x402-pagador.test.js).
|
|
16
|
+
|
|
17
|
+
const RONDAS = 24;
|
|
18
|
+
const TASA = 136; // bytes por bloque para Keccak-256
|
|
19
|
+
const MASCARA = (1n << 64n) - 1n;
|
|
20
|
+
|
|
21
|
+
// Constantes de ronda (iota), RC[0..23] de la especificación de Keccak.
|
|
22
|
+
const RC = [
|
|
23
|
+
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
|
|
24
|
+
0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
|
|
25
|
+
0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
|
|
26
|
+
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
|
|
27
|
+
0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
|
|
28
|
+
0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Desplazamientos de rho, indexados por x + 5*y.
|
|
32
|
+
const ROT = [
|
|
33
|
+
0, 1, 62, 28, 27,
|
|
34
|
+
36, 44, 6, 55, 20,
|
|
35
|
+
3, 10, 43, 25, 39,
|
|
36
|
+
41, 45, 15, 21, 8,
|
|
37
|
+
18, 2, 61, 56, 14,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const rotl = (v, n) => n === 0 ? v : (((v << BigInt(n)) | (v >> BigInt(64 - n))) & MASCARA);
|
|
41
|
+
|
|
42
|
+
// Keccak-f[1600] sobre un arreglo de 25 BigInt (se muta en el lugar).
|
|
43
|
+
function keccakF(A) {
|
|
44
|
+
const C = new Array(5), B = new Array(25);
|
|
45
|
+
for (let r = 0; r < RONDAS; r++) {
|
|
46
|
+
// theta
|
|
47
|
+
for (let x = 0; x < 5; x++) C[x] = A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20];
|
|
48
|
+
for (let x = 0; x < 5; x++) {
|
|
49
|
+
const D = C[(x + 4) % 5] ^ rotl(C[(x + 1) % 5], 1);
|
|
50
|
+
for (let y = 0; y < 25; y += 5) A[x + y] ^= D;
|
|
51
|
+
}
|
|
52
|
+
// rho y pi
|
|
53
|
+
for (let x = 0; x < 5; x++) {
|
|
54
|
+
for (let y = 0; y < 5; y++) {
|
|
55
|
+
B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(A[x + 5 * y], ROT[x + 5 * y]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// chi
|
|
59
|
+
for (let y = 0; y < 25; y += 5) {
|
|
60
|
+
for (let x = 0; x < 5; x++) {
|
|
61
|
+
A[x + y] = B[x + y] ^ ((~B[(x + 1) % 5 + y] & MASCARA) & B[(x + 2) % 5 + y]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// iota
|
|
65
|
+
A[0] ^= RC[r];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const aBytes = (entrada) => {
|
|
70
|
+
if (entrada instanceof Uint8Array) return entrada;
|
|
71
|
+
if (typeof entrada === 'string') return new TextEncoder().encode(entrada);
|
|
72
|
+
if (ArrayBuffer.isView(entrada)) return new Uint8Array(entrada.buffer, entrada.byteOffset, entrada.byteLength);
|
|
73
|
+
throw new TypeError('keccak256: se esperaba Uint8Array o string');
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// keccak256(bytes | string utf-8) → Uint8Array de 32 bytes.
|
|
77
|
+
export function keccak256(entrada) {
|
|
78
|
+
const msg = aBytes(entrada);
|
|
79
|
+
// Relleno pad10*1 del Keccak original: 0x01 en el primer byte libre, 0x80 en el último del
|
|
80
|
+
// bloque (si el mensaje llena el bloque exacto, se añade un bloque entero de relleno).
|
|
81
|
+
const bloques = Math.floor(msg.length / TASA) + 1;
|
|
82
|
+
const buf = new Uint8Array(bloques * TASA);
|
|
83
|
+
buf.set(msg);
|
|
84
|
+
buf[msg.length] ^= 0x01;
|
|
85
|
+
buf[buf.length - 1] ^= 0x80;
|
|
86
|
+
|
|
87
|
+
const A = new Array(25).fill(0n);
|
|
88
|
+
for (let b = 0; b < bloques; b++) {
|
|
89
|
+
const base = b * TASA;
|
|
90
|
+
for (let i = 0; i < TASA / 8; i++) {
|
|
91
|
+
// Carriles little-endian: el byte 0 es el menos significativo.
|
|
92
|
+
let carril = 0n;
|
|
93
|
+
for (let j = 7; j >= 0; j--) carril = (carril << 8n) | BigInt(buf[base + i * 8 + j]);
|
|
94
|
+
A[i] ^= carril;
|
|
95
|
+
}
|
|
96
|
+
keccakF(A);
|
|
97
|
+
}
|
|
98
|
+
const salida = new Uint8Array(32);
|
|
99
|
+
for (let i = 0; i < 4; i++) {
|
|
100
|
+
let carril = A[i];
|
|
101
|
+
for (let j = 0; j < 8; j++) { salida[i * 8 + j] = Number(carril & 0xffn); carril >>= 8n; }
|
|
102
|
+
}
|
|
103
|
+
return salida;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const aHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
107
|
+
|
|
108
|
+
// keccak256 en hexadecimal sin prefijo, minúsculas.
|
|
109
|
+
export const keccak256Hex = (entrada) => aHex(keccak256(entrada));
|
|
110
|
+
|
|
111
|
+
// Hex (con o sin 0x) → Uint8Array. Falla cerrado: un hex de largo impar o con basura no se adivina.
|
|
112
|
+
export function deHex(hex) {
|
|
113
|
+
const h = String(hex).replace(/^0x/i, '');
|
|
114
|
+
if (h.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(h)) throw new Error(`hex inválido: ${String(hex).slice(0, 20)}`);
|
|
115
|
+
const out = new Uint8Array(h.length / 2);
|
|
116
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Nyx5/1 — Almacén de OBJETOS de una casa: dónde vive un adjunto cifrado (NX-413).
|
|
2
|
+
//
|
|
3
|
+
// Es deliberadamente tonto: guarda bytes bajo una clave y los devuelve. Todo lo que es «sobre»
|
|
4
|
+
// (quién puede bajarlo, cuántos bytes, hasta cuándo) vive en los metadatos del almacén normal
|
|
5
|
+
// (`kv`, espacio `adjuntos`), y todo lo que es «contenido» (nombre, tipo, llave) viaja dentro
|
|
6
|
+
// del bloque cifrado del sobre y no llega nunca hasta aquí.
|
|
7
|
+
//
|
|
8
|
+
// Tres implementaciones, la misma interfaz async, igual que FileStore / D1Store:
|
|
9
|
+
// ObjetosR2 Cloudflare R2 (producción; el binding lo pasa src/plataformas/worker.js)
|
|
10
|
+
// ObjetosDisco archivos (la casa local de un test, una demo o un desarrollo)
|
|
11
|
+
// ObjetosMemoria memoria (un test que no quiere tocar el disco)
|
|
12
|
+
//
|
|
13
|
+
// CONTRATO:
|
|
14
|
+
// put(clave, bytes) -> void get(clave) -> Buffer|Uint8Array|null
|
|
15
|
+
// delete(clave) -> void claves() -> [string] (sólo lo usa el reloj; opcional)
|
|
16
|
+
//
|
|
17
|
+
// La clave la arma `claveObjeto(blobId)` en src/correo/adjuntos.js y es siempre `blobs/<uuid>`.
|
|
18
|
+
// NUNCA el nombre del archivo: un nombre trae `../` y además le dice a la casa qué hay dentro.
|
|
19
|
+
|
|
20
|
+
export class ObjetosMemoria {
|
|
21
|
+
constructor() { this.m = new Map(); }
|
|
22
|
+
async put(clave, bytes) { this.m.set(clave, Buffer.from(bytes)); }
|
|
23
|
+
async get(clave) { return this.m.get(clave) ?? null; }
|
|
24
|
+
async delete(clave) { this.m.delete(clave); }
|
|
25
|
+
async claves() { return [...this.m.keys()]; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ObjetosDisco {
|
|
29
|
+
constructor(dir) { this.dir = dir; }
|
|
30
|
+
// node:fs por import dinámico: este módulo carga limpio en el edge, donde se usa ObjetosR2.
|
|
31
|
+
async _fs() { this.fs ||= await import('node:fs'); this.path ||= await import('node:path'); return this.fs; }
|
|
32
|
+
// La clave ya está acotada a `blobs/<uuid>` por quien llama; aquí se vuelve a comprobar, porque
|
|
33
|
+
// un almacén que escribe donde le digan es un traversal esperando a que alguien se confunda.
|
|
34
|
+
_ruta(clave) {
|
|
35
|
+
if (!/^blobs\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(clave)) throw new Error(`clave de objeto inválida: ${JSON.stringify(String(clave).slice(0, 60))}`);
|
|
36
|
+
return this.path.join(this.dir, clave.slice('blobs/'.length));
|
|
37
|
+
}
|
|
38
|
+
async put(clave, bytes) {
|
|
39
|
+
const fs = await this._fs();
|
|
40
|
+
const p = this._ruta(clave);
|
|
41
|
+
fs.mkdirSync(this.path.dirname(p), { recursive: true });
|
|
42
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
43
|
+
fs.writeFileSync(tmp, Buffer.from(bytes), { mode: 0o600 });
|
|
44
|
+
fs.renameSync(tmp, p);
|
|
45
|
+
}
|
|
46
|
+
async get(clave) {
|
|
47
|
+
const fs = await this._fs();
|
|
48
|
+
const p = this._ruta(clave);
|
|
49
|
+
return fs.existsSync(p) ? fs.readFileSync(p) : null;
|
|
50
|
+
}
|
|
51
|
+
async delete(clave) {
|
|
52
|
+
const fs = await this._fs();
|
|
53
|
+
const p = this._ruta(clave);
|
|
54
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
55
|
+
}
|
|
56
|
+
async claves() {
|
|
57
|
+
const fs = await this._fs();
|
|
58
|
+
if (!fs.existsSync(this.dir)) return [];
|
|
59
|
+
return fs.readdirSync(this.dir).filter((f) => !f.endsWith('.tmp')).map((f) => `blobs/${f}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class ObjetosR2 {
|
|
64
|
+
constructor(bucket) { this.bucket = bucket; }
|
|
65
|
+
async put(clave, bytes) { await this.bucket.put(clave, bytes); }
|
|
66
|
+
async get(clave) {
|
|
67
|
+
const o = await this.bucket.get(clave);
|
|
68
|
+
if (!o) return null;
|
|
69
|
+
return new Uint8Array(await o.arrayBuffer());
|
|
70
|
+
}
|
|
71
|
+
async delete(clave) { await this.bucket.delete(clave); }
|
|
72
|
+
// R2 pagina; el reloj limpia por los METADATOS (kv), no recorriendo el bucket, así que esto
|
|
73
|
+
// existe sólo para inspección y devuelve la primera página.
|
|
74
|
+
async claves() {
|
|
75
|
+
const l = await this.bucket.list({ prefix: 'blobs/', limit: 1000 });
|
|
76
|
+
return (l.objects || []).map((o) => o.key);
|
|
77
|
+
}
|
|
78
|
+
}
|