@dotrino/vault 0.72.0 → 0.74.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/package.json +1 -1
- package/src/enroll.js +23 -5
- package/src/service.js +20 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/enroll.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* `docs/pairing-protocol.md`).
|
|
31
31
|
*/
|
|
32
32
|
import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
|
|
33
|
-
import { verifyContinuity, canSeal } from '@dotrino/identity/acta'
|
|
33
|
+
import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
34
34
|
|
|
35
35
|
/** Un token de emparejamiento vale 5 min. */
|
|
36
36
|
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
@@ -159,8 +159,24 @@ export function createEnrollDesk ({
|
|
|
159
159
|
* · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
|
|
160
160
|
* aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
|
|
161
161
|
* identidad de verdad de la cuenta es `iss`.
|
|
162
|
+
* · `caps` → LOS PERMISOS con los que entra al acta, elegidos por quien
|
|
163
|
+
* empareja (dueño, 2026-09-26: «al emparejar debería escoger los
|
|
164
|
+
* permisos, caso contrario estoy obligado a hacer dos actas»). Sin
|
|
165
|
+
* esto el aparato entraba con lo que da el scope y cualquier otro
|
|
166
|
+
* permiso —`admin`, `approve`…— costaba una segunda acta. Se quedan
|
|
167
|
+
* AQUÍ, en la sesión de la bóveda: NO viajan en el QR, así que la
|
|
168
|
+
* invitación que circula no concede nada; lo concede quien teclea
|
|
169
|
+
* el código, igual que hacía `caps` después.
|
|
162
170
|
*/
|
|
163
|
-
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
171
|
+
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
|
|
172
|
+
if (caps != null) {
|
|
173
|
+
if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
|
|
174
|
+
const cn = scopeToCn(scope)
|
|
175
|
+
const bad = caps.filter((c) => !(DEVICE_CAPS.includes(c) || (c === 'secrets' && cn)))
|
|
176
|
+
if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
|
|
177
|
+
if (!caps.length) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
|
|
178
|
+
caps = [...new Set(caps)]
|
|
179
|
+
}
|
|
164
180
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
165
181
|
const acct = String(account || '').slice(0, 40)
|
|
166
182
|
// INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
|
|
@@ -176,12 +192,12 @@ export function createEnrollDesk ({
|
|
|
176
192
|
const conn = typeof connToken === 'function' ? await connToken() : connToken
|
|
177
193
|
if (conn) {
|
|
178
194
|
const sn = randToken(8)
|
|
179
|
-
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
195
|
+
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
180
196
|
return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
|
|
181
197
|
}
|
|
182
198
|
const token = randToken(PAIR_TOKEN_BYTES)
|
|
183
199
|
const sn = randToken(PAIR_TOKEN_BYTES)
|
|
184
|
-
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
200
|
+
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
185
201
|
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
186
202
|
}
|
|
187
203
|
|
|
@@ -364,8 +380,10 @@ export function createEnrollDesk ({
|
|
|
364
380
|
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
365
381
|
// cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
|
|
366
382
|
// con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
|
|
383
|
+
// Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
|
|
384
|
+
// esta misma acta; si no, los que da el scope.
|
|
367
385
|
const cn = scopeToCn(pend.scope)
|
|
368
|
-
const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
|
|
386
|
+
const caps = [...new Set([...(pend.caps || scopeToCaps(pend.scope)), ...(cn ? ['secrets'] : [])])]
|
|
369
387
|
if (!caps.length) {
|
|
370
388
|
throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
|
|
371
389
|
}
|
package/src/service.js
CHANGED
|
@@ -313,7 +313,7 @@ export async function sayHello (client, qr) {
|
|
|
313
313
|
const hello = await new Promise((resolve, reject) => {
|
|
314
314
|
const off = client.on('message', (_f, p) => {
|
|
315
315
|
if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
|
|
316
|
-
else if (p?.type === MSG.ERROR) { finish(); reject(
|
|
316
|
+
else if (p?.type === MSG.ERROR) { finish(); reject(vaultError(p)) }
|
|
317
317
|
})
|
|
318
318
|
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
319
319
|
const finish = () => { off(); clearTimeout(t) }
|
|
@@ -387,7 +387,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
387
387
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
388
388
|
const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
|
|
389
389
|
show({ deviceId, code })
|
|
390
|
-
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(
|
|
390
|
+
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(vaultError(p)) }
|
|
391
391
|
})
|
|
392
392
|
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval on the vault')) }, approveTimeoutMs)
|
|
393
393
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
@@ -507,7 +507,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
507
507
|
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
508
508
|
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
509
509
|
const res = await pending
|
|
510
|
-
if (res.type === MSG.ERROR) throw
|
|
510
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
511
511
|
return { encPub, created, ok: true }
|
|
512
512
|
} finally { client.close() }
|
|
513
513
|
}
|
|
@@ -656,7 +656,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
656
656
|
try { res = await pending; break } catch (e) { ultimo = e }
|
|
657
657
|
}
|
|
658
658
|
if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
|
|
659
|
-
if (res.type === MSG.ERROR) throw
|
|
659
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
660
660
|
|
|
661
661
|
// CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
|
|
662
662
|
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
@@ -668,7 +668,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
668
668
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
669
669
|
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
670
670
|
.catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
|
|
671
|
-
if (res.type === MSG.ERROR) throw
|
|
671
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
672
672
|
}
|
|
673
673
|
|
|
674
674
|
// AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
|
|
@@ -1244,9 +1244,23 @@ function fingerprintOf (secrets) {
|
|
|
1244
1244
|
*/
|
|
1245
1245
|
const FINAL_CODES = new Set([
|
|
1246
1246
|
'no-wrapping', // falta la envoltura, y solo la fabrica el dueño abriendo la bóveda
|
|
1247
|
-
'plaintext-var'
|
|
1247
|
+
'plaintext-var', // el valor está en claro en la bóveda: hay que volver a escribirlo
|
|
1248
|
+
// NADIE EN EL ACTA PUEDE APROBAR: reintentar no lo arregla, y callarlo es lo peor. Pasó el
|
|
1249
|
+
// 2026-09-24: la limpieza de aparatos muertos se llevó al único teléfono con `aprueba`, y
|
|
1250
|
+
// `dotrino-env` se quedó reintentando cada minuto sin decir nada mientras la bóveda
|
|
1251
|
+
// contestaba exactamente qué había que hacer.
|
|
1252
|
+
'no-approver'
|
|
1248
1253
|
])
|
|
1249
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* Un `vault.error` como Error CON SU `code`. Antes se construía con el texto a secas, y el
|
|
1257
|
+
* código que la bóveda manda para distinguir los casos se perdía justo aquí — así que
|
|
1258
|
+
* `isFinal` no podía reconocer ninguno por `code` y todo se reintentaba.
|
|
1259
|
+
*/
|
|
1260
|
+
function vaultError (p) {
|
|
1261
|
+
return Object.assign(new Error(p?.error || 'vault error'), { code: p?.code || null })
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1250
1264
|
export function isFinal (e) {
|
|
1251
1265
|
if (FINAL_CODES.has(e?.code)) return true
|
|
1252
1266
|
return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
|