@dotrino/vault 0.10.0 → 0.11.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 +93 -4
- package/src/protocol.js +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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
|
@@ -42,6 +42,10 @@ export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
|
42
42
|
export const MSG_ENROLL = 'vault.enroll'
|
|
43
43
|
export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
|
|
44
44
|
export const MSG_ENROLLED = 'vault.enrolled'
|
|
45
|
+
// --- camino A: la cuenta del aparato pasa a vivir en la bóveda ---
|
|
46
|
+
export const MSG_ENROLL_ADOPT = 'vault.enroll.adopt'
|
|
47
|
+
export const MSG_ACTA_SEALED = 'vault.acta.sealed'
|
|
48
|
+
export const MSG_ACTA_ADOPTED = 'vault.acta.adopted'
|
|
45
49
|
export const MSG_REVOKED = 'vault.revoked'
|
|
46
50
|
export const MSG_ERROR = 'vault.error'
|
|
47
51
|
|
|
@@ -104,8 +108,11 @@ export async function deviceIdOf (pub) {
|
|
|
104
108
|
export function createEnrollDesk ({
|
|
105
109
|
identity, iss, proxy, send, sendByPubkey,
|
|
106
110
|
audit = () => {}, log = () => {},
|
|
107
|
-
onChallenge = () => {}, onPendingChange = () => {},
|
|
108
|
-
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
|
|
111
|
+
onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
|
|
112
|
+
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
|
|
113
|
+
// Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
|
|
114
|
+
// es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
|
|
115
|
+
encPub = null, vaultLabel = ''
|
|
109
116
|
} = {}) {
|
|
110
117
|
if (!identity) throw new Error('createEnrollDesk: falta identity')
|
|
111
118
|
if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
|
|
@@ -172,6 +179,17 @@ export function createEnrollDesk ({
|
|
|
172
179
|
return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
|
|
173
180
|
}
|
|
174
181
|
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
|
|
182
|
+
// V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
|
|
183
|
+
// bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
|
|
184
|
+
// humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
|
|
185
|
+
const intent = d.intent || 'join'
|
|
186
|
+
if (intent !== 'join' && intent !== 'adopt') {
|
|
187
|
+
return reply(from, { type: MSG_ERROR, error: 'intención desconocida: ' + intent })
|
|
188
|
+
}
|
|
189
|
+
if (intent !== (pend.mode || 'join')) {
|
|
190
|
+
audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
|
|
191
|
+
return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
|
|
192
|
+
}
|
|
175
193
|
if (!isFresh(d)) {
|
|
176
194
|
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
177
195
|
return reply(from, { type: MSG_ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
|
|
@@ -208,9 +226,12 @@ export function createEnrollDesk ({
|
|
|
208
226
|
}
|
|
209
227
|
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
210
228
|
if (d.label) pend.label = String(d.label).slice(0, 60)
|
|
229
|
+
// Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
|
|
230
|
+
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
231
|
+
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
211
232
|
|
|
212
233
|
reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
|
|
213
|
-
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
|
|
234
|
+
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
|
|
214
235
|
fire(onPendingChange)
|
|
215
236
|
return { deviceId }
|
|
216
237
|
}
|
|
@@ -246,6 +267,20 @@ export function createEnrollDesk ({
|
|
|
246
267
|
throw new Error('el código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Vuelve a mirarlo y prueba otra vez.')
|
|
247
268
|
}
|
|
248
269
|
|
|
270
|
+
// CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
|
|
271
|
+
// aparato la meta en el acta de la cuenta que le está pasando. El código de vuelta es
|
|
272
|
+
// la misma defensa de siempre, en el otro sentido: el aparato solo hace caso a una
|
|
273
|
+
// bóveda que demuestre que un humano la aprobó.
|
|
274
|
+
if ((pend.mode || 'join') === 'adopt') {
|
|
275
|
+
audit('adopt-approve', { device: pend.deviceId, profile: pend.profileId || null })
|
|
276
|
+
pend.state = 'AWAITING_ACTA'
|
|
277
|
+
pend.approvedAt = Date.now()
|
|
278
|
+
reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
|
|
279
|
+
log('[vault] adopción aprobada para %s: esperando el acta sellada', pend.deviceId)
|
|
280
|
+
fire(onPendingChange)
|
|
281
|
+
return { ok: true, deviceId: pend.deviceId, adopting: true }
|
|
282
|
+
}
|
|
283
|
+
|
|
249
284
|
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
250
285
|
|
|
251
286
|
// Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
|
|
@@ -273,6 +308,60 @@ export function createEnrollDesk ({
|
|
|
273
308
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
274
309
|
}
|
|
275
310
|
|
|
311
|
+
/**
|
|
312
|
+
* CAMINO A · paso 6: llega el acta que el aparato acaba de sellar, con la bóveda dentro
|
|
313
|
+
* como miembro, la clave de contenido envuelta para ella y el mando ya traspasado.
|
|
314
|
+
*
|
|
315
|
+
* Lo que se comprueba antes de guardar nada (y por qué):
|
|
316
|
+
* · que el sellador sea ESTA bóveda — si no, no es un traspaso, es un acta ajena;
|
|
317
|
+
* · que la selle el aparato que estaba en este emparejamiento — cierra que un tercero
|
|
318
|
+
* que vea pasar el mensaje cuele la suya;
|
|
319
|
+
* · que sea la cuenta que ese aparato declaró al enrolarse (`profileId`) — cierra el
|
|
320
|
+
* cambiazo de cuenta entre el anuncio que leyó el humano y lo que llega después.
|
|
321
|
+
*
|
|
322
|
+
* Adoptar la cuenta de otro solo procede sobre un perfil que **nació para eso** (la marca
|
|
323
|
+
* de `prepareForAdoption`). Es la misma regla del navegador: sin la marca, adoptar sería
|
|
324
|
+
* pisar una cuenta con datos, y eso no puede pasar por accidente.
|
|
325
|
+
*/
|
|
326
|
+
async function handleActaSealed (from, p) {
|
|
327
|
+
const acta = p?.acta
|
|
328
|
+
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
329
|
+
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no hay ninguna adopción esperando un acta' })
|
|
330
|
+
if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'acta ausente o ilegible' })
|
|
331
|
+
if (acta.sealer !== iss) {
|
|
332
|
+
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
333
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta no nombra a esta bóveda como quien manda' })
|
|
334
|
+
}
|
|
335
|
+
if (acta.sealedBy !== pend.dpub) {
|
|
336
|
+
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
337
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta no la selló el dispositivo de este emparejamiento' })
|
|
338
|
+
}
|
|
339
|
+
if (pend.profileId && acta.profileId !== pend.profileId) {
|
|
340
|
+
audit('rejected', { what: 'adopt', reason: 'other-profile' })
|
|
341
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta es de otra cuenta distinta a la que anunció el dispositivo' })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const r = await identity.joinProfile(acta)
|
|
346
|
+
if (!r?.joined) throw new Error(r?.reason || 'no se pudo adoptar')
|
|
347
|
+
audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
|
|
348
|
+
// El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
|
|
349
|
+
// dos quedan en la misma versión.
|
|
350
|
+
const mia = (await identity.profileActa?.())?.acta || acta
|
|
351
|
+
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
|
|
352
|
+
pend.state = 'DONE'
|
|
353
|
+
pending.delete(pend.token)
|
|
354
|
+
fire(onPendingChange)
|
|
355
|
+
fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
|
|
356
|
+
log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
|
|
357
|
+
return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
|
|
358
|
+
} catch (e) {
|
|
359
|
+
log('[vault] no se pudo adoptar la cuenta: %s', e.message)
|
|
360
|
+
reply(pend.from, { type: MSG_ERROR, error: 'la bóveda no pudo adoptar la cuenta: ' + e.message })
|
|
361
|
+
return { ok: false, error: e.message }
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
276
365
|
/** Rechaza un enrolamiento pendiente. */
|
|
277
366
|
function reject (deviceId) {
|
|
278
367
|
const pend = deviceId
|
|
@@ -310,7 +399,7 @@ export function createEnrollDesk ({
|
|
|
310
399
|
}
|
|
311
400
|
|
|
312
401
|
return {
|
|
313
|
-
startPairing, stopPairing, handleEnroll, approve, reject,
|
|
402
|
+
startPairing, stopPairing, handleEnroll, handleActaSealed, approve, reject,
|
|
314
403
|
listPending, findPending, emitRevoke, revoke,
|
|
315
404
|
get pendingCount () { return pending.size }
|
|
316
405
|
}
|
package/src/protocol.js
CHANGED
|
@@ -20,6 +20,13 @@ export const MSG = Object.freeze({
|
|
|
20
20
|
ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
|
|
21
21
|
ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
|
|
22
22
|
ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
|
|
23
|
+
// Camino A (la cuenta del aparato pasa a vivir en la bóveda): en vez de un cert, la
|
|
24
|
+
// bóveda manda QUIÉN es para que el aparato la admita, le envuelva la clave de
|
|
25
|
+
// contenido y le traspase el mando; el aparato devuelve el acta sellada y la bóveda
|
|
26
|
+
// responde con la definitiva. Ver docs/vinculacion-de-cuentas.md §2.
|
|
27
|
+
ENROLL_ADOPT: 'vault.enroll.adopt', // vault → dispositivo: { code, pub, encPub, label }
|
|
28
|
+
ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
|
|
29
|
+
ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
|
|
23
30
|
REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
|
|
24
31
|
SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
|
|
25
32
|
SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
|