@dotrino/vault 0.33.3 → 0.35.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 +3 -3
- package/src/enroll.js +1 -1
- package/src/index.js +124 -7
- package/src/invite.js +6 -4
- package/src/protocol.js +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"pairing"
|
|
48
48
|
],
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@dotrino/identity": ">=0.
|
|
51
|
-
"@dotrino/proxy-client": ">=0.
|
|
50
|
+
"@dotrino/identity": ">=0.61.0",
|
|
51
|
+
"@dotrino/proxy-client": ">=0.13.0"
|
|
52
52
|
},
|
|
53
53
|
"license": "MIT",
|
|
54
54
|
"repository": {
|
package/src/enroll.js
CHANGED
|
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
|
|
|
52
52
|
export const MSG_ERROR = 'vault.error'
|
|
53
53
|
|
|
54
54
|
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
55
|
-
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
|
|
55
|
+
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
|
|
56
56
|
export const scopeToCaps = (scope) =>
|
|
57
57
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
58
58
|
|
package/src/index.js
CHANGED
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
* (`identity.signDelegation`). Transporte: `@dotrino/proxy-client` (import perezoso).
|
|
26
26
|
* No reimplementa nada del ecosistema.
|
|
27
27
|
*/
|
|
28
|
-
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
28
|
+
import { verifyChain, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
29
29
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
30
30
|
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
31
|
-
// local se queda corta, el dispositivo deja de
|
|
31
|
+
// local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
|
|
32
32
|
import { MSG, SCOPE } from './protocol.js'
|
|
33
33
|
|
|
34
34
|
const SIGN_SCOPE = SCOPE.SIGN
|
|
@@ -168,15 +168,132 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
168
168
|
if (mine) desk.emitRevoke(chk.device, mine.nonce)
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Lo que el daemon comprueba antes de cualquier operación firmada, en un solo sitio:
|
|
173
|
+
* frescura (anti-replay), cadena de certs, scope esperado y revocaciones.
|
|
174
|
+
*
|
|
175
|
+
* Estaba repetido en `handleRenew` y `handleDevices` con matices distintos; al añadir
|
|
176
|
+
* el resto de operaciones eso habría sido cuatro copias divergiendo.
|
|
177
|
+
*/
|
|
178
|
+
async function authorise (from, p, expectedScope) {
|
|
179
|
+
const d = p?.data
|
|
180
|
+
if (!d || !p.signature || !p.cert) {
|
|
181
|
+
send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
185
|
+
send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or a clock out of sync)' })
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
const chk = await verifyChain({
|
|
189
|
+
data: d, signature: p.signature, cert: p.cert,
|
|
190
|
+
...(expectedScope ? { expectedScope } : {}),
|
|
191
|
+
trustedIssuer: iss, revoked: await revocationSet(),
|
|
192
|
+
})
|
|
193
|
+
if (!chk.ok) {
|
|
194
|
+
send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
return chk
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* FIRMAR en name de la identidad. Es la razón de ser de una bóveda, y faltaba: un
|
|
202
|
+
* aparato enrolado contra este dispositivo podía renovar su cert y listar aparatos,
|
|
203
|
+
* pero no pedir la única cosa para la que se enroló.
|
|
204
|
+
*/
|
|
205
|
+
async function handleSign (from, p) {
|
|
206
|
+
const chk = await authorise(from, p, SCOPE.SIGN)
|
|
207
|
+
if (!chk) return
|
|
208
|
+
const toSign = p.data?.payload
|
|
209
|
+
if (toSign == null) return send(from, { type: MSG.ERROR, error: 'data.payload required' })
|
|
210
|
+
const { signature, publickey } = await identity.signData(toSign)
|
|
211
|
+
send(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Leer del almacén del perfil. Mismo scope que en el daemon: `read`. */
|
|
215
|
+
async function handleGet (from, p) {
|
|
216
|
+
const chk = await authorise(from, p, SCOPE.READ)
|
|
217
|
+
if (!chk) return
|
|
218
|
+
const id = p.data?.id || 'root'
|
|
219
|
+
try {
|
|
220
|
+
const node = await identity.getNode?.(id)
|
|
221
|
+
send(from, { type: MSG.DATA, id, node: node ?? null })
|
|
222
|
+
} catch (e) {
|
|
223
|
+
send(from, { type: MSG.ERROR, error: 'get: ' + e.message })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Escribir en el almacén. Se pasa por `vaultStore`, que es el mismo camino que usa
|
|
229
|
+
* un aparato contra el daemon — no se reimplementa el store aquí.
|
|
230
|
+
*/
|
|
231
|
+
async function handleStore (from, p) {
|
|
232
|
+
const d = p?.data
|
|
233
|
+
if (!d || typeof d.method !== 'string') {
|
|
234
|
+
return send(from, { type: MSG.ERROR, error: 'store: invalid method' })
|
|
235
|
+
}
|
|
236
|
+
const chk = await authorise(from, p, SCOPE.STORE)
|
|
237
|
+
if (!chk) return
|
|
238
|
+
try {
|
|
239
|
+
const result = await identity.vaultStore?.(d.method, d.args || [])
|
|
240
|
+
send(from, { type: MSG.DATA, id: d.method, node: result ?? null })
|
|
241
|
+
} catch (e) {
|
|
242
|
+
send(from, { type: MSG.ERROR, error: 'store: ' + e.message })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* ¿Sigue este aparato dentro del acta? Lo pregunta un aparato al arrancar, y por eso
|
|
248
|
+
* NO va firmado con cert: va firmado con su propia llave. Un aparato revocado tiene
|
|
249
|
+
* que poder enterarse de que lo está.
|
|
250
|
+
*/
|
|
251
|
+
async function handleCheck (from, p) {
|
|
252
|
+
const d = p?.data
|
|
253
|
+
if (!d || typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
254
|
+
const pub = d.publickey
|
|
255
|
+
if (typeof pub !== 'string') return send(from, { type: MSG.ERROR, error: 'unauthorized: shape' })
|
|
256
|
+
if (!(await verifyDeviceSig({ publickey: pub, data: d, signature: p.signature }))) {
|
|
257
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature' })
|
|
258
|
+
}
|
|
259
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
260
|
+
const inside = (record?.members || []).some((m) => m?.pub === pub)
|
|
261
|
+
if (inside) return send(from, { type: MSG.CHECKED, in: true })
|
|
262
|
+
|
|
263
|
+
// Fuera del acta: se le dice, y además se le re-emite el aviso firmado si consta
|
|
264
|
+
// revocado — para que se apague solo en vez de quedarse creyendo que sigue dentro.
|
|
265
|
+
const { revokedCerts, issued } = await identity.listDelegations()
|
|
266
|
+
const mine = (revokedCerts || issued || []).find((x) => x.sub === pub && x.revokedAt)
|
|
267
|
+
if (mine) desk.emitRevoke(pub, mine.nonce)
|
|
268
|
+
send(from, { type: MSG.CHECKED, in: false })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Un fallo dentro de un handler NO se traga.
|
|
273
|
+
*
|
|
274
|
+
* El router llevaba `.catch(() => {})` en cada rama: si algo reventaba, el aparato del
|
|
275
|
+
* otro lado se quedaba esperando para siempre y aquí no quedaba rastro. Ahora se
|
|
276
|
+
* contesta el error — que es lo que permite depurarlo desde el lado que pregunta.
|
|
277
|
+
*/
|
|
278
|
+
const handle = (name, promise, from) => Promise.resolve(promise).catch((e) => {
|
|
279
|
+
send(from, { type: MSG.ERROR, error: `${name}: ${e?.message || e}` })
|
|
280
|
+
})
|
|
281
|
+
|
|
171
282
|
client.on('message', (_from, p) => {
|
|
172
283
|
if (!p || typeof p !== 'object') return
|
|
173
284
|
// El QR corto no lleva la llave: el aparato la pide con un HELLO presentando el `sn`.
|
|
174
|
-
if (p.type === MSG.HELLO)
|
|
175
|
-
else if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p)
|
|
285
|
+
if (p.type === MSG.HELLO) handle('hello', desk.handleHello(_from, p), _from)
|
|
286
|
+
else if (p.type === MSG.ENROLL) handle('enroll', desk.handleEnroll(_from, p), _from)
|
|
176
287
|
// Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
|
|
177
|
-
else if (p.type === MSG.ACTA_SEALED)
|
|
178
|
-
else if (p.type === MSG.RENEW) handleRenew(_from, p)
|
|
179
|
-
else if (p.type === MSG.DEVICES) handleDevices(_from, p)
|
|
288
|
+
else if (p.type === MSG.ACTA_SEALED) handle('acta', desk.handleActaSealed(_from, p), _from)
|
|
289
|
+
else if (p.type === MSG.RENEW) handle('renew', handleRenew(_from, p), _from)
|
|
290
|
+
else if (p.type === MSG.DEVICES) handle('devices', handleDevices(_from, p), _from)
|
|
291
|
+
// Lo que faltaba para que un aparato enrolado aquí pueda hacer lo mismo que contra
|
|
292
|
+
// el daemon del PC: firmar, leer, guardar y comprobar que sigue dentro.
|
|
293
|
+
else if (p.type === MSG.SIGN) handle('sign', handleSign(_from, p), _from)
|
|
294
|
+
else if (p.type === MSG.GET) handle('get', handleGet(_from, p), _from)
|
|
295
|
+
else if (p.type === MSG.STORE) handle('store', handleStore(_from, p), _from)
|
|
296
|
+
else if (p.type === MSG.CHECK) handle('check', handleCheck(_from, p), _from)
|
|
180
297
|
})
|
|
181
298
|
|
|
182
299
|
/**
|
package/src/invite.js
CHANGED
|
@@ -69,10 +69,12 @@ export const FMT_SHORT = 't'
|
|
|
69
69
|
export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* La base del enlace del QR. Corta a propósito (`/d#v=` en vez de
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
72
|
+
* La base del enlace del QR. Corta a propósito (`/d#v=` en vez de `/vault#vault=`):
|
|
73
|
+
* son caracteres menos dentro del QR, y ahí los caracteres se pagan en módulos.
|
|
74
|
+
*
|
|
75
|
+
* `parseInvite` no mira la ruta, solo el #fragment: por eso un enlace emitido con
|
|
76
|
+
* cualquiera de las formas anteriores sigue emparejando aunque su dirección ya no
|
|
77
|
+
* exista.
|
|
76
78
|
*/
|
|
77
79
|
export const PAIR_URL = 'https://vault.dotrino.com/d#v='
|
|
78
80
|
|
package/src/protocol.js
CHANGED
|
@@ -98,7 +98,11 @@ export const SCOPE = Object.freeze({
|
|
|
98
98
|
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
99
99
|
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
100
100
|
ADMIN: 'vault:admin',
|
|
101
|
-
APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
101
|
+
APPROVE: 'vault:approve', // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
102
|
+
// El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
|
|
103
|
+
// Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
|
|
104
|
+
// lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
|
|
105
|
+
PASSWORDS: 'vault:passwords'
|
|
102
106
|
})
|
|
103
107
|
|
|
104
108
|
/**
|