@dotrino/vaultd 0.118.0 → 0.121.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/b64.js +36 -0
- package/lib/src/enroll.js +39 -12
- package/lib/src/index.js +97 -3
- package/lib/src/invite.js +3 -29
- package/lib/src/passwordLogins.js +431 -0
- package/lib/src/protocol.js +13 -0
- package/package.json +4 -3
- package/src/ctl.js +212 -1
- package/src/daemon.js +67 -0
- package/src/transport.js +68 -1
- package/src/vault.js +119 -2
package/lib/src/b64.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* b64.js — base64url a mano, sin `Buffer` ni `btoa`.
|
|
3
|
+
*
|
|
4
|
+
* Vive aparte porque lo usan piezas que corren en los tres sitios: el binario (Node), la
|
|
5
|
+
* pestaña y la extensión. `Buffer` no existe en el navegador y `btoa` no existe en algunos
|
|
6
|
+
* workers, así que la única forma de tener UNA implementación es esta.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const B64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
10
|
+
|
|
11
|
+
export function bytesToB64url (bytes) {
|
|
12
|
+
let out = ''
|
|
13
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
14
|
+
const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
|
|
15
|
+
out += B64_STD[a >> 2]
|
|
16
|
+
out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
|
|
17
|
+
if (b === undefined) break
|
|
18
|
+
out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
|
|
19
|
+
if (c === undefined) break
|
|
20
|
+
out += B64_STD[c & 63]
|
|
21
|
+
}
|
|
22
|
+
return out.replace(/\+/g, '-').replace(/\//g, '_')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function b64urlToBytes (s) {
|
|
26
|
+
const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
|
|
27
|
+
const out = []
|
|
28
|
+
let acc = 0; let bits = 0
|
|
29
|
+
for (const ch of clean) {
|
|
30
|
+
const v = B64_STD.indexOf(ch)
|
|
31
|
+
if (v < 0) return null
|
|
32
|
+
acc = (acc << 6) | v; bits += 6
|
|
33
|
+
if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
|
|
34
|
+
}
|
|
35
|
+
return Uint8Array.from(out)
|
|
36
|
+
}
|
package/lib/src/enroll.js
CHANGED
|
@@ -349,23 +349,50 @@ export function createEnrollDesk ({
|
|
|
349
349
|
return { ok: true, deviceId: pend.deviceId, adopting: true }
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
-
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
353
|
-
|
|
354
352
|
// Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
|
|
355
353
|
// credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
|
|
356
354
|
// Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
|
|
357
|
-
|
|
355
|
+
//
|
|
356
|
+
// NADA DE REPLIEGUES (2026-09-17). Esto antes estaba envuelto en un `catch` que solo
|
|
357
|
+
// anotaba el error, y además se saltaba en silencio si la identidad no sabía admitir o si
|
|
358
|
+
// el scope no daba ninguna capacidad: el aparato recibía su certificado SIN estar en el
|
|
359
|
+
// acta, y el fallo aparecía después y en otro sitio. Ahora, si no se puede admitir, no
|
|
360
|
+
// se entrega nada y se dice.
|
|
361
|
+
if (typeof identity.admitMember !== 'function') {
|
|
362
|
+
throw Object.assign(new Error('this vault cannot add devices to the account record: no certificate was issued'), { code: 'admit-unavailable' })
|
|
363
|
+
}
|
|
364
|
+
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
365
|
+
// cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
|
|
366
|
+
// con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
|
|
367
|
+
const cn = scopeToCn(pend.scope)
|
|
368
|
+
const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
|
|
369
|
+
if (!caps.length) {
|
|
370
|
+
throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
374
|
+
|
|
358
375
|
try {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
376
|
+
await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
377
|
+
} catch (e) {
|
|
378
|
+
// El certificado ya está firmado y no se entrega: se revoca, para que no quede uno
|
|
379
|
+
// válido suelto. Si ni eso sale, se dice también.
|
|
380
|
+
let revoked = true
|
|
381
|
+
try { await identity.revokeDelegation(cert.nonce) } catch (re) {
|
|
382
|
+
revoked = false
|
|
383
|
+
log('[vault] could not revoke the undelivered certificate %s: %s', cert.nonce, re.message)
|
|
366
384
|
}
|
|
367
|
-
|
|
368
|
-
|
|
385
|
+
audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'admit-failed' })
|
|
386
|
+
reply(pend.from, { type: MSG_ERROR, error: 'the vault could not add this device to the account: pairing failed, try again' })
|
|
387
|
+
pending.delete(pend.token)
|
|
388
|
+
fire(onPendingChange)
|
|
389
|
+
log('[vault] could not add %s to the record, no certificate was delivered: %s', pend.deviceId, e.message)
|
|
390
|
+
throw Object.assign(
|
|
391
|
+
new Error(`could not add the device to the account record (${e.message}): no certificate was delivered${revoked ? '' : ', and the signed one could not be revoked'}`),
|
|
392
|
+
{ code: 'admit-failed', cause: e }
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
const record = (await identity.profileActa?.())?.acta || null
|
|
369
396
|
|
|
370
397
|
audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
|
|
371
398
|
// Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
|
package/lib/src/index.js
CHANGED
|
@@ -46,11 +46,16 @@ export { deviceIdOf }
|
|
|
46
46
|
* `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
47
47
|
* @param {object} [opts]
|
|
48
48
|
* @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
|
|
49
|
+
* @param {object} [opts.logins] escritorio de `createLoginDesk` (`./password-logins`) para
|
|
50
|
+
* ENTRAR CON USUARIO Y CONTRASEÑA. Lo monta quien levanta esta bóveda, porque en el
|
|
51
|
+
* navegador no hay archivos y el estado tiene que guardarlo él. Sin él, esta bóveda
|
|
52
|
+
* contesta `logins-unavailable` — no se inventa un almacén.
|
|
49
53
|
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
|
|
50
|
-
* approve, reject, listPending, listMachines, revoke, getSelfCert,
|
|
51
|
-
*
|
|
54
|
+
* approve, reject, listPending, listMachines, revoke, getSelfCert, listLogins,
|
|
55
|
+
* loginRegisterBegin, loginRegisterFinish, loginBegin, loginEnd, closeLogin,
|
|
56
|
+
* clearLoginBlock, removeLogin, onPendingChange, onAdopted, close }
|
|
52
57
|
*/
|
|
53
|
-
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
58
|
+
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient, logins = null } = {}) {
|
|
54
59
|
const iss = identity.me?.publickey
|
|
55
60
|
if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
|
|
56
61
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
@@ -328,8 +333,72 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
328
333
|
else if (p.type === MSG.GET) handle('get', handleGet(_from, p), _from)
|
|
329
334
|
else if (p.type === MSG.STORE) handle('store', handleStore(_from, p), _from)
|
|
330
335
|
else if (p.type === MSG.CHECK) handle('check', handleCheck(_from, p), _from)
|
|
336
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA. La misma pieza que usa el binario
|
|
337
|
+
// (`passwordLogins.js`), con el almacén que le ponga quien monta esta bóveda: en el
|
|
338
|
+
// navegador no hay archivos, así que lo pone el iframe de identidad.
|
|
339
|
+
else if (p.type === MSG.LOGIN_START) handle('login', handleLoginStart(_from, p), _from)
|
|
340
|
+
else if (p.type === MSG.LOGIN_FINISH) handle('login', handleLoginFinish(_from, p), _from)
|
|
341
|
+
else if (p.type === MSG.LOGIN_CLOSE) handle('login', handleLoginClose(_from, p), _from)
|
|
331
342
|
})
|
|
332
343
|
|
|
344
|
+
// --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
|
|
345
|
+
//
|
|
346
|
+
// Sin almacén no se inventa nada: se contesta que aquí no hay inicios de sesión. Es una
|
|
347
|
+
// bóveda que no atiende esto, no una que dice que la contraseña está mal.
|
|
348
|
+
const noLogins = (from) => send(from, { type: MSG.ERROR, error: 'this vault does not keep password logins', code: 'logins-unavailable' })
|
|
349
|
+
|
|
350
|
+
/** Lo mismo desde la consola: sin almacén no se administra nada, y se dice. */
|
|
351
|
+
function needLogins () {
|
|
352
|
+
if (!logins) throw Object.assign(new Error('this vault does not keep password logins: no store was given to startDeviceVault'), { code: 'logins-unavailable' })
|
|
353
|
+
return logins
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function loginError (from, e) {
|
|
357
|
+
const code = e?.code
|
|
358
|
+
if (code === 'too-many-tries') return send(from, { type: MSG.ERROR, error: e.message, code, waitMs: e.waitMs || 0 })
|
|
359
|
+
if (code === 'login-failed' || code === 'no-exchange' || code === 'bad-input' || code === 'bad-user') {
|
|
360
|
+
return send(from, { type: MSG.ERROR, error: e.message, code })
|
|
361
|
+
}
|
|
362
|
+
send(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function handleLoginStart (from, p) {
|
|
366
|
+
if (!logins) return noLogins(from)
|
|
367
|
+
try {
|
|
368
|
+
const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
|
|
369
|
+
send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
|
|
370
|
+
} catch (e) { loginError(from, e) }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handleLoginFinish (from, p) {
|
|
374
|
+
if (!logins) return noLogins(from)
|
|
375
|
+
try {
|
|
376
|
+
const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
377
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
378
|
+
send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
|
|
379
|
+
} catch (e) { loginError(from, e) }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Salir va firmado con la llave que acaba de abrir: cerrar la de otro sería echarlo. */
|
|
383
|
+
async function handleLoginClose (from, p) {
|
|
384
|
+
if (!logins) return noLogins(from)
|
|
385
|
+
const d = p?.data
|
|
386
|
+
if (typeof d?.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
387
|
+
return send(from, { type: MSG.ERROR, error: 'stale request', code: 'stale' })
|
|
388
|
+
}
|
|
389
|
+
if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
|
|
390
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
|
|
391
|
+
}
|
|
392
|
+
if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
|
|
393
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
|
|
394
|
+
}
|
|
395
|
+
const mine = logins.list().find((x) => x.user === d.user)
|
|
396
|
+
if (!mine || mine.pub !== d.publickey) {
|
|
397
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
|
|
398
|
+
}
|
|
399
|
+
send(from, { type: MSG.LOGIN_CLOSED, ok: logins.closeSession({ user: d.user, sid: d.sid }).ok })
|
|
400
|
+
}
|
|
401
|
+
|
|
333
402
|
/**
|
|
334
403
|
* Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
|
|
335
404
|
* propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
|
|
@@ -368,6 +437,31 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
368
437
|
// exactamente esto.
|
|
369
438
|
revoke: (nonce) => desk.revoke(nonce),
|
|
370
439
|
getSelfCert,
|
|
440
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA, desde la consola de esta bóveda. Es la misma pieza
|
|
441
|
+
// que el binario (`registerLogin`), para que el aparato que sale de aquí sea idéntico.
|
|
442
|
+
// Sin almacén no hay nada que administrar, y se dice en vez de contestar una lista vacía.
|
|
443
|
+
listLogins: () => needLogins().list(),
|
|
444
|
+
loginRegisterBegin: (opts) => needLogins().registerBegin(opts),
|
|
445
|
+
// El alta se trae cuando se usa: arrastra el OPAQUE en WASM (unos 260 KB) y la mayoría
|
|
446
|
+
// de las apps que usan este módulo no crean ningún inicio de sesión.
|
|
447
|
+
loginRegisterFinish: async (opts) => {
|
|
448
|
+
const { registerLogin } = await import('./passwordLogins.js')
|
|
449
|
+
return registerLogin({ ...opts, identity, logins: needLogins() })
|
|
450
|
+
},
|
|
451
|
+
// Entrar desde la propia consola, sin pasar por el proxio. Hace falta para cambiar la
|
|
452
|
+
// contraseña: el paquete de llaves se abre con la vieja y se vuelve a cerrar con la nueva.
|
|
453
|
+
loginBegin: ({ user, request }) => needLogins().loginBegin({ user, request }),
|
|
454
|
+
loginEnd: ({ lid, finalization, label }) => needLogins().loginEnd({ lid, finalization, label }),
|
|
455
|
+
closeLogin: ({ user, sid }) => needLogins().closeSession({ user, sid }),
|
|
456
|
+
clearLoginBlock: ({ user }) => needLogins().clearBlock({ user }),
|
|
457
|
+
removeLogin: ({ user }) => {
|
|
458
|
+
const store = needLogins()
|
|
459
|
+
const found = store.list().find((x) => x.user === user)
|
|
460
|
+
if (!found) return { ok: false }
|
|
461
|
+
store.remove({ user })
|
|
462
|
+
// Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
|
|
463
|
+
return { ok: true, pub: found.pub, deviceId: found.deviceId }
|
|
464
|
+
},
|
|
371
465
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
372
466
|
/** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
|
|
373
467
|
onAdopted (fn) { _onAdopted = fn || (() => {}) },
|
package/lib/src/invite.js
CHANGED
|
@@ -80,37 +80,11 @@ export const PAIR_URL = 'https://vault.dotrino.com/d#v='
|
|
|
80
80
|
|
|
81
81
|
// ---------------------------------------------------------------------------
|
|
82
82
|
// Conversiones (sin dependencias: esto corre en el navegador, en Node y dentro
|
|
83
|
-
// del binario SEA)
|
|
83
|
+
// del binario SEA). El base64url vive en `b64.js`, porque el paquete de llaves de
|
|
84
|
+
// los inicios de sesión usa el mismo.
|
|
84
85
|
// ---------------------------------------------------------------------------
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
function bytesToB64url (bytes) {
|
|
89
|
-
let out = ''
|
|
90
|
-
for (let i = 0; i < bytes.length; i += 3) {
|
|
91
|
-
const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
|
|
92
|
-
out += B64_STD[a >> 2]
|
|
93
|
-
out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
|
|
94
|
-
if (b === undefined) break
|
|
95
|
-
out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
|
|
96
|
-
if (c === undefined) break
|
|
97
|
-
out += B64_STD[c & 63]
|
|
98
|
-
}
|
|
99
|
-
return out.replace(/\+/g, '-').replace(/\//g, '_')
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function b64urlToBytes (s) {
|
|
103
|
-
const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
|
|
104
|
-
const out = []
|
|
105
|
-
let acc = 0; let bits = 0
|
|
106
|
-
for (const ch of clean) {
|
|
107
|
-
const v = B64_STD.indexOf(ch)
|
|
108
|
-
if (v < 0) return null
|
|
109
|
-
acc = (acc << 6) | v; bits += 6
|
|
110
|
-
if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
|
|
111
|
-
}
|
|
112
|
-
return Uint8Array.from(out)
|
|
113
|
-
}
|
|
87
|
+
import { bytesToB64url, b64urlToBytes } from './b64.js'
|
|
114
88
|
|
|
115
89
|
const utf8 = (s) => new TextEncoder().encode(s)
|
|
116
90
|
const fromUtf8 = (b) => new TextDecoder().decode(b)
|