@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.
Files changed (64) hide show
  1. package/README.md +50 -7
  2. package/bin/nyx5.js +81 -2
  3. package/demo/edge-local.mjs +41 -0
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/demo/x402-testnet.mjs +80 -0
  6. package/docs/ARQUITECTURA.md +354 -0
  7. package/docs/SPEC.md +414 -15
  8. package/examples/ci/nyx5-ejecutor.yml +82 -0
  9. package/examples/frameworks/README.md +55 -0
  10. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  11. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  13. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  14. package/examples/frameworks/crewai_nyx5.py +72 -0
  15. package/examples/frameworks/http.md +117 -0
  16. package/examples/frameworks/langgraph_nyx5.py +90 -0
  17. package/examples/frameworks/nyx5_http.py +255 -0
  18. package/examples/frameworks/openai-agents.mjs +41 -0
  19. package/examples/hola-mundo.mjs +4 -4
  20. package/migrations/0006_puente_remoto.sql +19 -0
  21. package/migrations/0007_indice_reputacion.sql +31 -0
  22. package/migrations/0008_notaria.sql +18 -0
  23. package/migrations/0009_contratos_estado.sql +4 -0
  24. package/package.json +4 -2
  25. package/server.json +2 -2
  26. package/src/correo/acciones.js +216 -0
  27. package/src/correo/adjuntos.js +185 -0
  28. package/src/correo/agente.js +370 -22
  29. package/src/correo/asistente.js +599 -0
  30. package/src/correo/ci.js +262 -0
  31. package/src/correo/cobro.js +164 -0
  32. package/src/correo/estafeta.js +1548 -58
  33. package/src/correo/ideas.js +154 -0
  34. package/src/correo/indice.js +178 -0
  35. package/src/correo/politica.js +280 -2
  36. package/src/correo/resolver.js +35 -11
  37. package/src/libro/contratos.js +116 -7
  38. package/src/libro/estado.js +150 -0
  39. package/src/libro/informe.js +147 -3
  40. package/src/libro/libro.js +83 -12
  41. package/src/libro/notaria.js +74 -0
  42. package/src/libro/verifica.js +239 -36
  43. package/src/nucleo/almacen-d1.js +143 -14
  44. package/src/nucleo/almacen.js +137 -13
  45. package/src/nucleo/boveda.js +37 -0
  46. package/src/nucleo/crypto.js +55 -12
  47. package/src/nucleo/keccak.js +118 -0
  48. package/src/nucleo/objetos.js +78 -0
  49. package/src/nucleo/secp256k1.js +228 -0
  50. package/src/plataformas/app-html.js +1 -1
  51. package/src/plataformas/home-html.js +1 -1
  52. package/src/plataformas/iconos.js +6 -0
  53. package/src/plataformas/node.js +28 -4
  54. package/src/plataformas/spec-html.js +1 -1
  55. package/src/plataformas/terms-html.js +2 -0
  56. package/src/plataformas/worker.js +52 -7
  57. package/src/puentes/ejecutor.js +118 -0
  58. package/src/puentes/herramientas.js +315 -0
  59. package/src/puentes/mcp-remoto.js +71 -0
  60. package/src/puentes/mcp.js +5 -99
  61. package/src/puentes/oauth.js +361 -0
  62. package/src/puentes/x402-pagador.js +248 -0
  63. package/src/puentes/x402.js +266 -0
  64. package/src/version.js +4 -0
@@ -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
- libroListContracts() { const d = path.join(this.dir, 'libro', 'contratos'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
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
- libroStatement(account, limit) {
164
- return this.libroJournal().filter((a) => a.lines.some((l) => l.account === account)).slice(-limit);
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
- indexReplaceAgents(domain, cards) { writeJson(path.join(this.dir, 'indice', 'agentes', `${domain}.json`), { domain, cards, updated: new Date().toISOString() }); }
202
- indexSearch({ q, capability, accepts, house, limit = 50, offset = 0 } = {}) {
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
- let cards = fs.readdirSync(d).filter((f) => f.endsWith('.json'))
205
- .filter((f) => !house || f === `${house}.json`)
206
- .flatMap((f) => (readJson(path.join(d, f))?.cards || []));
207
- if (capability) cards = cards.filter((c) => c.capabilities?.[capability]);
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
+ }
@@ -8,7 +8,7 @@
8
8
  import {
9
9
  generateKeyPairSync, createPrivateKey, createPublicKey,
10
10
  sign as nodeSign, verify as nodeVerify,
11
- randomBytes, randomUUID, createHash, diffieHellman, hkdfSync,
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
- const encPrivateKey = (d, x) => createPrivateKey({ key: { kty: 'OKP', crv: 'X25519', d, x }, format: 'jwk' });
44
- const encPublicKey = (x) => createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x }, format: 'jwk' });
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.publicKey.export({ format: 'jwk' }).x;
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 = diffieHellman({ privateKey: eph.privateKey, publicKey: encPublicKey(r.enc) });
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 = diffieHellman({
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
+ }