@dotrino/vaultd 0.133.0 → 0.134.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/lib/src/service.js +69 -0
- package/package.json +1 -1
- package/src/vault.js +107 -12
package/lib/src/service.js
CHANGED
|
@@ -512,6 +512,75 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
512
512
|
} finally { client.close() }
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* GUARDA VARIABLES EN EL CAJÓN DE ESTE SERVICIO (dueño, 2026-09-29: «para facilitar la
|
|
517
|
+
* migración de ENVs a Dotrino»). Es lo que hay detrás de `dotrino-env import`.
|
|
518
|
+
*
|
|
519
|
+
* Los valores se sellan AQUÍ, en la máquina del servicio, para quien diga el acta; la
|
|
520
|
+
* bóveda recibe sobres que no abre. La lista de destinatarios la firma la bóveda y se
|
|
521
|
+
* comprueba (`verifyResponder`): si el proxio pudiera cambiarla por sus llaves, abriría las
|
|
522
|
+
* privadas al pasar.
|
|
523
|
+
*
|
|
524
|
+
* Lo que decide la bóveda, no este cliente:
|
|
525
|
+
* · si hay quien apruebe, se pide aprobación siempre, y al aprobarse puede EDITAR;
|
|
526
|
+
* · si nadie puede aprobar, solo AÑADE lo que falta (una que ya exista falla con `exists`);
|
|
527
|
+
* · BORRAR no existe por aquí.
|
|
528
|
+
*
|
|
529
|
+
* @param {object} o
|
|
530
|
+
* @param {Array<{ key:string, value:string, public?:boolean }>} o.vars
|
|
531
|
+
* @param {string} [o.dir] identidad de `dotrino-env` (`service-identity.json`); o, en su
|
|
532
|
+
* lugar, la identidad por parámetros (`ns`, `proxyUrl`, `masterPubkey`, `device`, `cert`),
|
|
533
|
+
* que es como la trae un agente de remote-agent.
|
|
534
|
+
* @returns {Promise<{ keys: string[], pending: string|null, approval: boolean }>}
|
|
535
|
+
* `pending`: el id del pedido de aprobación (se guarda al aprobar); `keys`: lo guardado ya.
|
|
536
|
+
*/
|
|
537
|
+
export async function storeVars ({ dir, ns, proxyUrl, masterPubkey, device, cert, vars, timeoutMs = 30000 } = {}) {
|
|
538
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
539
|
+
ns = ns || saved?.ns
|
|
540
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
541
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
542
|
+
device = device || saved?.device
|
|
543
|
+
cert = cert || saved?.cert
|
|
544
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
|
|
545
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
546
|
+
if (!Array.isArray(vars) || !vars.length) throw new Error('storeVars: no variables to store')
|
|
547
|
+
for (const v of vars) {
|
|
548
|
+
if (typeof v?.key !== 'string' || !v.key) throw new Error('storeVars: every variable needs a key')
|
|
549
|
+
if (typeof v.value !== 'string' || !v.value) throw Object.assign(new Error(`storeVars: "${v.key}" has no value`), { code: 'novalue' })
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
553
|
+
try {
|
|
554
|
+
await identifyAsService(client, device)
|
|
555
|
+
const ask = async (data) => {
|
|
556
|
+
const signed = { ...data, ns, publickey: device.publickey, ts: Date.now() }
|
|
557
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
|
|
558
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
559
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
|
|
560
|
+
const res = await pending
|
|
561
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
562
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
563
|
+
if (res.body?.op !== data.op.replace(/^store$/, 'store.result') || res.body?.ns !== ns) throw new Error('the vault answered something else')
|
|
564
|
+
return res.body
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const { recipients, approval } = await ask({ op: 'store.recipients' })
|
|
568
|
+
const { buildSealedVar, authorFromDeviceKey } = await import('./admin.js')
|
|
569
|
+
const author = authorFromDeviceKey(device)
|
|
570
|
+
const items = []
|
|
571
|
+
for (const v of vars) {
|
|
572
|
+
const sealed = await buildSealedVar({
|
|
573
|
+
recipients: v.public ? recipients.public : recipients.private,
|
|
574
|
+
owner: `ns:${ns}`, key: v.key, value: v.value, author
|
|
575
|
+
})
|
|
576
|
+
items.push({ key: v.key, sealed, public: !!v.public })
|
|
577
|
+
}
|
|
578
|
+
const nonce = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
579
|
+
const r = await ask({ op: 'store', items, nonce })
|
|
580
|
+
return { keys: r.keys || [], pending: r.pending || null, approval: !!approval }
|
|
581
|
+
} finally { client.close() }
|
|
582
|
+
}
|
|
583
|
+
|
|
515
584
|
/**
|
|
516
585
|
* Pide los secretos del ns al vault (una petición puntual; lanza si falla).
|
|
517
586
|
* Usa la identidad persistida por `enrollService` salvo que se pase explícita.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.134.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
package/src/vault.js
CHANGED
|
@@ -150,6 +150,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
150
150
|
* permisos como los demás, y se quita quitándolo — sin acordarse de un registro escondido
|
|
151
151
|
* en una máquina.
|
|
152
152
|
*/
|
|
153
|
+
/** ¿Hay en el acta alguien que pueda aprobar? Sin nadie, no se pide aprobación. */
|
|
154
|
+
function hasApprover (record) {
|
|
155
|
+
return !!record?.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
156
|
+
}
|
|
157
|
+
|
|
153
158
|
function needsApproval (pub, record) {
|
|
154
159
|
if (!record) return true // sin acta no se decide que sí: se pide permiso
|
|
155
160
|
// SIN NADIE QUE APRUEBE, NO SE NECESITA APROBACIÓN (dueño, 2026-09-24: «la regla es, si
|
|
@@ -1423,6 +1428,80 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1423
1428
|
}
|
|
1424
1429
|
}
|
|
1425
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* UN SERVICIO GUARDA VARIABLES EN SU PROPIO CAJÓN (dueño, 2026-09-29: «para facilitar la
|
|
1433
|
+
* migración de ENVs a Dotrino»). Es lo que usa `dotrino-env import`: el `.env` que ya
|
|
1434
|
+
* existe en la máquina del servicio sube tal cual, sin copiarlo a mano en la consola.
|
|
1435
|
+
*
|
|
1436
|
+
* Va por el canal del servicio —el mismo que lee el cajón— y con SUS fronteras: el cert
|
|
1437
|
+
* con `vault:secrets:<ns>` y el CN del acta. Así un servicio solo escribe en su cajón y
|
|
1438
|
+
* nunca en el de otro. La guarda la hace la MISMA pieza que la consola (`varsDesk`): la
|
|
1439
|
+
* firma del autor, las envolturas, la espera de aprobación. Aquí no hay otra puerta.
|
|
1440
|
+
*
|
|
1441
|
+
* Los límites (dueño, 2026-09-29):
|
|
1442
|
+
* · si hay alguien que aprueba, SIEMPRE se pide aprobación —`unattended` no exime— y con
|
|
1443
|
+
* el sí puede también EDITAR lo que ya existe;
|
|
1444
|
+
* · si nadie puede aprobar, no se pide y solo puede AÑADIR lo que falta;
|
|
1445
|
+
* · BORRAR, nunca: eso es de la consola y del CLI de la bóveda.
|
|
1446
|
+
*
|
|
1447
|
+
* Dos operaciones: `store.recipients` (para quién se sella: solo públicas, nada que ocultar)
|
|
1448
|
+
* y `store` (los sobres ya hechos; la bóveda no los abre).
|
|
1449
|
+
*/
|
|
1450
|
+
const storeNonces = new Map()
|
|
1451
|
+
async function handleVarStore (from, p) {
|
|
1452
|
+
const ns = p.data?.ns
|
|
1453
|
+
const op = p.data?.op
|
|
1454
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: `${op}: invalid namespace` })
|
|
1455
|
+
const chk = await verifyChain({
|
|
1456
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1457
|
+
expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
|
|
1458
|
+
})
|
|
1459
|
+
if (!chk.ok) return denyChain(from, chk, p, op)
|
|
1460
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1461
|
+
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1462
|
+
audit('rejected', { what: op, ns, reason: record ? 'cn' : 'sin-acta' })
|
|
1463
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1464
|
+
}
|
|
1465
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1466
|
+
// FIRMADA COMO TODO LO QUE SIRVE LA BÓVEDA (con la llave de sellado que nombra el acta, y
|
|
1467
|
+
// el acta al lado): `store.recipients` dice PARA QUIÉN se sellan los valores, y si el
|
|
1468
|
+
// proxio pudiera cambiar esa lista por sus llaves, abriría las privadas al pasar.
|
|
1469
|
+
const answer = async (body) => reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1470
|
+
try {
|
|
1471
|
+
if (op === 'store.recipients') {
|
|
1472
|
+
return await answer({
|
|
1473
|
+
op: 'store.recipients', ns, ts: Date.now(),
|
|
1474
|
+
recipients: { public: await varsDesk.recipients({ ns, public: true }), private: await varsDesk.recipients({ ns, public: false }) },
|
|
1475
|
+
// Para que quien importa sepa de antemano qué va a pasar con lo que ya existe.
|
|
1476
|
+
approval: hasApprover(record)
|
|
1477
|
+
})
|
|
1478
|
+
}
|
|
1479
|
+
// CAMBIA ESTADO: un solo uso. La frescura (±5 min) no basta — repetir el mismo sobre
|
|
1480
|
+
// dentro de esa ventana volvería a timbrar el teléfono de quien aprueba.
|
|
1481
|
+
const nonce = p.data?.nonce
|
|
1482
|
+
if (typeof nonce !== 'string' || nonce.length < 16) return reply(from, { type: MSG.ERROR, code: 'no-nonce', error: 'store: missing single-use nonce' })
|
|
1483
|
+
const now = Date.now()
|
|
1484
|
+
for (const [n, exp] of storeNonces) if (exp <= now) storeNonces.delete(n)
|
|
1485
|
+
if (storeNonces.has(nonce)) { audit('rejected', { what: 'store', ns, reason: 'replay' }); return reply(from, { type: MSG.ERROR, code: 'replay', error: 'store: nonce already used' }) }
|
|
1486
|
+
storeNonces.set(nonce, now + 10 * 60 * 1000)
|
|
1487
|
+
|
|
1488
|
+
const items = p.data?.items
|
|
1489
|
+
if (!Array.isArray(items) || !items.length) return reply(from, { type: MSG.ERROR, error: 'store: needs `items`, each with its key and its sealed envelope' })
|
|
1490
|
+
const result = await varsDesk.setMany({ ns, items, caller: chk.device, by: deviceId, fromService: true })
|
|
1491
|
+
if (result?.pending) {
|
|
1492
|
+
audit('store.pending', { device: deviceId, ns, id: result.pending, count: items.length })
|
|
1493
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} wants to store ${items.length} variable(s) — waiting for approval (${result.pending})`)
|
|
1494
|
+
} else {
|
|
1495
|
+
audit('store', { device: deviceId, ns, keys: result?.keys || [] })
|
|
1496
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} stored ${(result?.keys || []).length} variable(s) (nobody can approve: add only)`)
|
|
1497
|
+
await notifyMembers('vars', { by: deviceId, keys: result?.keys || [], ns })
|
|
1498
|
+
}
|
|
1499
|
+
return await answer({ op: 'store.result', ns, ts: Date.now(), keys: result?.keys || [], pending: result?.pending || null })
|
|
1500
|
+
} catch (e) {
|
|
1501
|
+
return reply(from, { type: MSG.ERROR, ...(e.code ? { code: e.code } : {}), error: `${op}: ${e.message}` })
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1426
1505
|
async function handleSecrets (from, p) {
|
|
1427
1506
|
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
1428
1507
|
// REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
|
|
@@ -1433,6 +1512,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1433
1512
|
// da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
|
|
1434
1513
|
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
1435
1514
|
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
1515
|
+
if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
|
|
1436
1516
|
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1437
1517
|
const ns = p.data?.ns
|
|
1438
1518
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
@@ -2323,8 +2403,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2323
2403
|
// PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
|
|
2324
2404
|
// despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
|
|
2325
2405
|
// marca solo decide si se entrega sin aprobación.
|
|
2406
|
+
// Si esto va a pedir aprobación, al aprobarse puede EDITAR; si no, solo añadir.
|
|
2407
|
+
const ask = needsApproval(caller, await refreshActa())
|
|
2326
2408
|
const apply = async () => {
|
|
2327
|
-
await checkAuthor(owner, key, sealed, caller)
|
|
2409
|
+
await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
|
|
2328
2410
|
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
2329
2411
|
await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
|
|
2330
2412
|
audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
|
|
@@ -2332,9 +2414,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2332
2414
|
}
|
|
2333
2415
|
// Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
|
|
2334
2416
|
// minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
|
|
2335
|
-
await checkAuthor(owner, key, sealed, caller)
|
|
2417
|
+
await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
|
|
2336
2418
|
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
2337
|
-
const pending = await holdWrite({ caller, ns, pub, keys: [key], apply })
|
|
2419
|
+
const pending = await holdWrite({ caller, ns, pub, keys: [key], apply, ask })
|
|
2338
2420
|
if (pending) return { ok: true, key, pending }
|
|
2339
2421
|
await apply()
|
|
2340
2422
|
return { ok: true, key }
|
|
@@ -2362,7 +2444,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2362
2444
|
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
2363
2445
|
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
2364
2446
|
*/
|
|
2365
|
-
async setMany ({ ns, pub, items, caller = null, by: who = null }) {
|
|
2447
|
+
async setMany ({ ns, pub, items, caller = null, by: who = null, fromService = false }) {
|
|
2366
2448
|
// CADA VARIABLE VIENE EN SU PROPIO SOBRE, ya hecho. La bóveda no abre ninguno: solo
|
|
2367
2449
|
// comprueba quién los firma y a quién envuelven, igual que en `set`. Aquí estaba el
|
|
2368
2450
|
// `enc` que las traía todas juntas selladas AL PERFIL, y abrirlo era lo que obligaba
|
|
@@ -2370,16 +2452,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2370
2452
|
if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: no variables came')
|
|
2371
2453
|
const owner = ns ? `ns:${ns}` : `dev:${pub}`
|
|
2372
2454
|
const keys = []
|
|
2455
|
+
// ¿SE PIDE APROBACIÓN? Desde la mesa del admin, lo de siempre (`needsApproval`: quien
|
|
2456
|
+
// tiene `unattended` escribe solo). Desde el propio SERVICIO que guarda en su cajón, se
|
|
2457
|
+
// pide SIEMPRE que haya quien apruebe, tenga o no `unattended` (dueño, 2026-09-29:
|
|
2458
|
+
// «el unattended también pediría aprobación»): `unattended` es para llevarse claves al
|
|
2459
|
+
// arrancar, no para reescribir su propia configuración. Sin nadie que apruebe no se
|
|
2460
|
+
// pide —la regla de siempre— y entonces solo puede añadir.
|
|
2461
|
+
const record = await refreshActa()
|
|
2462
|
+
const ask = fromService ? hasApprover(record) : needsApproval(caller, record)
|
|
2373
2463
|
for (const it of items) {
|
|
2374
2464
|
if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
|
|
2375
|
-
await checkAuthor(owner, it.key, it.sealed, caller)
|
|
2465
|
+
await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
|
|
2376
2466
|
await checkRecipients(owner, it.sealed.wraps, !!it.public)
|
|
2377
2467
|
}
|
|
2378
2468
|
// Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
|
|
2379
2469
|
// configuración que nadie quiso, y el servicio se reinicia con ella.
|
|
2380
2470
|
const apply = async () => {
|
|
2381
2471
|
for (const it of items) {
|
|
2382
|
-
await checkAuthor(owner, it.key, it.sealed, caller)
|
|
2472
|
+
await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
|
|
2383
2473
|
await checkRecipients(owner, it.sealed.wraps, !!it.public)
|
|
2384
2474
|
}
|
|
2385
2475
|
for (const it of items) {
|
|
@@ -2389,7 +2479,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2389
2479
|
audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
|
|
2390
2480
|
await settleDebts(owner)
|
|
2391
2481
|
}
|
|
2392
|
-
const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply })
|
|
2482
|
+
const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply, ask })
|
|
2393
2483
|
if (pending) return { ok: true, keys: [], pending }
|
|
2394
2484
|
await apply()
|
|
2395
2485
|
return { ok: true, keys }
|
|
@@ -2410,8 +2500,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2410
2500
|
*
|
|
2411
2501
|
* Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
|
|
2412
2502
|
*/
|
|
2413
|
-
async function holdWrite ({ caller, ns, pub, keys, apply }) {
|
|
2414
|
-
if (!needsApproval(caller, await refreshActa())) return null
|
|
2503
|
+
async function holdWrite ({ caller, ns, pub, keys, apply, ask }) {
|
|
2504
|
+
if (!(ask ?? needsApproval(caller, await refreshActa()))) return null
|
|
2415
2505
|
// Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
|
|
2416
2506
|
// acta pudo cambiar en medio.
|
|
2417
2507
|
const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
|
|
@@ -3062,7 +3152,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3062
3152
|
* relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
|
|
3063
3153
|
* las dos comprobaciones lo note.
|
|
3064
3154
|
*/
|
|
3065
|
-
async function checkAuthor (owner, key, sealed, caller) {
|
|
3155
|
+
async function checkAuthor (owner, key, sealed, caller, { allowOverwrite = false } = {}) {
|
|
3066
3156
|
const a = sealed?.author
|
|
3067
3157
|
if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
|
|
3068
3158
|
throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
|
|
@@ -3088,12 +3178,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3088
3178
|
// un sobre existente; solamente el admin y el vault editan sobres, los otros pueden
|
|
3089
3179
|
// crear los que faltan nomás»).
|
|
3090
3180
|
//
|
|
3181
|
+
// SALVO QUE ALGUIEN LO APRUEBE (dueño, 2026-09-29: «si requiere aprobación hagamos que
|
|
3182
|
+
// pueda también editar; si no hay approval, solamente agregar — la aprobación nos da el
|
|
3183
|
+
// nivel de seguridad aceptable»). Un sí de quien aprueba es justo la decisión humana que
|
|
3184
|
+
// faltaba para reemplazar; sin él, sigue siendo solo rellenar.
|
|
3185
|
+
//
|
|
3091
3186
|
// Es la misma regla que `putWrap` («solo añade, nunca pisa») y por el mismo motivo: un
|
|
3092
3187
|
// servicio que pudiera reemplazar un sobre podría dejar sin leer a otro miembro con uno
|
|
3093
3188
|
// basura — denegación de servicio disfrazada de escritura. Rellenar lo que falta no
|
|
3094
3189
|
// quita nada a nadie; reemplazar sí, y eso es de quien administra.
|
|
3095
|
-
if (m.cn && secrets.has?.(owner, key)) {
|
|
3096
|
-
throw new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — a service only fills in what is missing; replacing is for who administers`)
|
|
3190
|
+
if (m.cn && !allowOverwrite && secrets.has?.(owner, key)) {
|
|
3191
|
+
throw Object.assign(new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — without an approval a service only fills in what is missing; replacing is for who administers`), { code: 'exists' })
|
|
3097
3192
|
}
|
|
3098
3193
|
const cuerpo = authorBody(owner, key, sealed.e, a.ts)
|
|
3099
3194
|
if (!(await verifyDeviceSig({ publickey: a.pub, data: cuerpo, signature: a.sig }))) {
|