@dotrino/vaultd 0.9.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/lib/src/enroll.js +131 -5
- package/lib/src/invite.js +70 -1
- package/lib/src/protocol.js +12 -0
- package/lib/src/service.js +33 -3
- package/package.json +2 -2
- package/src/ctl.js +16 -1
- package/src/daemon.js +9 -2
- package/src/manager.js +12 -3
- package/src/profiles.js +20 -3
- package/src/qr.js +7 -2
- package/src/tui/app.js +2 -3
- package/src/tui/i18n.js +6 -4
- package/src/vault.js +18 -1
package/lib/src/enroll.js
CHANGED
|
@@ -39,9 +39,15 @@ export const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
|
39
39
|
/** Vida por defecto del cert de un dispositivo (tope duro de `MAX_DELEGATION_MS`). */
|
|
40
40
|
export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
41
41
|
|
|
42
|
+
export const MSG_HELLO = 'vault.hello'
|
|
43
|
+
export const MSG_HELLO_OK = 'vault.hello.ok'
|
|
42
44
|
export const MSG_ENROLL = 'vault.enroll'
|
|
43
45
|
export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
|
|
44
46
|
export const MSG_ENROLLED = 'vault.enrolled'
|
|
47
|
+
// --- camino A: la cuenta del aparato pasa a vivir en la bóveda ---
|
|
48
|
+
export const MSG_ENROLL_ADOPT = 'vault.enroll.adopt'
|
|
49
|
+
export const MSG_ACTA_SEALED = 'vault.acta.sealed'
|
|
50
|
+
export const MSG_ACTA_ADOPTED = 'vault.acta.adopted'
|
|
45
51
|
export const MSG_REVOKED = 'vault.revoked'
|
|
46
52
|
export const MSG_ERROR = 'vault.error'
|
|
47
53
|
|
|
@@ -104,8 +110,14 @@ export async function deviceIdOf (pub) {
|
|
|
104
110
|
export function createEnrollDesk ({
|
|
105
111
|
identity, iss, proxy, send, sendByPubkey,
|
|
106
112
|
audit = () => {}, log = () => {},
|
|
107
|
-
onChallenge = () => {}, onPendingChange = () => {},
|
|
108
|
-
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
|
|
113
|
+
onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
|
|
114
|
+
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
|
|
115
|
+
// Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
|
|
116
|
+
// es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
|
|
117
|
+
encPub = null, vaultLabel = '',
|
|
118
|
+
// Token de CONEXIÓN de esta bóveda en el proxy (4 chars): su dirección. Es lo
|
|
119
|
+
// único que necesita el QR corto para que el aparato le hable punto a punto.
|
|
120
|
+
connToken = null
|
|
109
121
|
} = {}) {
|
|
110
122
|
if (!identity) throw new Error('createEnrollDesk: falta identity')
|
|
111
123
|
if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
|
|
@@ -136,9 +148,19 @@ export function createEnrollDesk ({
|
|
|
136
148
|
*/
|
|
137
149
|
function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
138
150
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
151
|
+
const acct = String(account || '').slice(0, 40)
|
|
152
|
+
// INVITACIÓN CORTA: si sabemos nuestra dirección en el proxy, el QR lleva solo
|
|
153
|
+
// eso y el nonce de la sesión (13 bytes). La llave, el proxy y el nombre de la
|
|
154
|
+
// cuenta los pide el aparato por la red presentando el `sn`. El nonce hace de
|
|
155
|
+
// identificador de sesión: no hace falta un token de emparejamiento aparte.
|
|
156
|
+
const conn = typeof connToken === 'function' ? connToken() : connToken
|
|
157
|
+
if (conn) {
|
|
158
|
+
const sn = randToken(8)
|
|
159
|
+
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
160
|
+
return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
|
|
161
|
+
}
|
|
139
162
|
const token = randToken(PAIR_TOKEN_BYTES)
|
|
140
163
|
const sn = randToken(PAIR_TOKEN_BYTES)
|
|
141
|
-
const acct = String(account || '').slice(0, 40)
|
|
142
164
|
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
143
165
|
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
144
166
|
}
|
|
@@ -158,6 +180,28 @@ export function createEnrollDesk ({
|
|
|
158
180
|
return null
|
|
159
181
|
}
|
|
160
182
|
|
|
183
|
+
/**
|
|
184
|
+
* «¿Quién eres?» — la respuesta al QR corto. Solo se contesta a quien presente el
|
|
185
|
+
* `sn` de una sesión VIVA: el token de conexión son 4 caracteres y se puede acertar
|
|
186
|
+
* a ciegas, el `sn` no. Fuera de un emparejamiento no hay ninguna sesión y por lo
|
|
187
|
+
* tanto no hay respuesta: la puerta solo está abierta mientras dura el `pair`.
|
|
188
|
+
*/
|
|
189
|
+
async function handleHello (from, p) {
|
|
190
|
+
const pend = pending.get(String(p?.sn || ''))
|
|
191
|
+
if (!pend || Date.now() > pend.exp) {
|
|
192
|
+
audit('rejected', { what: 'hello', reason: 'sin-sesion' })
|
|
193
|
+
return reply(from, { type: MSG_ERROR, error: 'no hay ningún emparejamiento abierto con ese código' })
|
|
194
|
+
}
|
|
195
|
+
// La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
|
|
196
|
+
// la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
|
|
197
|
+
// de otra bóveda. Lo que NO hace es demostrar que sea TU bóveda —cualquiera puede
|
|
198
|
+
// firmar con una llave suya—; eso solo lo demuestra el código de 6 dígitos.
|
|
199
|
+
const body = { op: 'hello', sn: pend.sn, iss, proxy, acct: pend.account || '', m: pend.mode || 'join', ts: Date.now() }
|
|
200
|
+
const { signature } = await identity.signData(body)
|
|
201
|
+
reply(from, { type: MSG_HELLO_OK, body, signature })
|
|
202
|
+
return { ok: true }
|
|
203
|
+
}
|
|
204
|
+
|
|
161
205
|
/**
|
|
162
206
|
* ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
|
|
163
207
|
* COMPROMISO de su código. Todavía NO se firma ningún cert.
|
|
@@ -172,6 +216,17 @@ export function createEnrollDesk ({
|
|
|
172
216
|
return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
|
|
173
217
|
}
|
|
174
218
|
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
|
|
219
|
+
// V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
|
|
220
|
+
// bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
|
|
221
|
+
// humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
|
|
222
|
+
const intent = d.intent || 'join'
|
|
223
|
+
if (intent !== 'join' && intent !== 'adopt') {
|
|
224
|
+
return reply(from, { type: MSG_ERROR, error: 'intención desconocida: ' + intent })
|
|
225
|
+
}
|
|
226
|
+
if (intent !== (pend.mode || 'join')) {
|
|
227
|
+
audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
|
|
228
|
+
return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
|
|
229
|
+
}
|
|
175
230
|
if (!isFresh(d)) {
|
|
176
231
|
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
177
232
|
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 +263,12 @@ export function createEnrollDesk ({
|
|
|
208
263
|
}
|
|
209
264
|
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
210
265
|
if (d.label) pend.label = String(d.label).slice(0, 60)
|
|
266
|
+
// Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
|
|
267
|
+
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
268
|
+
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
211
269
|
|
|
212
270
|
reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
|
|
213
|
-
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
|
|
271
|
+
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
|
|
214
272
|
fire(onPendingChange)
|
|
215
273
|
return { deviceId }
|
|
216
274
|
}
|
|
@@ -246,6 +304,20 @@ export function createEnrollDesk ({
|
|
|
246
304
|
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
305
|
}
|
|
248
306
|
|
|
307
|
+
// CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
|
|
308
|
+
// aparato la meta en el acta de la cuenta que le está pasando. El código de vuelta es
|
|
309
|
+
// la misma defensa de siempre, en el otro sentido: el aparato solo hace caso a una
|
|
310
|
+
// bóveda que demuestre que un humano la aprobó.
|
|
311
|
+
if ((pend.mode || 'join') === 'adopt') {
|
|
312
|
+
audit('adopt-approve', { device: pend.deviceId, profile: pend.profileId || null })
|
|
313
|
+
pend.state = 'AWAITING_ACTA'
|
|
314
|
+
pend.approvedAt = Date.now()
|
|
315
|
+
reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
|
|
316
|
+
log('[vault] adopción aprobada para %s: esperando el acta sellada', pend.deviceId)
|
|
317
|
+
fire(onPendingChange)
|
|
318
|
+
return { ok: true, deviceId: pend.deviceId, adopting: true }
|
|
319
|
+
}
|
|
320
|
+
|
|
249
321
|
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
250
322
|
|
|
251
323
|
// Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
|
|
@@ -273,6 +345,60 @@ export function createEnrollDesk ({
|
|
|
273
345
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
274
346
|
}
|
|
275
347
|
|
|
348
|
+
/**
|
|
349
|
+
* CAMINO A · paso 6: llega el acta que el aparato acaba de sellar, con la bóveda dentro
|
|
350
|
+
* como miembro, la clave de contenido envuelta para ella y el mando ya traspasado.
|
|
351
|
+
*
|
|
352
|
+
* Lo que se comprueba antes de guardar nada (y por qué):
|
|
353
|
+
* · que el sellador sea ESTA bóveda — si no, no es un traspaso, es un acta ajena;
|
|
354
|
+
* · que la selle el aparato que estaba en este emparejamiento — cierra que un tercero
|
|
355
|
+
* que vea pasar el mensaje cuele la suya;
|
|
356
|
+
* · que sea la cuenta que ese aparato declaró al enrolarse (`profileId`) — cierra el
|
|
357
|
+
* cambiazo de cuenta entre el anuncio que leyó el humano y lo que llega después.
|
|
358
|
+
*
|
|
359
|
+
* Adoptar la cuenta de otro solo procede sobre un perfil que **nació para eso** (la marca
|
|
360
|
+
* de `prepareForAdoption`). Es la misma regla del navegador: sin la marca, adoptar sería
|
|
361
|
+
* pisar una cuenta con datos, y eso no puede pasar por accidente.
|
|
362
|
+
*/
|
|
363
|
+
async function handleActaSealed (from, p) {
|
|
364
|
+
const acta = p?.acta
|
|
365
|
+
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
366
|
+
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no hay ninguna adopción esperando un acta' })
|
|
367
|
+
if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'acta ausente o ilegible' })
|
|
368
|
+
if (acta.sealer !== iss) {
|
|
369
|
+
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
370
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta no nombra a esta bóveda como quien manda' })
|
|
371
|
+
}
|
|
372
|
+
if (acta.sealedBy !== pend.dpub) {
|
|
373
|
+
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
374
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta no la selló el dispositivo de este emparejamiento' })
|
|
375
|
+
}
|
|
376
|
+
if (pend.profileId && acta.profileId !== pend.profileId) {
|
|
377
|
+
audit('rejected', { what: 'adopt', reason: 'other-profile' })
|
|
378
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta es de otra cuenta distinta a la que anunció el dispositivo' })
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const r = await identity.joinProfile(acta)
|
|
383
|
+
if (!r?.joined) throw new Error(r?.reason || 'no se pudo adoptar')
|
|
384
|
+
audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
|
|
385
|
+
// El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
|
|
386
|
+
// dos quedan en la misma versión.
|
|
387
|
+
const mia = (await identity.profileActa?.())?.acta || acta
|
|
388
|
+
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
|
|
389
|
+
pend.state = 'DONE'
|
|
390
|
+
pending.delete(pend.token)
|
|
391
|
+
fire(onPendingChange)
|
|
392
|
+
fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
|
|
393
|
+
log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
|
|
394
|
+
return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
|
|
395
|
+
} catch (e) {
|
|
396
|
+
log('[vault] no se pudo adoptar la cuenta: %s', e.message)
|
|
397
|
+
reply(pend.from, { type: MSG_ERROR, error: 'la bóveda no pudo adoptar la cuenta: ' + e.message })
|
|
398
|
+
return { ok: false, error: e.message }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
276
402
|
/** Rechaza un enrolamiento pendiente. */
|
|
277
403
|
function reject (deviceId) {
|
|
278
404
|
const pend = deviceId
|
|
@@ -310,7 +436,7 @@ export function createEnrollDesk ({
|
|
|
310
436
|
}
|
|
311
437
|
|
|
312
438
|
return {
|
|
313
|
-
startPairing, stopPairing, handleEnroll, approve, reject,
|
|
439
|
+
startPairing, stopPairing, handleEnroll, handleActaSealed, handleHello, approve, reject,
|
|
314
440
|
listPending, findPending, emitRevoke, revoke,
|
|
315
441
|
get pendingCount () { return pending.size }
|
|
316
442
|
}
|
package/lib/src/invite.js
CHANGED
|
@@ -49,6 +49,18 @@
|
|
|
49
49
|
export const FMT_JSON = 'j'
|
|
50
50
|
export const FMT_B64 = 'b'
|
|
51
51
|
export const FMT_COMPACT = 'c'
|
|
52
|
+
/**
|
|
53
|
+
* `t` — la invitación CORTA: solo la dirección y el nonce de la sesión. La llave
|
|
54
|
+
* maestra ya no viaja; el aparato se la pide a la bóveda por la red presentando el
|
|
55
|
+
* `sn`, y la comprueba con la firma del certificado y con el código de 6 dígitos,
|
|
56
|
+
* que es lo único que de verdad decide. Esconderla no aportaba nada —una pública es
|
|
57
|
+
* pública— y ocupaba 44 de los ~100 caracteres.
|
|
58
|
+
*
|
|
59
|
+
* `token` aquí NO es el de la sesión de emparejamiento: es el **token de conexión**
|
|
60
|
+
* que el proxy le da a la bóveda (4 caracteres), o sea su dirección. Con eso el
|
|
61
|
+
* aparato le habla directo, punto a punto, sin resolver nada.
|
|
62
|
+
*/
|
|
63
|
+
export const FMT_SHORT = 't'
|
|
52
64
|
|
|
53
65
|
/** El proxy del ecosistema: si es este, no viaja en la invitación compacta. */
|
|
54
66
|
export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
|
|
@@ -266,6 +278,57 @@ function compactDecode (text) {
|
|
|
266
278
|
// API
|
|
267
279
|
// ---------------------------------------------------------------------------
|
|
268
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Blob de la forma CORTA (`t`): cabecera(1) ‖ token de conexión(4 ASCII) ‖ sn(8)
|
|
283
|
+
* ‖ [len+proxy].
|
|
284
|
+
* bits 0-2 versión · bit 3 modo (join/adopt) · bit 4 lleva proxy propio
|
|
285
|
+
*
|
|
286
|
+
* Sin llave y sin nombre de cuenta: eso llega en la respuesta de la bóveda, así que
|
|
287
|
+
* un nombre largo ya no agranda el QR. 13 bytes → 18 caracteres.
|
|
288
|
+
*
|
|
289
|
+
* El PROXY sí viaja cuando no es el del ecosistema, y no es opcional: el token de
|
|
290
|
+
* conexión solo tiene sentido **en el proxy donde se emitió**. Quitarlo hacía que un
|
|
291
|
+
* aparato se conectara al proxy público y le hablara a un token de otro servidor —
|
|
292
|
+
* el mensaje no llegaba a ninguna parte y el emparejamiento se quedaba esperando.
|
|
293
|
+
* Le pasa a cualquiera con proxy propio (y lo cazó el E2E de secretos, que levanta
|
|
294
|
+
* uno local).
|
|
295
|
+
*/
|
|
296
|
+
const CONN_TOKEN_LEN = 4
|
|
297
|
+
|
|
298
|
+
function shortEncode (qr) {
|
|
299
|
+
if (!qr || qr.v !== 2) return null
|
|
300
|
+
const mode = MODES.indexOf(qr.m)
|
|
301
|
+
if (mode < 0) return null
|
|
302
|
+
const conn = String(qr.conn || '')
|
|
303
|
+
if (conn.length !== CONN_TOKEN_LEN || !/^[\x21-\x7e]+$/.test(conn)) return null
|
|
304
|
+
if (!/^[0-9a-f]{16}$/.test(qr.sn || '')) return null // 8 bytes
|
|
305
|
+
const known = new Set(['v', 'conn', 'sn', 'm', 'proxy'])
|
|
306
|
+
if (Object.keys(qr).some((k) => !known.has(k))) return null
|
|
307
|
+
const ownProxy = qr.proxy && qr.proxy !== DEFAULT_PROXY
|
|
308
|
+
const proxy = ownProxy ? utf8(String(qr.proxy)) : null
|
|
309
|
+
if (proxy && proxy.length > 255) return null
|
|
310
|
+
const out = [qr.v | (mode << 3) | (ownProxy ? 0x10 : 0), ...[...conn].map((c) => c.charCodeAt(0)), ...hexToBytes(qr.sn)]
|
|
311
|
+
if (proxy) out.push(proxy.length, ...proxy)
|
|
312
|
+
return bytesToB64url(Uint8Array.from(out))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function shortDecode (text) {
|
|
316
|
+
const b = b64urlToBytes(text)
|
|
317
|
+
if (!b || b.length < 1 + CONN_TOKEN_LEN + 8) return null
|
|
318
|
+
const head = b[0]
|
|
319
|
+
if (head & 0xe0) return null
|
|
320
|
+
let i = 1 + CONN_TOKEN_LEN + 8
|
|
321
|
+
let proxy = DEFAULT_PROXY
|
|
322
|
+
if (head & 0x10) {
|
|
323
|
+
if (i >= b.length) return null
|
|
324
|
+
const n = b[i]; i += 1
|
|
325
|
+
if (i + n !== b.length) return null
|
|
326
|
+
proxy = fromUtf8(b.subarray(i, i + n)); i += n
|
|
327
|
+
} else if (i !== b.length) return null
|
|
328
|
+
const conn = String.fromCharCode(...b.subarray(1, 1 + CONN_TOKEN_LEN))
|
|
329
|
+
return { v: head & 7, conn, sn: bytesToHex(b.subarray(1 + CONN_TOKEN_LEN, 1 + CONN_TOKEN_LEN + 8)), m: MODES[(head >> 3) & 1], proxy }
|
|
330
|
+
}
|
|
331
|
+
|
|
269
332
|
/** Comparación por contenido, sin depender del orden de las claves. */
|
|
270
333
|
const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))
|
|
271
334
|
|
|
@@ -278,6 +341,11 @@ const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))
|
|
|
278
341
|
*/
|
|
279
342
|
export function encodeInvite (qr, fmt = FMT_COMPACT) {
|
|
280
343
|
const json = JSON.stringify(qr)
|
|
344
|
+
// La forma corta se emite cuando el QR trae dirección en vez de llave.
|
|
345
|
+
if (qr && qr.conn) {
|
|
346
|
+
const t = shortEncode(qr)
|
|
347
|
+
if (t) { const back = shortDecode(t); if (back && canon(back) === canon(qr)) return FMT_SHORT + t }
|
|
348
|
+
}
|
|
281
349
|
if (fmt === FMT_JSON) return FMT_JSON + json
|
|
282
350
|
if (fmt === FMT_COMPACT) {
|
|
283
351
|
const c = compactEncode(qr)
|
|
@@ -320,6 +388,7 @@ export function parseInvite (text) {
|
|
|
320
388
|
|
|
321
389
|
const marca = payload[0]
|
|
322
390
|
const resto = payload.slice(1)
|
|
391
|
+
if (marca === FMT_SHORT) { const o = shortDecode(resto); if (o) return o }
|
|
323
392
|
if (marca === FMT_COMPACT) { const o = compactDecode(resto); if (o) return o }
|
|
324
393
|
if (marca === FMT_JSON) { const o = parse(undoUrl(resto)) || parse(resto); if (o) return o }
|
|
325
394
|
if (marca === FMT_B64) { const s = b64urlDecodeStr(resto); const o = s && parse(s); if (o) return o }
|
|
@@ -331,4 +400,4 @@ export function parseInvite (text) {
|
|
|
331
400
|
return s ? parse(s) : null
|
|
332
401
|
}
|
|
333
402
|
|
|
334
|
-
export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, PAIR_URL, DEFAULT_PROXY }
|
|
403
|
+
export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, FMT_SHORT, PAIR_URL, DEFAULT_PROXY }
|
package/lib/src/protocol.js
CHANGED
|
@@ -17,9 +17,21 @@
|
|
|
17
17
|
* pineada (cierra el wipe-DoS; un ERROR plano jamas borra).
|
|
18
18
|
*/
|
|
19
19
|
export const MSG = Object.freeze({
|
|
20
|
+
// La invitación corta no lleva la llave: el aparato la pide presentando el `sn` de
|
|
21
|
+
// la sesión. Una pública es pública — esto no la esconde, solo evita abrirle la
|
|
22
|
+
// puerta a quien acertó el token de conexión a ciegas.
|
|
23
|
+
HELLO: 'vault.hello', // dispositivo → vault: { sn }
|
|
24
|
+
HELLO_OK: 'vault.hello.ok', // vault → dispositivo: { iss, acct }
|
|
20
25
|
ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
|
|
21
26
|
ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
|
|
22
27
|
ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
|
|
28
|
+
// Camino A (la cuenta del aparato pasa a vivir en la bóveda): en vez de un cert, la
|
|
29
|
+
// bóveda manda QUIÉN es para que el aparato la admita, le envuelva la clave de
|
|
30
|
+
// contenido y le traspase el mando; el aparato devuelve el acta sellada y la bóveda
|
|
31
|
+
// responde con la definitiva. Ver docs/vinculacion-de-cuentas.md §2.
|
|
32
|
+
ENROLL_ADOPT: 'vault.enroll.adopt', // vault → dispositivo: { code, pub, encPub, label }
|
|
33
|
+
ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
|
|
34
|
+
ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
|
|
23
35
|
REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
|
|
24
36
|
SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
|
|
25
37
|
SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
|
package/lib/src/service.js
CHANGED
|
@@ -60,6 +60,23 @@ function installNodeGlobals () {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* La respuesta al `hello` va firmada y con el `sn` DENTRO de lo firmado. Comprobarlo
|
|
66
|
+
* ata la respuesta a ESTA sesión: no vale la de otro emparejamiento ni la de otra
|
|
67
|
+
* bóveda. Ojo con lo que NO prueba: cualquiera puede firmar con una llave suya, así
|
|
68
|
+
* que esto no dice que sea TU bóveda — eso lo dice el código de 6 dígitos, que solo
|
|
69
|
+
* aprende la bóveda donde tú lo tecleas.
|
|
70
|
+
*/
|
|
71
|
+
async function verificarHola (p, sn) {
|
|
72
|
+
const b = p?.body
|
|
73
|
+
if (!b?.iss || b.sn !== sn) throw new Error('la bóveda contestó a otro emparejamiento')
|
|
74
|
+
if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
|
|
75
|
+
throw new Error('la respuesta de la bóveda no está bien firmada')
|
|
76
|
+
}
|
|
77
|
+
return b
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
|
|
64
81
|
installNodeGlobals()
|
|
65
82
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
@@ -132,12 +149,25 @@ function writeServiceIdentity (dir, obj) {
|
|
|
132
149
|
*/
|
|
133
150
|
export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
|
|
134
151
|
if (typeof qr === 'string') { try { qr = JSON.parse(qr) } catch (_) { throw new Error('qr inválido: no es JSON') } }
|
|
135
|
-
if (!qr?.
|
|
152
|
+
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
|
|
136
153
|
if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
|
|
137
154
|
if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
|
|
138
155
|
label = label || 'servicio:' + ns
|
|
139
156
|
|
|
140
|
-
const client = await freshClient(qr.proxy)
|
|
157
|
+
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
158
|
+
// QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
|
|
159
|
+
if (!qr.iss) {
|
|
160
|
+
const hola = await new Promise((resolve, reject) => {
|
|
161
|
+
const off = client.on('message', (_f, p) => {
|
|
162
|
+
if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then(resolve, reject) }
|
|
163
|
+
else if (p?.type === MSG.ERROR) { fin(); reject(new Error(p.error)) }
|
|
164
|
+
})
|
|
165
|
+
const t = setTimeout(() => { fin(); reject(new Error('la bóveda no contestó: ese código pudo caducar')) }, 15000)
|
|
166
|
+
const fin = () => { off(); clearTimeout(t) }
|
|
167
|
+
try { client.send(qr.conn, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
|
|
168
|
+
})
|
|
169
|
+
qr = { ...qr, iss: hola.iss, proxy: hola.proxy || qr.proxy }
|
|
170
|
+
}
|
|
141
171
|
try {
|
|
142
172
|
const device = await makeDeviceKey({ label })
|
|
143
173
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
@@ -147,7 +177,7 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
|
|
|
147
177
|
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
148
178
|
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
149
179
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
150
|
-
const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
|
|
180
|
+
const data = { op: 'enroll', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
151
181
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
152
182
|
|
|
153
183
|
const enrolled = new Promise((resolve, reject) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"node": ">=20"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@dotrino/identity": "^0.
|
|
22
|
+
"@dotrino/identity": "^0.36.0",
|
|
23
23
|
"@dotrino/proxy-client": "0.8.0",
|
|
24
24
|
"ws": "^8.18.0"
|
|
25
25
|
},
|
package/src/ctl.js
CHANGED
|
@@ -166,9 +166,24 @@ async function cmdPair (args = []) {
|
|
|
166
166
|
PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
|
|
167
167
|
console.log('Cuenta nueva: %s (%s)', name, d.id)
|
|
168
168
|
}
|
|
169
|
+
// `--adopt [nombre]`: la TERCERA respuesta a «¿de qué cuenta hablamos?» — el camino A.
|
|
170
|
+
// Aquí la cuenta NO sale de esta bóveda: la trae el aparato y esta bóveda pasa a
|
|
171
|
+
// guardarla y a mandarla. Por eso se crea un perfil VACÍO, que nace a la espera de
|
|
172
|
+
// adoptarla; sin ese sitio no habría dónde meterla.
|
|
173
|
+
const adIdx = args.findIndex((a) => a === '--adopt')
|
|
174
|
+
const adopt = adIdx >= 0
|
|
175
|
+
if (adopt) {
|
|
176
|
+
const next = args[adIdx + 1]
|
|
177
|
+
const name = (next && !next.startsWith('-')) ? next : `cuenta del dispositivo`
|
|
178
|
+
const d = await profileRequest('add', { name, adopt: true })
|
|
179
|
+
if (d.error) { console.error('%s', d.error); process.exit(1) }
|
|
180
|
+
if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
|
|
181
|
+
PROFILE = d.id
|
|
182
|
+
console.log('Cuenta a la espera de adoptar la del dispositivo: %s (%s)', name, d.id)
|
|
183
|
+
}
|
|
169
184
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
170
185
|
// se empareja el dispositivo.
|
|
171
|
-
writeReq('pair-request.json', service ? { service } : {})
|
|
186
|
+
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
|
|
172
187
|
avisar(s.pid, 'SIGUSR1')
|
|
173
188
|
|
|
174
189
|
let pair = null
|
package/src/daemon.js
CHANGED
|
@@ -123,7 +123,14 @@ export async function runDaemon () {
|
|
|
123
123
|
// TAMBIÉN dentro del QR (`acct`) para que el dispositivo pueda anunciar qué va
|
|
124
124
|
// a pasar antes de hacerlo (V9 de docs/vinculacion-de-cuentas.md).
|
|
125
125
|
const profileName = mgr.profiles.get(profileId)?.name || ''
|
|
126
|
-
|
|
126
|
+
// MODO (V9): lo decide la bóveda y viaja en el QR para que el aparato pueda decir
|
|
127
|
+
// qué va a pasar antes de hacerlo.
|
|
128
|
+
// · `join` → el dispositivo entra a esta cuenta de la bóveda.
|
|
129
|
+
// · `adopt` → la bóveda se queda con la cuenta que trae el aparato (camino A). El
|
|
130
|
+
// perfil de esta bóveda tiene que haber nacido para eso (`--adopt`
|
|
131
|
+
// crea uno vacío), o no habría dónde meterla.
|
|
132
|
+
const mode = pairReq?.mode === 'adopt' ? 'adopt' : 'join'
|
|
133
|
+
const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName })
|
|
127
134
|
writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
|
|
128
135
|
// El token es un secreto efímero: no debe quedar en disco más allá de su
|
|
129
136
|
// vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
|
|
@@ -160,7 +167,7 @@ export async function runDaemon () {
|
|
|
160
167
|
case 'list': return {} // el volcado de perfiles ya se hace abajo
|
|
161
168
|
// `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
|
|
162
169
|
// cuentas pueden llamarse igual). Lo usa «emparejar en una cuenta nueva».
|
|
163
|
-
case 'add': { const p = await mgr.add(req.name); return { done: `perfil creado: ${p.name || p.id}`, id: p.id } }
|
|
170
|
+
case 'add': { const p = await mgr.add(req.name, { adopt: !!req.adopt }); return { done: `perfil creado: ${p.name || p.id}`, id: p.id, adopt: !!req.adopt } }
|
|
164
171
|
case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
|
|
165
172
|
case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
|
|
166
173
|
case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
|
package/src/manager.js
CHANGED
|
@@ -56,6 +56,10 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
56
56
|
proxyUrl,
|
|
57
57
|
log: (...a) => log(`[${tag}]`, ...a),
|
|
58
58
|
isLocked: () => profiles.isLocked(id),
|
|
59
|
+
// Camino A: nació para adoptar la cuenta de un aparato (ver profiles.add).
|
|
60
|
+
forAdoption: !!p?.adopt,
|
|
61
|
+
// Ya adoptó: la marca se consume (no vuelve a estar «a la espera»).
|
|
62
|
+
onAdopted: () => { try { profiles.clearAdopt(id) } catch (_) {} },
|
|
59
63
|
onEnrollChallenge: (info) => onEnrollChallenge?.({ ...info, profile: id, profileName: p?.name || '' })
|
|
60
64
|
})
|
|
61
65
|
running.set(id, v)
|
|
@@ -88,10 +92,15 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
88
92
|
currentId: () => profiles.current(),
|
|
89
93
|
resolve: (ref) => profiles.resolve(ref),
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Crea un perfil. Con `adopt: true` nace **vacío, esperando la cuenta de un aparato**
|
|
97
|
+
* (camino A): la bóveda pone el sitio y la llave que entrará como miembro, pero la
|
|
98
|
+
* cuenta la trae el dispositivo y se adopta al emparejar.
|
|
99
|
+
*/
|
|
100
|
+
async add (name, { adopt = false } = {}) {
|
|
101
|
+
const p = profiles.add(name, { adopt })
|
|
93
102
|
await open(p.id)
|
|
94
|
-
log('[vault] perfil creado: %s (%s)', p.name || '(sin nombre)', p.id)
|
|
103
|
+
log('[vault] perfil creado%s: %s (%s)', adopt ? ' (a la espera de adoptar una cuenta)' : '', p.name || '(sin nombre)', p.id)
|
|
95
104
|
return profiles.get(p.id)
|
|
96
105
|
},
|
|
97
106
|
|
package/src/profiles.js
CHANGED
|
@@ -63,7 +63,9 @@ export function openProfiles (root = dataDir()) {
|
|
|
63
63
|
createdAt: p.createdAt || null,
|
|
64
64
|
protected: !!p.pwd,
|
|
65
65
|
locked: !!p.pwd && !unlocked.has(p.id),
|
|
66
|
-
current: p.id === data.current
|
|
66
|
+
current: p.id === data.current,
|
|
67
|
+
// Nació para adoptar la cuenta de un aparato (camino A) y todavía no lo ha hecho.
|
|
68
|
+
...(p.adopt ? { adopt: true } : {})
|
|
67
69
|
})
|
|
68
70
|
|
|
69
71
|
function assertExists (id) {
|
|
@@ -123,15 +125,30 @@ export function openProfiles (root = dataDir()) {
|
|
|
123
125
|
return { id, migrated: legacy }
|
|
124
126
|
},
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Crea un perfil. Con `adopt: true` nace **para adoptar la cuenta de un aparato**
|
|
130
|
+
* (camino A): la bóveda le hace sitio, pero la cuenta —el `profileId`, la reputación,
|
|
131
|
+
* lo ya firmado— la trae el dispositivo. La marca la consume `startVault`, que se la
|
|
132
|
+
* pasa a la identidad (`prepareForAdoption`); sin ella, adoptar una cuenta ajena se
|
|
133
|
+
* leería como pisar una cuenta con datos y se rechazaría, que es lo que tiene que
|
|
134
|
+
* pasar cuando nadie lo pidió.
|
|
135
|
+
*/
|
|
136
|
+
add (name, { adopt = false } = {}) {
|
|
127
137
|
const id = newId()
|
|
128
138
|
ensureDir(dirOf(id))
|
|
129
|
-
data.profiles.push({ id, name: cleanName(name), createdAt: Date.now() })
|
|
139
|
+
data.profiles.push({ id, name: cleanName(name), createdAt: Date.now(), ...(adopt ? { adopt: true } : {}) })
|
|
130
140
|
if (!data.current) data.current = id
|
|
131
141
|
save()
|
|
132
142
|
return entry(find(id))
|
|
133
143
|
},
|
|
134
144
|
|
|
145
|
+
/** Quita la marca de «nació para adoptar» (ya adoptó, o se canceló). */
|
|
146
|
+
clearAdopt (id) {
|
|
147
|
+
const p = find(id)
|
|
148
|
+
if (p?.adopt) { delete p.adopt; save() }
|
|
149
|
+
return !!p
|
|
150
|
+
},
|
|
151
|
+
|
|
135
152
|
rename (id, name) {
|
|
136
153
|
const p = assertExists(id)
|
|
137
154
|
api.assertUnlocked(id)
|
package/src/qr.js
CHANGED
|
@@ -27,9 +27,14 @@ const qrcode = typeof qrcodeMod === 'function' ? qrcodeMod : qrcodeMod.default
|
|
|
27
27
|
* módulos por línea) y siempre con el contraste correcto.
|
|
28
28
|
*
|
|
29
29
|
* @param {string} text Contenido a codificar (modo byte / UTF-8).
|
|
30
|
-
* @param {number} [quiet=
|
|
30
|
+
* @param {number} [quiet=5] Zona de silencio en módulos.
|
|
31
|
+
*
|
|
32
|
+
* La norma pide 4 como MÍNIMO; aquí va uno más a propósito. En una terminal el blanco
|
|
33
|
+
* que dibujamos es lo único que separa el código del fondo oscuro y del texto de la
|
|
34
|
+
* línea de arriba, y un módulo de margen cuesta 2 columnas y 1 fila — mucho menos que
|
|
35
|
+
* un QR que no engancha. Ir por debajo de 4 no se hace ni para ganar filas.
|
|
31
36
|
*/
|
|
32
|
-
export function qrToString (text, quiet =
|
|
37
|
+
export function qrToString (text, quiet = 5) {
|
|
33
38
|
// typeNumber 0 = autoseleccionar la versión mínima; EC level 'L' (más capacidad).
|
|
34
39
|
const qr = qrcode(0, 'L')
|
|
35
40
|
qr.addData(text) // 'Byte' por defecto para datos no numéricos/alfanuméricos
|
package/src/tui/app.js
CHANGED
|
@@ -694,10 +694,8 @@ function pairingBody (st, t, cols, height) {
|
|
|
694
694
|
// en la cuenta equivocada sin enterarse.
|
|
695
695
|
const ap = activeProfile(st)
|
|
696
696
|
const acct = info.profileName || ap?.name || info.profile || ap?.id || '—'
|
|
697
|
-
lines.push(t.bold(i.pairAccount(acct)))
|
|
698
697
|
const left = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000))
|
|
699
|
-
lines.push(t.
|
|
700
|
-
lines.push('')
|
|
698
|
+
lines.push(t.bold(i.pairAccount(acct, left)))
|
|
701
699
|
// QR: se dibuja siempre que quepa de ancho; si es más alto que la pantalla se
|
|
702
700
|
// puede hacer scroll hacia arriba/abajo para verlo completo.
|
|
703
701
|
let qr = ''
|
|
@@ -711,6 +709,7 @@ function pairingBody (st, t, cols, height) {
|
|
|
711
709
|
lines.push(t.warn(i.pairQrTooNarrow(cols, qrWidth)))
|
|
712
710
|
lines.push('')
|
|
713
711
|
}
|
|
712
|
+
lines.push(t.muted(i.pairScan))
|
|
714
713
|
lines.push(t.bold(i.pairUrl) + info.url)
|
|
715
714
|
lines.push('')
|
|
716
715
|
lines.push(t.muted(i.pairPaste))
|
package/src/tui/i18n.js
CHANGED
|
@@ -134,8 +134,10 @@ const es = {
|
|
|
134
134
|
newAccountLabel: 'Nombre de la cuenta nueva',
|
|
135
135
|
newAccountHint: 'nace vacía; el dispositivo será su primer invitado',
|
|
136
136
|
accountCreated: (name) => `Cuenta creada: ${name}`,
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
// Cuenta + vigencia en UNA línea: cada línea de cabecera es una fila menos de QR
|
|
138
|
+
// visible antes de tener que hacer scroll.
|
|
139
|
+
pairAccount: (name, min) => `Cuenta que se comparte: ${name} · válido ~${min} min`,
|
|
140
|
+
pairScan: 'Escanéalo, o abre esta dirección en el dispositivo:',
|
|
139
141
|
pairUrl: 'URL: ',
|
|
140
142
|
pairPaste: 'O pega este código en vault.dotrino.com/dispositivos:',
|
|
141
143
|
pairWarning: '⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.',
|
|
@@ -297,8 +299,8 @@ const en = {
|
|
|
297
299
|
newAccountLabel: 'Name of the new account',
|
|
298
300
|
newAccountHint: 'born empty; the device will be its first guest',
|
|
299
301
|
accountCreated: (name) => `Account created: ${name}`,
|
|
300
|
-
pairAccount: (name) => `Account being shared: ${name}`,
|
|
301
|
-
|
|
302
|
+
pairAccount: (name, min) => `Account being shared: ${name} · valid ~${min} min`,
|
|
303
|
+
pairScan: 'Scan it, or open this address on the device:',
|
|
302
304
|
pairUrl: 'URL: ',
|
|
303
305
|
pairPaste: 'Or paste this code into vault.dotrino.com/dispositivos:',
|
|
304
306
|
pairWarning: '⚠ This code lets someone READ your data and SIGN as you. Do not share it.',
|
package/src/vault.js
CHANGED
|
@@ -35,7 +35,7 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
|
35
35
|
* Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
|
|
36
36
|
* siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
|
|
37
37
|
*/
|
|
38
|
-
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false } = {}) {
|
|
38
|
+
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
|
|
39
39
|
ensureDir(dir)
|
|
40
40
|
// CIFRADO EN REPOSO ligado a esta máquina: `identity.json` deja de estar en claro, así
|
|
41
41
|
// que copiarlo a otro equipo no sirve de nada. No protege contra quien ya tiene ESTA
|
|
@@ -47,6 +47,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
47
47
|
} catch (e) { log('[vault] no se pudo cifrar la identidad en reposo:', e.message) }
|
|
48
48
|
const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
|
|
49
49
|
if (!identity.me?.publickey) await identity.setMyNickname('')
|
|
50
|
+
// CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
|
|
51
|
+
// igual (su llave es la que entrará como miembro), pero se marca para que `joinProfile`
|
|
52
|
+
// acepte cambiar su acta recién nacida por la que traiga el dispositivo. Sin la marca,
|
|
53
|
+
// adoptar sería pisar una cuenta con datos y se rechaza — que es lo correcto por defecto.
|
|
54
|
+
if (forAdoption) {
|
|
55
|
+
try { await identity.prepareForAdoption() } catch (e) { log('[vault] no se pudo preparar el perfil para adoptar:', e.message) }
|
|
56
|
+
}
|
|
50
57
|
|
|
51
58
|
const store = openStore(dir)
|
|
52
59
|
const threads = openThreadStore(dir)
|
|
@@ -101,6 +108,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
101
108
|
sendByPubkey: (pub, obj) => client.sendByPubkey(pub, obj),
|
|
102
109
|
audit,
|
|
103
110
|
log,
|
|
111
|
+
// Camino A: lo que esta bóveda le manda al aparato para entrar en SU acta. La llave de
|
|
112
|
+
// cifrado es lo que le permite leer el contenido de la cuenta que va a custodiar; sin
|
|
113
|
+
// ella entraría mandando una cuenta que no puede abrir.
|
|
114
|
+
encPub: identity.me?.encryptionPubkey || null,
|
|
115
|
+
vaultLabel: 'bóveda',
|
|
116
|
+
// Nuestra dirección en el proxy: lo único que lleva el QR corto.
|
|
117
|
+
connToken: () => client.token,
|
|
118
|
+
onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
|
|
104
119
|
defaultScope: [SCOPE.READ],
|
|
105
120
|
onChallenge ({ deviceId, scope }) {
|
|
106
121
|
log(`\n[vault] Un dispositivo quiere conectarse:`)
|
|
@@ -262,7 +277,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
262
277
|
client.on('message', async (from, payload) => {
|
|
263
278
|
if (!payload || typeof payload !== 'object') return
|
|
264
279
|
try {
|
|
280
|
+
if (payload.type === MSG.HELLO) return desk.handleHello(from, payload)
|
|
265
281
|
if (payload.type === MSG.ENROLL) return await desk.handleEnroll(from, payload)
|
|
282
|
+
if (payload.type === MSG.ACTA_SEALED) return await desk.handleActaSealed(from, payload)
|
|
266
283
|
if (payload.type === MSG.SIGN) return await handleSign(from, payload)
|
|
267
284
|
if (payload.type === MSG.GET) return await handleGet(from, payload)
|
|
268
285
|
if (payload.type === MSG.STORE) return await handleStore(from, payload)
|