@nyx5/nyx5 0.2.4 → 0.3.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 +2 -4
- package/agents.md +1 -1
- package/bin/nyx5.js +34 -33
- package/docs/SPEC.md +311 -308
- package/package.json +1 -3
- package/server.json +2 -2
- package/src/correo/agente.js +5 -5
- package/src/correo/estafeta.js +67 -72
- package/src/correo/politica.js +3 -3
- package/src/correo/resolver.js +9 -9
- package/src/correo/unirse.js +6 -6
- package/src/libro/contratos.js +25 -25
- package/src/libro/libro.js +3 -3
- package/src/libro/tareas.js +9 -9
- package/src/nucleo/almacen.js +1 -1
- package/src/nucleo/crypto.js +1 -1
- package/src/nucleo/d1-local.js +1 -1
- package/src/plataformas/home-html.js +1 -2
- package/src/plataformas/node.js +1 -1
- package/src/plataformas/spec-html.js +3 -4
- package/src/plataformas/worker.js +1 -1
- package/src/puentes/email.js +13 -7
- package/README.es.md +0 -201
- package/docs/SPEC.en.md +0 -489
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyx5/nyx5",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Nyx5/1 — mail and ledger for AI agents: agent@domain addresses, a store-and-forward mailbox, and a ledger where a claim costs something. One command to join, escrow released only against a deterministic check, and reputation that is just a query on the ledger. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -21,14 +21,12 @@
|
|
|
21
21
|
"files": [
|
|
22
22
|
"CONTRIBUTING.md",
|
|
23
23
|
"LICENSE",
|
|
24
|
-
"README.es.md",
|
|
25
24
|
"README.md",
|
|
26
25
|
"SECURITY.md",
|
|
27
26
|
"agents.md",
|
|
28
27
|
"bin/",
|
|
29
28
|
"demo/",
|
|
30
29
|
"docs/ARQUITECTURA.md",
|
|
31
|
-
"docs/SPEC.en.md",
|
|
32
30
|
"docs/SPEC.md",
|
|
33
31
|
"examples/",
|
|
34
32
|
"migrations/",
|
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"url": "https://github.com/Nicoiakl/nyx5",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.3.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@nyx5/nyx5",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.3.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/src/correo/agente.js
CHANGED
|
@@ -89,20 +89,20 @@ export class Agent {
|
|
|
89
89
|
// Tarjetas de los destinatarios: para cifrar (clave enc) y para saber si exigen proof-of-work.
|
|
90
90
|
const cards = await Promise.all(recipients.map((r) => this.resolver.agentCard(r).catch((e) => ({ address: r, _error: e.message }))));
|
|
91
91
|
const missing = cards.filter((c) => c._error);
|
|
92
|
-
if (missing.length) throw new Error(`
|
|
92
|
+
if (missing.length) throw new Error(`could not resolve: ${missing.map((c) => `${c.address} (${c._error})`).join(', ')}`);
|
|
93
93
|
|
|
94
94
|
let env;
|
|
95
95
|
if (encrypt && cards.every((c) => c.enc)) {
|
|
96
96
|
env = { ...base, encrypted: encryptContent(content, cards.map((c) => ({ address: c.address, enc: c.enc })), aad(base)) };
|
|
97
97
|
} else {
|
|
98
|
-
if (encrypt === 'required') throw new Error('
|
|
98
|
+
if (encrypt === 'required') throw new Error('a recipient does not publish an encryption key');
|
|
99
99
|
env = { ...base, content };
|
|
100
100
|
}
|
|
101
101
|
const powBits = Math.max(0, ...cards.map((c) => (c.inbox?.policy === 'pow' ? c.inbox.pow_bits ?? 16 : 0)));
|
|
102
102
|
if (powBits) env.pow = mintPow(id, powBits);
|
|
103
103
|
const stamped = cards.find((c) => c.inbox?.policy === 'stamp');
|
|
104
104
|
if (stamped) {
|
|
105
|
-
if (recipients.length > 1) throw new Error('
|
|
105
|
+
if (recipients.length > 1) throw new Error('a stamped envelope carries a single recipient');
|
|
106
106
|
env.stamp = { house: stamped.inbox.house || parseAddress(stamped.address).domain, amount: stamped.inbox.price ?? 1 };
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -221,9 +221,9 @@ export class Agent {
|
|
|
221
221
|
}
|
|
222
222
|
const card = await this.resolver.agentCardForKid(envelope.from, envelope.signature?.kid);
|
|
223
223
|
const verified = Resolver.acceptedKids(card).includes(envelope.signature?.kid) && verifyObject(envelope, envelope.signature.kid);
|
|
224
|
-
if (!verified) throw new Error(`
|
|
224
|
+
if (!verified) throw new Error(`invalid signature on envelope ${envelope.id} from ${envelope.from}`);
|
|
225
225
|
if (envelope.expires && Date.parse(envelope.expires) < Date.now()) throw new Error(`sobre vencido: ${envelope.id}`);
|
|
226
|
-
if (!envelope.encrypted && !envelope.to.includes(this.address)) throw new Error(`
|
|
226
|
+
if (!envelope.encrypted && !envelope.to.includes(this.address)) throw new Error(`envelope ${envelope.id} is not addressed to ${this.address}`);
|
|
227
227
|
const content = envelope.encrypted ? decryptContent(envelope.encrypted, this.address, this.keys, aad(envelope)) : envelope.content;
|
|
228
228
|
return { id: envelope.id, from: envelope.from, to: envelope.to, type: envelope.type, thread: envelope.thread, in_reply_to: envelope.in_reply_to, created: envelope.created, encrypted: !!envelope.encrypted, sender: card, content };
|
|
229
229
|
}
|
package/src/correo/estafeta.js
CHANGED
|
@@ -27,8 +27,8 @@ import { veredicto, pruebasDe, pruebasDisponibles } from '../libro/verifica.js';
|
|
|
27
27
|
import { contratoPublico, ACP } from '../libro/contratos.js';
|
|
28
28
|
import { Tareas } from '../libro/tareas.js';
|
|
29
29
|
import { APP_HTML } from '../plataformas/app-html.js';
|
|
30
|
-
import { SPEC_HTML,
|
|
31
|
-
import { HOME_HTML
|
|
30
|
+
import { SPEC_HTML, LLMS_TXT } from '../plataformas/spec-html.js';
|
|
31
|
+
import { HOME_HTML } from '../plataformas/home-html.js';
|
|
32
32
|
import { inboundEnvelope, outboundPayload, isEmailAddress } from '../puentes/email.js';
|
|
33
33
|
|
|
34
34
|
const now = () => Date.now();
|
|
@@ -156,10 +156,10 @@ export class Estafeta {
|
|
|
156
156
|
// N veces en paralelo, multiplicando el regalo de bienvenida (emisión no autorizada).
|
|
157
157
|
async _consumeInvite(code) {
|
|
158
158
|
const inv = code && await this.store.getInvite(String(code));
|
|
159
|
-
if (!inv) throw Object.assign(new Error('
|
|
160
|
-
if (inv.expires && Date.parse(inv.expires) < now()) throw Object.assign(new Error('
|
|
159
|
+
if (!inv) throw Object.assign(new Error('no such invitation'), { status: 403 });
|
|
160
|
+
if (inv.expires && Date.parse(inv.expires) < now()) throw Object.assign(new Error('invitation expired'), { status: 403 });
|
|
161
161
|
const usada = await this.store.consumeInvite(String(code), iso());
|
|
162
|
-
if (!usada) throw Object.assign(new Error('
|
|
162
|
+
if (!usada) throw Object.assign(new Error('invitation used up'), { status: 403 });
|
|
163
163
|
return usada;
|
|
164
164
|
}
|
|
165
165
|
// Directorio público de la casa: tarjetas sin datos privados, con filtros por capacidad.
|
|
@@ -198,27 +198,27 @@ export class Estafeta {
|
|
|
198
198
|
local = String(local).toLowerCase();
|
|
199
199
|
const address = `${local}@${this.domain}`;
|
|
200
200
|
parseAddress(address);
|
|
201
|
-
if (Estafeta.RESERVED.has(local) && !this.isSystem(local)) throw Object.assign(new Error(`
|
|
201
|
+
if (Estafeta.RESERVED.has(local) && !this.isSystem(local)) throw Object.assign(new Error(`name reserved by the protocol: ${local}`), { status: 409 });
|
|
202
202
|
if (delegation) {
|
|
203
203
|
// Tarjeta delegada: el agente padre firma { by, address, sig, scope, valid_until }; el dominio la certifica igual.
|
|
204
204
|
const { local: parentLocal, domain: parentDomain } = parseAddress(delegation.by);
|
|
205
|
-
if (parentDomain !== this.domain || !local.endsWith(`.${parentLocal}`)) throw Object.assign(new Error(`
|
|
205
|
+
if (parentDomain !== this.domain || !local.endsWith(`.${parentLocal}`)) throw Object.assign(new Error(`a subagent of ${delegation.by} must be named <name>.${parentLocal}@${this.domain}`), { status: 400 });
|
|
206
206
|
const parent = await this.store.getAgent(parentLocal);
|
|
207
207
|
if (!parent) throw Object.assign(new Error('agente padre inexistente'), { status: 404 });
|
|
208
|
-
if (delegation.address !== address || delegation.sig !== sig || !verifyObject(delegation, parent.sig)) throw Object.assign(new Error('
|
|
208
|
+
if (delegation.address !== address || delegation.sig !== sig || !verifyObject(delegation, parent.sig)) throw Object.assign(new Error('invalid delegation: it must be signed by the parent and match the card'), { status: 403 });
|
|
209
209
|
// Un delegado nunca tiene más ámbito que su padre (invariante 6). Lo que el hijo no declara
|
|
210
210
|
// lo HEREDA: declarar menos no puede ser una forma de escapar del tope de la cadena.
|
|
211
211
|
const ps = parent.delegation?.scope;
|
|
212
212
|
if (ps) {
|
|
213
213
|
const hs = delegation.scope || {};
|
|
214
214
|
if (ps.cap != null && (hs.cap == null || hs.cap > ps.cap)) {
|
|
215
|
-
if (hs.cap != null) throw Object.assign(new Error(`
|
|
215
|
+
if (hs.cap != null) throw Object.assign(new Error(`a subagent cannot have a higher cap than its parent (${ps.cap})`), { status: 403 });
|
|
216
216
|
hs.cap = ps.cap; // sin tope declarado: hereda el del padre
|
|
217
217
|
}
|
|
218
218
|
for (const campo of ['types', 'to_domains']) {
|
|
219
219
|
if (ps[campo]?.length) {
|
|
220
220
|
if (!hs[campo]?.length) hs[campo] = [...ps[campo]];
|
|
221
|
-
else if (!hs[campo].every((x) => ps[campo].includes(x))) throw Object.assign(new Error(`
|
|
221
|
+
else if (!hs[campo].every((x) => ps[campo].includes(x))) throw Object.assign(new Error(`a subagent cannot widen ${campo} beyond its parent`), { status: 403 });
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
delegation = { ...delegation, scope: hs };
|
|
@@ -243,7 +243,7 @@ export class Estafeta {
|
|
|
243
243
|
creado = await (this.store.putAgentIfAbsent
|
|
244
244
|
? this.store.putAgentIfAbsent(local, { ...card, webhook, notify_email })
|
|
245
245
|
: (this.store.putAgent(local, { ...card, webhook, notify_email }), true));
|
|
246
|
-
if (!creado) throw Object.assign(new Error('
|
|
246
|
+
if (!creado) throw Object.assign(new Error('that name was just taken; only its owner or the house can update it'), { status: 409 });
|
|
247
247
|
} else {
|
|
248
248
|
await this.store.putAgent(local, { ...card, webhook, notify_email });
|
|
249
249
|
}
|
|
@@ -279,15 +279,15 @@ export class Estafeta {
|
|
|
279
279
|
let rec;
|
|
280
280
|
if (domain === this.domain) {
|
|
281
281
|
rec = await this.store.getAgent(local);
|
|
282
|
-
if (!rec) throw Object.assign(new Error('
|
|
282
|
+
if (!rec) throw Object.assign(new Error('agent not registered'), { status: 401 });
|
|
283
283
|
} else {
|
|
284
|
-
if (!allowForeign) throw Object.assign(new Error('
|
|
285
|
-
try { rec = await this.resolver.agentCard(claims.address); } catch (e) { throw Object.assign(new Error(`
|
|
284
|
+
if (!allowForeign) throw Object.assign(new Error('the agent does not belong to this domain'), { status: 401 });
|
|
285
|
+
try { rec = await this.resolver.agentCard(claims.address); } catch (e) { throw Object.assign(new Error(`foreign agent could not be verified: ${e.message}`), { status: 401 }); }
|
|
286
286
|
}
|
|
287
|
-
if (Math.abs(now() - Date.parse(claims.ts)) > 300_000) throw Object.assign(new Error('token
|
|
288
|
-
if (claims.method !== rx.method || claims.path !== path) throw Object.assign(new Error('token
|
|
289
|
-
if (claims.host !== this.authHost) throw Object.assign(new Error(`token
|
|
290
|
-
if (!verifyBytes(canonical(claims), m[2], rec.sig)) throw Object.assign(new Error('
|
|
287
|
+
if (Math.abs(now() - Date.parse(claims.ts)) > 300_000) throw Object.assign(new Error('token expired (5 minute window)'), { status: 401 });
|
|
288
|
+
if (claims.method !== rx.method || claims.path !== path) throw Object.assign(new Error('token does not match this request'), { status: 401 });
|
|
289
|
+
if (claims.host !== this.authHost) throw Object.assign(new Error(`token issued for another house (host ${claims.host || 'missing'}, expected ${this.authHost})`), { status: 401 });
|
|
290
|
+
if (!verifyBytes(canonical(claims), m[2], rec.sig)) throw Object.assign(new Error('invalid token signature'), { status: 401 });
|
|
291
291
|
if (!await this.store.useNonce(`${claims.address}:${claims.nonce}`, now())) throw Object.assign(new Error('nonce reutilizado'), { status: 401 });
|
|
292
292
|
return { local, address: claims.address, record: rec };
|
|
293
293
|
}
|
|
@@ -296,8 +296,8 @@ export class Estafeta {
|
|
|
296
296
|
async outbound(env, submitter) {
|
|
297
297
|
const v = validateEnvelope(env, { maxBytes: this.policy.max_bytes });
|
|
298
298
|
if (!v.ok) return v;
|
|
299
|
-
if (env.from !== submitter.address) return { ok: false, code: 403, reason: 'from
|
|
300
|
-
if (env.signature.kid !== submitter.record.sig || !verifyObject(env, submitter.record.sig)) return { ok: false, code: 403, reason: '
|
|
299
|
+
if (env.from !== submitter.address) return { ok: false, code: 403, reason: 'from does not match the authenticated agent' };
|
|
300
|
+
if (env.signature.kid !== submitter.record.sig || !verifyObject(env, submitter.record.sig)) return { ok: false, code: 403, reason: 'invalid envelope signature' };
|
|
301
301
|
const scope = submitter.record.delegation?.scope;
|
|
302
302
|
if (scope?.types?.length && !scope.types.includes(env.type)) return { ok: false, code: 403, reason: `agente delegado: solo puede enviar type ${scope.types.join('|')}` };
|
|
303
303
|
if (scope?.to_domains?.length && !env.to.every((t) => scope.to_domains.includes(parseAddress(t).domain))) return { ok: false, code: 403, reason: `agente delegado: solo puede escribir a ${scope.to_domains.join(', ')}` };
|
|
@@ -335,7 +335,7 @@ export class Estafeta {
|
|
|
335
335
|
async _deliver(job) {
|
|
336
336
|
// Un sobre diferido pudo vencer mientras esperaba: no desaparece en silencio, rebota al remitente.
|
|
337
337
|
if (job.envelope.expires && Date.parse(job.envelope.expires) < now()) {
|
|
338
|
-
for (const to of job.to) await this._bounce(job, to, '
|
|
338
|
+
for (const to of job.to) await this._bounce(job, to, 'the envelope expired while waiting in the queue');
|
|
339
339
|
job.status = 'failed'; await this.store.removeJob(job.id); await this._outbox(job); return;
|
|
340
340
|
}
|
|
341
341
|
job.attempts += 1;
|
|
@@ -378,20 +378,20 @@ export class Estafeta {
|
|
|
378
378
|
for (const r of outcome.body.rejected || []) (RETRYABLE.has(r.code) ? retry : failed).push(r);
|
|
379
379
|
// Destinatarios que la respuesta no menciona: transitorio, no éxito silencioso.
|
|
380
380
|
const mentioned = new Set([...delivered, ...(outcome.body.rejected || []).map((r) => r.to)]);
|
|
381
|
-
for (const to of job.to) if (!mentioned.has(to)) retry.push({ to, code: outcome.status, reason: '
|
|
381
|
+
for (const to of job.to) if (!mentioned.has(to)) retry.push({ to, code: outcome.status, reason: 'recipient missing a verdict in the response' });
|
|
382
382
|
} else if (outcome.status === 0 || RETRYABLE.has(outcome.status)) {
|
|
383
383
|
retry.push(...job.to.map((to) => ({ to, code: outcome.status, reason: outcome.error || outcome.body?.reason })));
|
|
384
384
|
} else {
|
|
385
385
|
failed.push(...job.to.map((to) => ({ to, code: outcome.status, reason: outcome.body?.reason || 'rechazo permanente' })));
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
for (const f of failed) await this._bounce(job, f.to, `
|
|
389
|
-
if (delivered.length && job.envelope.receipt === 'delivered') for (const to of delivered) await this._notify(job, to, 'delivered', '
|
|
388
|
+
for (const f of failed) await this._bounce(job, f.to, `rejected (${f.code}): ${f.reason}`);
|
|
389
|
+
if (delivered.length && job.envelope.receipt === 'delivered') for (const to of delivered) await this._notify(job, to, 'delivered', 'delivered to the destination estafeta');
|
|
390
390
|
|
|
391
391
|
if (retry.length) {
|
|
392
392
|
const age = now() - Date.parse(job.created);
|
|
393
393
|
if (age > this.retry.giveUpMs) {
|
|
394
|
-
for (const r of retry) await this._bounce(job, r.to, `
|
|
394
|
+
for (const r of retry) await this._bounce(job, r.to, `no answer after ${job.attempts} attempts: ${r.reason}`);
|
|
395
395
|
job.status = 'failed'; await this.store.removeJob(job.id); await this._outbox(job); return;
|
|
396
396
|
}
|
|
397
397
|
const backoff = Math.min(this.retry.baseMs * 2 ** (job.attempts - 1), this.retry.maxMs) * (0.8 + Math.random() * 0.4);
|
|
@@ -463,12 +463,12 @@ export class Estafeta {
|
|
|
463
463
|
async _tomarTarea(env, senderCard) {
|
|
464
464
|
const q = env.content?.body;
|
|
465
465
|
if (env.content?.media !== MEDIA.cotizacion || q?.tipo !== 'cotizacion') {
|
|
466
|
-
return { ok: false, code: 400, reason: `tareas@
|
|
466
|
+
return { ok: false, code: 400, reason: `tareas@ accepts a quote (${MEDIA.cotizacion}) for a published task; see GET /tareas` };
|
|
467
467
|
}
|
|
468
468
|
const tarea = this.tareas.tarea(q.terms?.seed_task);
|
|
469
|
-
if (!tarea) return { ok: false, code: 404, reason: `no
|
|
470
|
-
if (q.seller !== env.from) return { ok: false, code: 403, reason: '
|
|
471
|
-
if (q.buyer !== `tareas@${this.domain}`) return { ok: false, code: 400, reason: `
|
|
469
|
+
if (!tarea) return { ok: false, code: 404, reason: `no seeded task with id ${q.terms?.seed_task}. The published ones are at GET /tareas` };
|
|
470
|
+
if (q.seller !== env.from) return { ok: false, code: 403, reason: 'the seller of the quote must be whoever sends it' };
|
|
471
|
+
if (q.buyer !== `tareas@${this.domain}`) return { ok: false, code: 400, reason: `the quote must be addressed to tareas@${this.domain}` };
|
|
472
472
|
const encaja = this.tareas.coincide(q, tarea, { arbitro: `verifica@${this.domain}` });
|
|
473
473
|
if (!encaja.ok) return { ok: false, code: 409, reason: encaja.reason };
|
|
474
474
|
const contratos = await this.store.libroListContracts();
|
|
@@ -484,7 +484,7 @@ export class Estafeta {
|
|
|
484
484
|
content: { media: MEDIA.op, body: { op: 'accept', quote: q } },
|
|
485
485
|
}, this.keys);
|
|
486
486
|
const r = await this.inbound(aceptacion);
|
|
487
|
-
if (!r.ok) return { ok: false, code: r.code || 409, reason: `
|
|
487
|
+
if (!r.ok) return { ok: false, code: r.code || 409, reason: `the house could not take the task: ${r.reason}` };
|
|
488
488
|
const contrato = Object.values(r.results || {})[0]?.contract || null;
|
|
489
489
|
this.log(`tarea ${tarea.id} tomada por ${env.from} (contrato ${contrato?.id})`);
|
|
490
490
|
await this._evento('seed_task_taken', env.from, { task: tarea.id, contract: contrato?.id || null, price: tarea.price });
|
|
@@ -527,16 +527,16 @@ export class Estafeta {
|
|
|
527
527
|
const yaAceptados = new Set(seen?.accepted || []);
|
|
528
528
|
|
|
529
529
|
const locals = env.to.filter((t) => parseAddress(t).domain === this.domain);
|
|
530
|
-
if (!locals.length) return { ok: false, code: 404, reason: '
|
|
530
|
+
if (!locals.length) return { ok: false, code: 404, reason: 'no recipient belongs to this domain' };
|
|
531
531
|
const pendientes = locals.filter((t) => !yaAceptados.has(t));
|
|
532
532
|
if (!pendientes.length) return { ok: true, code: 200, duplicate: true, accepted: [...yaAceptados], rejected: [] };
|
|
533
533
|
|
|
534
534
|
// Cadena de confianza: dominio emisor -> agente emisor -> firma del sobre
|
|
535
535
|
let senderCard;
|
|
536
536
|
try { senderCard = await this.resolver.agentCardForKid(env.from, env.signature.kid); }
|
|
537
|
-
catch (e) { return { ok: false, code: e.permanent ? 403 : 421, reason: `
|
|
537
|
+
catch (e) { return { ok: false, code: e.permanent ? 403 : 421, reason: `could not verify the sender: ${e.message}` }; }
|
|
538
538
|
const validKids = Resolver.acceptedKids(senderCard);
|
|
539
|
-
if (!validKids.includes(env.signature.kid) || !verifyObject(env, env.signature.kid)) return { ok: false, code: 403, reason: '
|
|
539
|
+
if (!validKids.includes(env.signature.kid) || !verifyObject(env, env.signature.kid)) return { ok: false, code: 403, reason: 'the envelope signature does not match the sender' };
|
|
540
540
|
|
|
541
541
|
// Firma de relay (segunda capa: la estafeta emisora también firma, análogo a SPF/DKIM)
|
|
542
542
|
const { domain: fromDomain } = parseAddress(env.from);
|
|
@@ -545,8 +545,8 @@ export class Estafeta {
|
|
|
545
545
|
const r = Object.fromEntries(relayHeader.replace(/^nyx51\s*/, '').split(';').map((p) => p.trim().split('=').map((x) => x.trim())).filter((p) => p[0]));
|
|
546
546
|
relayVerified = r.domain === fromDomain && senderCard._domain.keys.some((k) => k.sig === r.kid) && verifyBytes(`relay:${env.id}:${this.domain}`, r.sig, r.kid);
|
|
547
547
|
}
|
|
548
|
-
if (this.policy.require_relay && !relayVerified) return { ok: false, code: 403, reason: '
|
|
549
|
-
if (!this.rate.allow(fromDomain)) return { ok: false, code: 429, reason: '
|
|
548
|
+
if (this.policy.require_relay && !relayVerified) return { ok: false, code: 403, reason: 'this domain requires a valid relay signature' };
|
|
549
|
+
if (!this.rate.allow(fromDomain)) return { ok: false, code: 429, reason: 'rate limit for the sending domain' };
|
|
550
550
|
|
|
551
551
|
const accepted = [], rejected = [], results = {};
|
|
552
552
|
const mails = [], libroBundles = [];
|
|
@@ -556,7 +556,7 @@ export class Estafeta {
|
|
|
556
556
|
for (const to of pendientes) {
|
|
557
557
|
const { local } = parseAddress(to);
|
|
558
558
|
const rec = await this.store.getAgent(local);
|
|
559
|
-
if (!rec) { rejected.push({ to, code: 404, reason: '
|
|
559
|
+
if (!rec) { rejected.push({ to, code: 404, reason: 'no such agent' }); continue; }
|
|
560
560
|
const p = applyInboxPolicy(env, rec, senderCard._domain);
|
|
561
561
|
if (!p.ok) { rejected.push({ to, ...p, ok: undefined }); continue; }
|
|
562
562
|
try {
|
|
@@ -578,7 +578,7 @@ export class Estafeta {
|
|
|
578
578
|
results[to] = r.result;
|
|
579
579
|
} else if (p.stamp) {
|
|
580
580
|
// Una estampilla paga UN buzón: el sobre declara un monto, no un monto por destinatario.
|
|
581
|
-
if (stampUsed) { rejected.push({ to, code: 402, reason: '
|
|
581
|
+
if (stampUsed) { rejected.push({ to, code: 402, reason: 'the envelope stamp was already spent on another recipient' }); continue; }
|
|
582
582
|
const { asiento, bundle } = await this.libro.stamp(env, to, p.stamp.price);
|
|
583
583
|
stampUsed = true;
|
|
584
584
|
libroBundles.push(bundle);
|
|
@@ -596,7 +596,7 @@ export class Estafeta {
|
|
|
596
596
|
: b.beneficiary !== p.vouch.beneficiary ? 'la fianza no tiene al receptor como beneficiario'
|
|
597
597
|
: b.verifier !== p.vouch.beneficiary ? 'el receptor no puede ejecutar la fianza (no es su verificador)'
|
|
598
598
|
: null;
|
|
599
|
-
if (malo) { rejected.push({ to, code: 403, reason: `
|
|
599
|
+
if (malo) { rejected.push({ to, code: 403, reason: `invalid vouch: ${malo}` }); continue; }
|
|
600
600
|
mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid, vouched_by: b.seller, vouch_bond: b.id } });
|
|
601
601
|
} else {
|
|
602
602
|
mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid } });
|
|
@@ -652,8 +652,8 @@ export class Estafeta {
|
|
|
652
652
|
if (emailOrig && emailOrig === rec.notify_email) return; // no te avises de tu propio correo
|
|
653
653
|
const quien = emailOrig || env.from;
|
|
654
654
|
await this.emailOut({ fromAgent: `${local}@${this.domain}`, to: rec.notify_email,
|
|
655
|
-
subject: `
|
|
656
|
-
text: `${quien}
|
|
655
|
+
subject: `New message on Nyx5 from ${quien}`,
|
|
656
|
+
text: `${quien} wrote to ${local}@${this.domain}.\n\nOpen it in the mailbox: https://${this.domain}/app\n\n(Automatic notice. The content is in the mailbox, not in this email.)` });
|
|
657
657
|
}).catch((e) => this.log(`notify_email ${local} falló: ${e.message}`));
|
|
658
658
|
this._pushes.push(pendiente);
|
|
659
659
|
return pendiente;
|
|
@@ -667,11 +667,11 @@ export class Estafeta {
|
|
|
667
667
|
// verifica igual por la cadena normal (DNS -> dominio -> agente) al momento de usarla.
|
|
668
668
|
async indexAddHouse(domain) {
|
|
669
669
|
domain = String(domain || '').toLowerCase();
|
|
670
|
-
if (!/^[a-z0-9.-]+$/.test(domain)) throw Object.assign(new Error('
|
|
670
|
+
if (!/^[a-z0-9.-]+$/.test(domain)) throw Object.assign(new Error('invalid domain'), { status: 400 });
|
|
671
671
|
const houses = await this.store.indexListHouses();
|
|
672
|
-
if (houses.length >= this.index.maxHouses && !houses.some((h) => h.domain === domain)) throw Object.assign(new Error('
|
|
672
|
+
if (houses.length >= this.index.maxHouses && !houses.some((h) => h.domain === domain)) throw Object.assign(new Error('index full'), { status: 507 });
|
|
673
673
|
// La verificación ES la puerta: solo se lista lo que resuelve y firma como casa Nyx5.
|
|
674
|
-
const dc = await this.resolver.domainCard(domain).catch((e) => { throw Object.assign(new Error(`
|
|
674
|
+
const dc = await this.resolver.domainCard(domain).catch((e) => { throw Object.assign(new Error(`house could not be verified: ${e.message}`), { status: 422 }); });
|
|
675
675
|
const h = { domain, estafeta: dc._estafeta, added: iso(), last_ok: null, fails: 0 };
|
|
676
676
|
await this.store.indexPutHouse(h);
|
|
677
677
|
await this._indexCrawlHouse(h);
|
|
@@ -719,12 +719,12 @@ export class Estafeta {
|
|
|
719
719
|
// from_verified:false y via:'email'. No pasa por /inbound ni finge estar firmado (invariante 1).
|
|
720
720
|
async receiveEmail({ from, to, subject, text, messageId } = {}) {
|
|
721
721
|
await this.init();
|
|
722
|
-
if (!isEmailAddress(from)) return { ok: false, code: 400, reason: '
|
|
722
|
+
if (!isEmailAddress(from)) return { ok: false, code: 400, reason: 'invalid email sender' };
|
|
723
723
|
let local, dom;
|
|
724
|
-
try { ({ local, domain: dom } = parseAddress(to)); } catch { return { ok: false, code: 400, reason: '
|
|
725
|
-
if (dom !== this.domain) return { ok: false, code: 400, reason: `
|
|
724
|
+
try { ({ local, domain: dom } = parseAddress(to)); } catch { return { ok: false, code: 400, reason: 'invalid recipient' }; }
|
|
725
|
+
if (dom !== this.domain) return { ok: false, code: 400, reason: `the email is for ${dom}, not ${this.domain}` };
|
|
726
726
|
const rec = await this.store.getAgent(local);
|
|
727
|
-
if (!rec) return { ok: false, code: 404, reason: '
|
|
727
|
+
if (!rec) return { ok: false, code: 404, reason: 'no such agent' };
|
|
728
728
|
const env = inboundEnvelope({ from, to: `${local}@${this.domain}`, subject, text, messageId });
|
|
729
729
|
// dedupe por id (message-id del correo o uuid) igual que un sobre normal
|
|
730
730
|
const nuevo = await this.store.markSeenIfNew(env.id, { from: `email:${from}`, accepted: [to] });
|
|
@@ -736,13 +736,13 @@ export class Estafeta {
|
|
|
736
736
|
// SALIDA: un agente le escribe a una dirección de correo. Con proveedor, se envía (Reply-To = el
|
|
737
737
|
// agente, para que la respuesta vuelva por ENTRADA). Sin proveedor, queda pendiente: no se inventa canal.
|
|
738
738
|
async emailOut({ fromAgent, to, subject, text }) {
|
|
739
|
-
if (!isEmailAddress(to)) return { ok: false, code: 400, reason: '
|
|
739
|
+
if (!isEmailAddress(to)) return { ok: false, code: 400, reason: 'invalid email destination' };
|
|
740
740
|
// El pie viaja solo si la casa lo enciende (`email.footer`). Apagado por defecto: el texto
|
|
741
741
|
// que un tercero recibe es decisión del operador de la casa, no del código.
|
|
742
742
|
const payload = outboundPayload({ fromAgent, to, subject, text, footer: this.email.footer === true, domain: this.domain });
|
|
743
|
-
if (!this.email.provider) return { ok: false, code: 503, pending: true, reason: '
|
|
743
|
+
if (!this.email.provider) return { ok: false, code: 503, pending: true, reason: 'the email bridge has no outbound provider configured' };
|
|
744
744
|
try { const r = await this.email.provider(payload); return { ok: true, code: 202, provider: r?.id || null }; }
|
|
745
|
-
catch (e) { return { ok: false, code: 502, reason: `
|
|
745
|
+
catch (e) { return { ok: false, code: 502, reason: `the email provider failed: ${e.message}` }; }
|
|
746
746
|
}
|
|
747
747
|
|
|
748
748
|
async indexSearch(params) {
|
|
@@ -761,15 +761,10 @@ export class Estafeta {
|
|
|
761
761
|
if (rx.method === 'GET' && path === '/health') return send(200, { ok: true, domain: this.domain, agents: (await this.store.listAgents()).length, queue: (await this.store.listQueue()).length });
|
|
762
762
|
// Cliente web para personas: se sirve desde la propia casa (mismo origen, sin CORS).
|
|
763
763
|
// El HTML genera las llaves en el navegador del usuario; la casa nunca las ve.
|
|
764
|
-
// Inglés primario en la portada; el español vive en /es-home. Ambas se enlazan con hreflang.
|
|
765
764
|
if (rx.method === 'GET' && path === '/') return { status: 200, body: HOME_HTML, contentType: 'text/html; charset=utf-8' };
|
|
766
|
-
if (rx.method === 'GET' && (path === '/es-home' || path === '/es-home/')) return { status: 200, body: HOME_HTML_ES, contentType: 'text/html; charset=utf-8' };
|
|
767
765
|
if (rx.method === 'GET' && (path === '/app' || path === '/app/')) return { status: 200, body: APP_HTML, contentType: 'text/html; charset=utf-8' };
|
|
768
766
|
// La especificación en una página, indexable. Se genera desde docs/SPEC.md (build:spec).
|
|
769
|
-
// El inglés es la versión canónica de la spec (es donde busca quien integra un protocolo);
|
|
770
|
-
// el español vive en /es y ambas se enlazan entre sí con hreflang.
|
|
771
767
|
if (rx.method === 'GET' && (path === '/spec' || path === '/spec/')) return { status: 200, body: SPEC_HTML, contentType: 'text/html; charset=utf-8' };
|
|
772
|
-
if (rx.method === 'GET' && (path === '/es' || path === '/es/' || path === '/spec/es')) return { status: 200, body: SPEC_HTML_ES, contentType: 'text/html; charset=utf-8' };
|
|
773
768
|
if (rx.method === 'GET' && path === '/llms.txt') return { status: 200, body: LLMS_TXT, contentType: 'text/plain; charset=utf-8' };
|
|
774
769
|
if (rx.method === 'GET' && path === '/.well-known/nyx5.json') return send(200, await this.domainCard());
|
|
775
770
|
let m;
|
|
@@ -778,12 +773,12 @@ export class Estafeta {
|
|
|
778
773
|
// contrapartes: solo cuántas entregas, cuántas fianzas y cuántos tokens se movieron.
|
|
779
774
|
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/historial$/.exec(path))) {
|
|
780
775
|
const local = decodeURIComponent(m[1]).toLowerCase();
|
|
781
|
-
if (!(await this.store.getAgent(local))) return send(404, { reason: '
|
|
776
|
+
if (!(await this.store.getAgent(local))) return send(404, { reason: 'no such agent' });
|
|
782
777
|
return send(200, await this.libro.historial(`${local}@${this.domain}`));
|
|
783
778
|
}
|
|
784
779
|
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)$/.exec(path))) {
|
|
785
780
|
const card = await this.agentCard(decodeURIComponent(m[1]).toLowerCase());
|
|
786
|
-
return card ? send(200, card) : send(404, { reason: '
|
|
781
|
+
return card ? send(200, card) : send(404, { reason: 'no such agent' });
|
|
787
782
|
}
|
|
788
783
|
if (rx.method === 'GET' && path === '/agents') {
|
|
789
784
|
const p = Object.fromEntries(rx.query);
|
|
@@ -792,7 +787,7 @@ export class Estafeta {
|
|
|
792
787
|
if (rx.method === 'POST' && path === '/agents') {
|
|
793
788
|
const body = rx.body || {};
|
|
794
789
|
const local = String(body.local || '').toLowerCase();
|
|
795
|
-
if (!Estafeta.validLocal(local)) return send(400, { reason: '
|
|
790
|
+
if (!Estafeta.validLocal(local)) return send(400, { reason: 'invalid agent name' });
|
|
796
791
|
const exists = !!(await this.store.getAgent(local));
|
|
797
792
|
const isAdmin = (rx.headers.authorization || '') === `Bearer ${this.adminToken}`;
|
|
798
793
|
let ok = isAdmin, via = 'admin';
|
|
@@ -803,13 +798,13 @@ export class Estafeta {
|
|
|
803
798
|
}
|
|
804
799
|
if (!ok) {
|
|
805
800
|
// Auto-registro: el cuerpo viene firmado por la clave que se inscribe (prueba de posesión).
|
|
806
|
-
if (exists) return send(409, { reason: '
|
|
807
|
-
if (!body.signature || body.signature.kid !== body.sig || !verifyObject(body, body.sig)) return send(401, { reason: '
|
|
808
|
-
if (Math.abs(now() - Date.parse(body.ts || 0)) > 300_000) return send(401, { reason: '
|
|
809
|
-
if (!this.regRate.allow(rx.ip || 'x')) return send(429, { reason: '
|
|
801
|
+
if (exists) return send(409, { reason: 'that name is taken; only its owner or the house can update it' });
|
|
802
|
+
if (!body.signature || body.signature.kid !== body.sig || !verifyObject(body, body.sig)) return send(401, { reason: 'to self-register, sign the body with the same sig key you are enrolling (proof of possession)' });
|
|
803
|
+
if (Math.abs(now() - Date.parse(body.ts || 0)) > 300_000) return send(401, { reason: 'the signed request needs a ts (ISO) within 5 minutes' });
|
|
804
|
+
if (!this.regRate.allow(rx.ip || 'x')) return send(429, { reason: 'too many registrations from this address' });
|
|
810
805
|
if (this.policy.registration === 'open') via = 'open';
|
|
811
806
|
else if (this.policy.registration === 'invite') { const inv = await this._consumeInvite(body.invite); via = `invite:${inv.code}`; if (inv.welcome != null) body._welcome = inv.welcome; }
|
|
812
|
-
else return send(403, { reason: `
|
|
807
|
+
else return send(403, { reason: `this house does not accept self-registration (registration=${this.policy.registration}); ask for an invitation` });
|
|
813
808
|
}
|
|
814
809
|
// `source` es atribución y NO entra en la tarjeta: se descarta aquí y viaja al evento.
|
|
815
810
|
const { signature: _s, ts: _t, invite: _i, _welcome, source: _src, ...clean } = body;
|
|
@@ -822,11 +817,11 @@ export class Estafeta {
|
|
|
822
817
|
return send(201, { ...card, registered_via: via });
|
|
823
818
|
}
|
|
824
819
|
if (rx.method === 'POST' && path === '/invitations') {
|
|
825
|
-
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: '
|
|
820
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house issues invitations' });
|
|
826
821
|
return send(201, await this.createInvite(rx.body || {}));
|
|
827
822
|
}
|
|
828
823
|
if (rx.method === 'GET' && path === '/invitations') {
|
|
829
|
-
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: '
|
|
824
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house lists invitations' });
|
|
830
825
|
return send(200, { invitations: await this.store.listInvites() });
|
|
831
826
|
}
|
|
832
827
|
if (rx.method === 'POST' && path === '/outbound') {
|
|
@@ -850,7 +845,7 @@ export class Estafeta {
|
|
|
850
845
|
}
|
|
851
846
|
if (rx.method === 'GET' && (m = /^\/mailbox\/([^/]+)$/.exec(path))) {
|
|
852
847
|
const who = await this._authenticate(rx, path);
|
|
853
|
-
if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: '
|
|
848
|
+
if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
|
|
854
849
|
const limit = Number(rx.query.get('limit') || 50);
|
|
855
850
|
// los N más recientes (listMail viene en orden cronológico): con muchos mensajes viejos
|
|
856
851
|
// sin ackear, slice(0,limit) escondía justo los nuevos. slice(-limit) muestra los últimos.
|
|
@@ -858,7 +853,7 @@ export class Estafeta {
|
|
|
858
853
|
}
|
|
859
854
|
if (rx.method === 'POST' && (m = /^\/mailbox\/([^/]+)\/ack$/.exec(path))) {
|
|
860
855
|
const who = await this._authenticate(rx, path);
|
|
861
|
-
if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: '
|
|
856
|
+
if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
|
|
862
857
|
const { ids = [] } = rx.body || {};
|
|
863
858
|
const acked = [];
|
|
864
859
|
for (const id of ids) if (await this.store.ackMail(who.local, id)) acked.push(id);
|
|
@@ -875,11 +870,11 @@ export class Estafeta {
|
|
|
875
870
|
const who = await this._authenticate(rx, path, { allowForeign: true });
|
|
876
871
|
const c = await this.store.libroGetContract(decodeURIComponent(m[1]));
|
|
877
872
|
if (!c) return send(404, { reason: 'contrato inexistente' });
|
|
878
|
-
if (![c.seller, c.buyer, c.verifier, c.arbiter].includes(who.address)) return send(403, { reason: '
|
|
873
|
+
if (![c.seller, c.buyer, c.verifier, c.arbiter].includes(who.address)) return send(403, { reason: 'you are not a party to this' });
|
|
879
874
|
return send(200, contratoPublico(c));
|
|
880
875
|
}
|
|
881
876
|
if (rx.method === 'POST' && path === '/libro/topup') {
|
|
882
|
-
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: '
|
|
877
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house tops up balances' });
|
|
883
878
|
const { account, amount, concept } = rx.body || {};
|
|
884
879
|
return send(201, await this.libro.topup(account, amount, concept || 'carga de la casa'));
|
|
885
880
|
}
|
|
@@ -889,7 +884,7 @@ export class Estafeta {
|
|
|
889
884
|
// El catálogo es público: un agente que acaba de unirse tiene que poder leer qué hay
|
|
890
885
|
// que hacer, cuánto paga y con qué prueba se comprueba, sin autenticarse.
|
|
891
886
|
if (rx.method === 'GET' && path === '/tareas') {
|
|
892
|
-
if (!this.tareas.enabled) return send(404, { reason: '
|
|
887
|
+
if (!this.tareas.enabled) return send(404, { reason: 'this house does not seed work' });
|
|
893
888
|
return send(200, {
|
|
894
889
|
mostrador: `tareas@${this.domain}`, arbitro: `verifica@${this.domain}`,
|
|
895
890
|
por_agente_dia: this.tareas.porAgenteDia,
|
|
@@ -898,7 +893,7 @@ export class Estafeta {
|
|
|
898
893
|
});
|
|
899
894
|
}
|
|
900
895
|
if (rx.method === 'GET' && path === '/eventos') {
|
|
901
|
-
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: '
|
|
896
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house reads its events' });
|
|
902
897
|
const p = Object.fromEntries(rx.query);
|
|
903
898
|
const eventos = (await this.store.listEvents?.({ name: p.name || null, since: p.since || null, limit: Number(p.limit || 500) })) || [];
|
|
904
899
|
const conteo = {};
|
|
@@ -906,7 +901,7 @@ export class Estafeta {
|
|
|
906
901
|
return send(200, { conteo, eventos });
|
|
907
902
|
}
|
|
908
903
|
if (rx.method === 'GET' && path === '/libro/diario') {
|
|
909
|
-
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: '
|
|
904
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house reads the full journal' });
|
|
910
905
|
return send(200, { balances: (await this.store.libroState()).balances, journal: await this.libro.journal() });
|
|
911
906
|
}
|
|
912
907
|
if (rx.method === 'GET' && (m = /^\/outbox\/([^/]+)$/.exec(path))) {
|
package/src/correo/politica.js
CHANGED
|
@@ -19,7 +19,7 @@ export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
|
|
|
19
19
|
if (typeof env.id !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(env.id)) return fail('id inválido (se espera [A-Za-z0-9._:-]{8,128})');
|
|
20
20
|
try { parseAddress(env.from); } catch { return fail('from inválido'); }
|
|
21
21
|
if (!Array.isArray(env.to) || env.to.length < 1 || env.to.length > 50) return fail('to debe ser una lista de 1 a 50 direcciones');
|
|
22
|
-
for (const t of env.to) { try { parseAddress(t); } catch { return fail(`
|
|
22
|
+
for (const t of env.to) { try { parseAddress(t); } catch { return fail(`invalid recipient: ${t}`); } }
|
|
23
23
|
if (!TYPES.has(env.type)) return fail(`type inválido: ${env.type}`);
|
|
24
24
|
if (Number.isNaN(Date.parse(env.created))) return fail('created debe ser ISO-8601');
|
|
25
25
|
if (env.expires != null && Number.isNaN(Date.parse(env.expires))) return fail('expires debe ser ISO-8601');
|
|
@@ -39,7 +39,7 @@ export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
|
|
|
39
39
|
}
|
|
40
40
|
if (!env.signature || env.signature.alg !== 'Ed25519' || !env.signature.kid || !env.signature.value) return fail('sobre sin firma');
|
|
41
41
|
const bytes = Buffer.byteLength(JSON.stringify(env));
|
|
42
|
-
if (bytes > maxBytes) return { ok: false, code: 413, reason: `
|
|
42
|
+
if (bytes > maxBytes) return { ok: false, code: 413, reason: `envelope of ${bytes} bytes exceeds the maximum ${maxBytes}` };
|
|
43
43
|
return { ok: true };
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -91,7 +91,7 @@ export function applyInboxPolicy(env, agentRecord, senderDomain) {
|
|
|
91
91
|
case 'pow': {
|
|
92
92
|
const bits = inbox.pow_bits ?? 16;
|
|
93
93
|
const exempt = inbox.allowlist?.some((x) => x === env.from || x === fromDomain);
|
|
94
|
-
if (!exempt && !checkPow(env.id, env.pow, bits)) return { ok: false, code: 402, reason: `
|
|
94
|
+
if (!exempt && !checkPow(env.id, env.pow, bits)) return { ok: false, code: 402, reason: `proof-of-work of ${bits} bits is required`, pow_bits: bits };
|
|
95
95
|
break;
|
|
96
96
|
}
|
|
97
97
|
case 'stamp': {
|
package/src/correo/resolver.js
CHANGED
|
@@ -13,7 +13,7 @@ import { verifyObject } from '../nucleo/crypto.js';
|
|
|
13
13
|
|
|
14
14
|
export function parseAddress(address) {
|
|
15
15
|
const m = /^([a-z0-9][a-z0-9._-]{0,63})@([a-z0-9.-]+)$/i.exec(String(address || ''));
|
|
16
|
-
if (!m) throw new Error(`
|
|
16
|
+
if (!m) throw new Error(`invalid address: ${address}`);
|
|
17
17
|
return { local: m[1].toLowerCase(), domain: m[2].toLowerCase() };
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -72,7 +72,7 @@ export class Resolver {
|
|
|
72
72
|
// Cualquier OTRA falla de DNS (red, timeout, servfail) NO degrada el ancla en silencio:
|
|
73
73
|
// un atacante que bloquee la respuesta DNS no puede empujarnos a well-known + TOFU.
|
|
74
74
|
if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') {
|
|
75
|
-
throw Object.assign(new Error(`DNS
|
|
75
|
+
throw Object.assign(new Error(`DNS unavailable for ${domain}: ${e.message}`), { permanent: false });
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
return { source: 'well-known', url: `https://${domain}` };
|
|
@@ -90,14 +90,14 @@ export class Resolver {
|
|
|
90
90
|
|
|
91
91
|
const loc = await this.locate(domain);
|
|
92
92
|
const card = await this._get(`${loc.url.replace(/\/$/, '')}/.well-known/nyx5.json`);
|
|
93
|
-
if (card.nyx5 !== '1' || card.domain !== domain) throw Object.assign(new Error(`
|
|
93
|
+
if (card.nyx5 !== '1' || card.domain !== domain) throw Object.assign(new Error(`invalid domain card for ${domain}`), { permanent: true });
|
|
94
94
|
const keyIds = (card.keys || []).map((k) => k.sig);
|
|
95
95
|
if (!keyIds.includes(card.signature?.kid) || !verifyObject(card, card.signature.kid)) {
|
|
96
|
-
throw Object.assign(new Error(`
|
|
96
|
+
throw Object.assign(new Error(`invalid domain signature for ${domain}`), { permanent: true });
|
|
97
97
|
}
|
|
98
98
|
// Ancla: DNS/override dice qué clave debe tener el dominio. Si no hay ancla, TOFU (pin en primer uso).
|
|
99
99
|
const anchor = loc.sig || this.pins[domain];
|
|
100
|
-
if (anchor && !keyIds.includes(anchor)) throw Object.assign(new Error(`
|
|
100
|
+
if (anchor && !keyIds.includes(anchor)) throw Object.assign(new Error(`the key for domain ${domain} does not match the anchored one`), { permanent: true });
|
|
101
101
|
if (!anchor) {
|
|
102
102
|
this.pins[domain] = card.signature.kid;
|
|
103
103
|
// Se persiste ESE pin, no el mapa entero: escribir el mapa desde la memoria de un proceso
|
|
@@ -135,19 +135,19 @@ export class Resolver {
|
|
|
135
135
|
// La verificación de una tarjeta de agente, en un solo lugar: certificación del dominio,
|
|
136
136
|
// vigencia y cadena de delegación. La usan el camino local y el remoto por igual.
|
|
137
137
|
async _verifyAgentCard(card, dc, address, local, domain) {
|
|
138
|
-
if (card.nyx5 !== '1' || card.address !== `${local}@${domain}`) throw Object.assign(new Error(`
|
|
138
|
+
if (card.nyx5 !== '1' || card.address !== `${local}@${domain}`) throw Object.assign(new Error(`invalid agent card: ${address}`), { permanent: true });
|
|
139
139
|
const domainKeys = dc.keys.map((k) => k.sig);
|
|
140
140
|
if (!domainKeys.includes(card.certification?.kid) || !verifyObject(card, card.certification.kid, 'certification')) {
|
|
141
|
-
throw Object.assign(new Error(`
|
|
141
|
+
throw Object.assign(new Error(`invalid certification for ${address}`), { permanent: true });
|
|
142
142
|
}
|
|
143
143
|
if (card.valid_until && Date.parse(card.valid_until) < Date.now()) throw Object.assign(new Error(`tarjeta vencida: ${address}`), { permanent: true });
|
|
144
144
|
if (card.delegation) {
|
|
145
145
|
// Cadena de delegación: el padre (ya certificado por el dominio) firmó esta tarjeta.
|
|
146
146
|
const d = card.delegation;
|
|
147
147
|
const { local: parentLocal, domain: parentDomain } = parseAddress(d.by);
|
|
148
|
-
if (parentDomain !== domain || !local.endsWith(`.${parentLocal}`) || d.address !== card.address || d.sig !== card.sig) throw Object.assign(new Error(`
|
|
148
|
+
if (parentDomain !== domain || !local.endsWith(`.${parentLocal}`) || d.address !== card.address || d.sig !== card.sig) throw Object.assign(new Error(`inconsistent delegation on ${address}`), { permanent: true });
|
|
149
149
|
const parent = await this.agentCard(d.by);
|
|
150
|
-
if (!Resolver.acceptedKids(parent).includes(d.signature?.kid) || !verifyObject(d, d.signature.kid)) throw Object.assign(new Error(`
|
|
150
|
+
if (!Resolver.acceptedKids(parent).includes(d.signature?.kid) || !verifyObject(d, d.signature.kid)) throw Object.assign(new Error(`delegation not signed by ${d.by}`), { permanent: true });
|
|
151
151
|
card = { ...card, delegation: { ...d, _parent: parent } };
|
|
152
152
|
}
|
|
153
153
|
return { ...card, _estafeta: dc._estafeta, _domain: dc };
|