@dotrino/identity 0.117.2 → 0.119.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/index.js +14 -0
- package/vault/acta.js +8 -10
- package/vault/index.html +1 -0
- package/vault/session.js +1 -1
- package/vault/vault.js +39 -0
- package/vault/vendor/passmanager/VERSION.txt +1 -1
- package/vault/vendor/passmanager/model.js +4 -0
- package/vault/vendor/passmanager/sealed/entry.js +1 -1
- package/vault/vendor/passmanager/sealed/store.js +16 -10
- package/vault/vendor/passmanager/vault/approval.js +36 -7
- package/vault/vendor/passmanager/vault/sealed-responder.js +23 -2
- package/vault/vendor/passmanager/vault/sealed.js +5 -1
- package/vault/vendor/roadmap/VERSION.txt +1 -1
- package/vault/vendor/roadmap/manifests.js +99 -58
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/core/backup.js +244 -0
- package/vault/vendor/vault/core/libVersion.js +1 -1
- package/vault/vendor/vault/core/passwords.js +88 -13
- package/vault/vendor/vault/core/secretsStore.js +61 -2
- package/vault/vendor/vault/core/vault.js +173 -5
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -601,6 +601,20 @@ export class Identity {
|
|
|
601
601
|
/** Cambia la contraseña: hace falta la vieja, y lo que estuviera abierto se cierra. */
|
|
602
602
|
async selfVaultLoginPasswd (opts) { return this._call('selfVaultLoginPasswd', opts || {}, 60000) }
|
|
603
603
|
|
|
604
|
+
// ----- el RESPALDO, cuando la bóveda es ESTA pestaña (solo desde la consola) -----
|
|
605
|
+
//
|
|
606
|
+
// Bóveda abierta y origen de la consola (`vault.dotrino.com`): cualquier otro recibe
|
|
607
|
+
// `code: 'not-the-console'`. El archivo vuelve YA CIFRADO con la contraseña dada.
|
|
608
|
+
|
|
609
|
+
/** Los cajones que hay: `[{ owner, count, device? }]`. */
|
|
610
|
+
async selfVaultBackupDrawers () { return this._call('selfVaultBackupDrawers', {}, 30000) }
|
|
611
|
+
/** Exporta todo (`drawers: null`) o esos cajones. Devuelve `{ file, summary }`. */
|
|
612
|
+
async selfVaultBackupExport (opts) { return this._call('selfVaultBackupExport', opts || {}, 120000) }
|
|
613
|
+
/** El plan de importar `file` (qué es nuevo, igual o choca). No escribe nada. */
|
|
614
|
+
async selfVaultBackupPlan (opts) { return this._call('selfVaultBackupPlan', opts || {}, 120000) }
|
|
615
|
+
/** Importa. Con un choque sin decidir lanza `BACKUP_CONFLICTS` y no escribe nada. */
|
|
616
|
+
async selfVaultBackupImport (opts) { return this._call('selfVaultBackupImport', opts || {}, 120000) }
|
|
617
|
+
|
|
604
618
|
/** Cierra un inicio de sesión abierto (sin `sid`, todos los de ese usuario). */
|
|
605
619
|
async selfVaultLoginClose (opts) { return this._call('selfVaultLoginClose', opts || {}) }
|
|
606
620
|
|
package/vault/acta.js
CHANGED
|
@@ -105,7 +105,7 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
|
|
|
105
105
|
* el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
|
|
106
106
|
* acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
|
|
107
107
|
*/
|
|
108
|
-
export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', '
|
|
108
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
|
|
109
109
|
|
|
110
110
|
/** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
|
|
111
111
|
/**
|
|
@@ -119,14 +119,12 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve',
|
|
|
119
119
|
* pedirle algo a la bóveda es exactamente lo que decide el acta — tener dos registros
|
|
120
120
|
* de lo mismo obliga a acordarse de los dos al quitar un aparato.
|
|
121
121
|
*
|
|
122
|
-
* `
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* Sin `passwords` no significa nada: la passkey vive en una entrada de contraseñas. Y una
|
|
129
|
-
* SESIÓN no lo lleva nunca, como `passwords` (`session.js`).
|
|
122
|
+
* Y `passwords` cubre TAMBIÉN LAS PASSKEYS (dueño, 2026-10-16: «realmente deberían ser uno
|
|
123
|
+
* solo»). Hasta la 0.117 había un `passkeys` aparte para abrir la privada de una passkey;
|
|
124
|
+
* se fusionó en este. Un acta vieja que todavía lo nombre sigue valiendo: un permiso
|
|
125
|
+
* desconocido se ignora (ver `checkShape`), y al siguiente cambio del acta desaparece.
|
|
126
|
+
* Lo que distingue una passkey es la POLÍTICA de la bóveda, no un permiso: añadirla es
|
|
127
|
+
* directo, reemplazarla o borrarla pide aprobación (`@dotrino/passmanager`).
|
|
130
128
|
*
|
|
131
129
|
* `replica` es REPARTIR, NO DECIDIR. Un replicador no tiene maestra: guarda el acta y los
|
|
132
130
|
* sobres —que ya vienen sellados a su destinatario, así que tampoco puede abrirlos— y los
|
|
@@ -151,7 +149,7 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve',
|
|
|
151
149
|
* de la cuenta, se ve en la pantalla de permisos como los demás, y se quita quitándolo —
|
|
152
150
|
* sin acordarse de un segundo registro escondido.
|
|
153
151
|
*/
|
|
154
|
-
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', '
|
|
152
|
+
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
|
|
155
153
|
|
|
156
154
|
/**
|
|
157
155
|
* Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
|
package/vault/index.html
CHANGED
|
@@ -168,6 +168,7 @@
|
|
|
168
168
|
"@dotrino/identity/machine": "./machine.js",
|
|
169
169
|
"@dotrino/vault/password-logins": "./vendor/vault/passwordLogins.js",
|
|
170
170
|
"@dotrino/vault/login-client": "./vendor/vault/loginClient.js",
|
|
171
|
+
"@dotrino/vault/backup": "./vendor/vault/core/backup.js",
|
|
171
172
|
"@dotrino/opaque": "./vendor/opaque/src/index.js",
|
|
172
173
|
"@dotrino/identity/capabilities": "./capabilities.js",
|
|
173
174
|
"@dotrino/identity/acta": "./acta.js",
|
package/vault/session.js
CHANGED
|
@@ -57,7 +57,7 @@ export const SESSION_SCOPES = Object.freeze(['id:whoami', 'vault:store'])
|
|
|
57
57
|
* blanca a propósito: si mañana alguien añade un alcance a `SESSION_SCOPES` sin pensarlo,
|
|
58
58
|
* esto sigue cortando lo que no puede pasar.
|
|
59
59
|
*/
|
|
60
|
-
export const SESSION_FORBIDDEN = Object.freeze(['secrets', 'admin', 'approve', 'sealer', 'passwords', '
|
|
60
|
+
export const SESSION_FORBIDDEN = Object.freeze(['secrets', 'admin', 'approve', 'sealer', 'passwords', 'unattended', 'replica'])
|
|
61
61
|
|
|
62
62
|
/** Qué capacidad del acta hace falta para conceder cada alcance de sesión. */
|
|
63
63
|
const SCOPE_NEEDS = Object.freeze({ 'id:whoami': null, 'vault:store': 'store' })
|
package/vault/vault.js
CHANGED
|
@@ -469,6 +469,45 @@ import { useBackend as usePeerBackend } from './peerStore.js'
|
|
|
469
469
|
}
|
|
470
470
|
}
|
|
471
471
|
return { ...r, deviceId: fila?.deviceId || null }
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
// ----- EL RESPALDO (`dotrino-vault/docs/respaldo.md`), cuando la bóveda es ESTA pestaña -----
|
|
475
|
+
//
|
|
476
|
+
// Exportar e importar, con la bóveda abierta (lo comprueba el núcleo: `VAULT_LOCKED`).
|
|
477
|
+
// Y SOLO DESDE LA CONSOLA: este iframe atiende a cualquier `*.dotrino.com`, y quien exporta
|
|
478
|
+
// elige la contraseña del respaldo — o sea, puede leerlo todo. Un fallo en cualquiera de
|
|
479
|
+
// las apps no puede convertirse en sacar los secretos de la cuenta.
|
|
480
|
+
// El archivo se cierra AQUÍ: a la consola le llega ya cifrado.
|
|
481
|
+
selfVaultBackupDrawers: async ({ __origin } = {}) => {
|
|
482
|
+
soloConsola(__origin)
|
|
483
|
+
return pidaDaemon().backupDrawers()
|
|
484
|
+
},
|
|
485
|
+
selfVaultBackupExport: async ({ drawers = null, password, __origin } = {}) => {
|
|
486
|
+
soloConsola(__origin)
|
|
487
|
+
const { sealBackup } = await import('@dotrino/vault/backup')
|
|
488
|
+
const { payload, summary } = await pidaDaemon().exportBackup({ drawers })
|
|
489
|
+
return { file: await sealBackup(payload, password), summary }
|
|
490
|
+
},
|
|
491
|
+
selfVaultBackupPlan: async ({ file, password, drawers = null, map = {}, __origin } = {}) => {
|
|
492
|
+
soloConsola(__origin)
|
|
493
|
+
const { openBackup } = await import('@dotrino/vault/backup')
|
|
494
|
+
return pidaDaemon().planBackupImport(await openBackup(file, password), { drawers, map })
|
|
495
|
+
},
|
|
496
|
+
selfVaultBackupImport: async ({ file, password, drawers = null, map = {}, onConflict = null, decisions = {}, __origin } = {}) => {
|
|
497
|
+
soloConsola(__origin)
|
|
498
|
+
const { openBackup } = await import('@dotrino/vault/backup')
|
|
499
|
+
return pidaDaemon().importBackup(await openBackup(file, password), { drawers, map, onConflict, decisions })
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* ¿ES LA CONSOLA? `vault.dotrino.com`, o un origen de desarrollo (localhost / LAN privada),
|
|
505
|
+
* que es donde se prueba. Lo demás del ecosistema no puede pedir un respaldo.
|
|
506
|
+
*/
|
|
507
|
+
const CONSOLE_ORIGIN = /^(https:\/\/vault\.dotrino\.com|https?:\/\/(localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})(:\d+)?)$/
|
|
508
|
+
function soloConsola (origin) {
|
|
509
|
+
if (!CONSOLE_ORIGIN.test(String(origin || ''))) {
|
|
510
|
+
throw Object.assign(new Error('only the vault console (vault.dotrino.com) can back up or restore'), { code: 'not-the-console' })
|
|
472
511
|
}
|
|
473
512
|
}
|
|
474
513
|
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/passmanager@0.
|
|
1
|
+
Copia vendorizada de @dotrino/passmanager@0.18.4 (dotrino-passmanager/lib/src/{crypto,fields,generate,import,index,match,model,pubkey,sealed/convert,sealed/device,sealed/entry,sealed/index,sealed/keys,sealed/recovery,sealed/store,session-cache,totp,transport/protocol,transport/proxy,transport/sealed,vault/approval,vault/errors,vault/guard,vault/interface,vault/local,vault/remote,vault/responder,vault/sealed-local,vault/sealed-responder,vault/sealed,webauthn}.js).
|
|
2
2
|
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
3
|
El sobre de las contraseñas, el mismo que usan el binario y la extensión. Importa @dotrino/identity/content.
|
|
@@ -324,6 +324,10 @@ export function mergePatch (base, changes = {}) {
|
|
|
324
324
|
...(changes.totp !== undefined ? { totp: changes.totp } : {}),
|
|
325
325
|
...(changes.notes !== undefined ? { notes: changes.notes } : {}),
|
|
326
326
|
...vaciados,
|
|
327
|
+
// El contador de una passkey sube en cada firma: cambiarlo no toca la llave.
|
|
328
|
+
...(Number.isInteger(changes.signCount) && base.webauthn
|
|
329
|
+
? { webauthn: { ...base.webauthn, signCount: changes.signCount } }
|
|
330
|
+
: {}),
|
|
327
331
|
sites,
|
|
328
332
|
fields: quedan,
|
|
329
333
|
id: base.id,
|
|
@@ -78,7 +78,7 @@ export async function sealEntry ({ plain, main, passkeys = null, keys }) {
|
|
|
78
78
|
if (e.webauthn.privateKey) {
|
|
79
79
|
if (!passkeys?.cek || typeof passkeys.gen !== 'number') {
|
|
80
80
|
throw Object.assign(
|
|
81
|
-
new Error('this entry carries a passkey: it needs its own generation
|
|
81
|
+
new Error('this entry carries a passkey: it needs its own generation'),
|
|
82
82
|
{ code: 'passkeys-generation-missing' })
|
|
83
83
|
}
|
|
84
84
|
fields[PASSKEY_FIELD] = await sealV(passkeys.cek, passkeys.gen, e.webauthn.privateKey)
|
|
@@ -38,8 +38,9 @@ export class SealedStore {
|
|
|
38
38
|
* @param {object} store `{ get(k), set(k, v) }`
|
|
39
39
|
* @param {object} opts
|
|
40
40
|
* `recipients(kind)` → `[{ pub, encPub }]` quién DEBE tener envoltura. `kind` es
|
|
41
|
-
* `'main'`
|
|
42
|
-
*
|
|
41
|
+
* `'main'` o `'passkeys'` (la generación aparte de la privada de una passkey). Desde
|
|
42
|
+
* 2026-10-16 las dos son los aparatos con `passwords`: el permiso `passkeys` se fusionó
|
|
43
|
+
* en ese. Lo pone quien conoce el acta: esta pieza no la lee.
|
|
43
44
|
* `verifyAuthor({ body, author })` → ¿la firmó quien dice, y puede escribir? Lo
|
|
44
45
|
* contesta quien tiene el acta. **No es opcional**: sin ella, cualquiera que llegue
|
|
45
46
|
* al almacén podría dejar una entrada a nombre de otro, y la bóveda no puede mirar
|
|
@@ -97,13 +98,12 @@ export class SealedStore {
|
|
|
97
98
|
'the wraps must include the recovery one (#recovery), or this entry could never be handed out again')
|
|
98
99
|
}
|
|
99
100
|
const deben = (await this.recipients(kind)).filter((m) => m?.encPub).map((m) => m.pub)
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
if (kind === 'passkeys' && !deben.length) {
|
|
101
|
+
// Una passkey no se guarda para NADIE: sin aparatos que puedan abrirla y sin copia de
|
|
102
|
+
// recuperación, se perdería entera. Con la copia basta: quien reciba el permiso después
|
|
103
|
+
// la recibe al re-repartir (`rewrapAll`).
|
|
104
|
+
if (kind === 'passkeys' && !deben.length && !wraps[RECOVERY]) {
|
|
105
105
|
throw new SealedError('no-passkeys-device',
|
|
106
|
-
'
|
|
106
|
+
'nobody could ever open this passkey: no device has `passwords` and the account has no recovery copy')
|
|
107
107
|
}
|
|
108
108
|
const hay = Object.keys(wraps).filter((k) => k !== RECOVERY)
|
|
109
109
|
if (!same(deben, hay)) {
|
|
@@ -127,7 +127,7 @@ export class SealedStore {
|
|
|
127
127
|
// reparte una llave que nadie va a usar.
|
|
128
128
|
if (llevaPasskey && !tienePasskeys) {
|
|
129
129
|
throw new SealedError('passkeys-generation-missing',
|
|
130
|
-
'this entry carries a passkey: it needs its own wraps
|
|
130
|
+
'this entry carries a passkey: it needs its own wraps (its own generation)')
|
|
131
131
|
}
|
|
132
132
|
if (!llevaPasskey && tienePasskeys) {
|
|
133
133
|
throw new SealedError('passkeys-generation-unused', 'there is no passkey in this entry to wrap')
|
|
@@ -225,7 +225,7 @@ export class SealedStore {
|
|
|
225
225
|
if (passkeys) await this.#checkWraps('passkeys', passkeys.wraps)
|
|
226
226
|
if (fields[PASSKEY_FIELD] && !passkeys) {
|
|
227
227
|
throw new SealedError('passkeys-generation-missing',
|
|
228
|
-
'that patch carries a passkey: it needs its own wraps
|
|
228
|
+
'that patch carries a passkey: it needs its own wraps (its own generation)')
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
const s = await this.#state()
|
|
@@ -343,6 +343,12 @@ export class SealedStore {
|
|
|
343
343
|
return s.entries.find((e) => e.id === id)?.priv || []
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/** ¿Esa entrada lleva una passkey? La bóveda lo sabe sin abrir nada: por su huella `wa`. */
|
|
347
|
+
async hasPasskey (id) {
|
|
348
|
+
const s = await this.#state()
|
|
349
|
+
return !!s.entries.find((e) => e.id === id)?.wa
|
|
350
|
+
}
|
|
351
|
+
|
|
346
352
|
async remove (id) {
|
|
347
353
|
const s = await this.#state()
|
|
348
354
|
const antes = s.entries.length
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
// · una NEGATIVA no queda recordada como aprobación: la siguiente vuelve a preguntar
|
|
12
12
|
// · dos peticiones a la vez producen UN aviso, no dos
|
|
13
13
|
// · lo aprobado vive en memoria y muere con la bóveda: no se guarda en ninguna parte
|
|
14
|
+
// · y además CADUCA: una hora deslizante, como dotrino-env (dueño, 2026-10-16). Cada uso
|
|
15
|
+
// la renueva; una hora sin usarla y se vuelve a preguntar. Antes duraba hasta reiniciar
|
|
16
|
+
// la bóveda, o sea días, y un sí de la mañana servía para todo lo de la semana.
|
|
17
|
+
|
|
18
|
+
/** Cuánto vale un sí sin usarse: una hora, deslizante. */
|
|
19
|
+
export const GRANT_TTL_MS = 60 * 60 * 1000
|
|
14
20
|
|
|
15
21
|
export class ApprovalGate {
|
|
16
22
|
/**
|
|
@@ -22,27 +28,49 @@ export class ApprovalGate {
|
|
|
22
28
|
* aprobación es del aparato y no de cada credencial.
|
|
23
29
|
* `remember` si un sí vale para los siguientes. En `false` la puerta sigue
|
|
24
30
|
* juntando las simultáneas, pero cada petición nueva vuelve a preguntar.
|
|
31
|
+
* `ttlMs` cuánto vale un sí sin usarse (deslizante: cada uso lo renueva). Una hora.
|
|
32
|
+
* `now` el reloj, para las pruebas.
|
|
25
33
|
*/
|
|
26
|
-
constructor ({ ask, scope, remember = true } = {}) {
|
|
34
|
+
constructor ({ ask, scope, remember = true, ttlMs = GRANT_TTL_MS, now = Date.now } = {}) {
|
|
27
35
|
this.ask = ask || (async () => false)
|
|
28
36
|
this.scope = scope || (() => null)
|
|
29
37
|
this.remember = remember !== false
|
|
30
|
-
this.
|
|
38
|
+
this.ttlMs = ttlMs
|
|
39
|
+
this.now = now
|
|
40
|
+
this._granted = new Map() // llave → cuándo se usó por última vez
|
|
31
41
|
this._asking = new Map()
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
/** ¿
|
|
44
|
+
/** ¿Sigue vivo el sí de esa llave? Si sí, se renueva (deslizante); si caducó, se olvida. */
|
|
45
|
+
#fresh (key) {
|
|
46
|
+
const last = this._granted.get(key)
|
|
47
|
+
if (last == null) return false
|
|
48
|
+
if (this.now() - last > this.ttlMs) { this._granted.delete(key); return false }
|
|
49
|
+
this._granted.set(key, this.now())
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ¿Pasa? Pregunta si hace falta, y recuerda el sí si le toca recordarlo.
|
|
55
|
+
*
|
|
56
|
+
* `req.remember === false`: ESTA petición pregunta siempre, y su sí no vale para las
|
|
57
|
+
* siguientes. Es la de lo que no se deshace (borrar o pisar una passkey): que el sí a
|
|
58
|
+
* leer, o a borrar otra cosa, la dejara pasar es borrar sin que nadie lo vea
|
|
59
|
+
* (pasó el 2026-10-16: un borrado aprobado dejó pasar el siguiente sin aviso).
|
|
60
|
+
*/
|
|
35
61
|
async allow (req = {}) {
|
|
36
62
|
const key = this.scope(req)
|
|
37
|
-
|
|
63
|
+
const remember = this.remember && req.remember !== false
|
|
64
|
+
if (remember && key != null && this.#fresh(key)) return true
|
|
38
65
|
|
|
39
66
|
// Dos pestañas abriendo el mismo sitio no deben hacer sonar el teléfono dos veces.
|
|
40
|
-
|
|
67
|
+
// Lo que pregunta siempre se junta solo con la MISMA petición, no con todo el aparato.
|
|
68
|
+
const junta = key == null || !remember ? `${key ?? ''}:${req.op || ''}:${req.payload?.id || ''}` : String(key)
|
|
41
69
|
if (this._asking.has(junta)) return this._asking.get(junta)
|
|
42
70
|
|
|
43
71
|
const p = Promise.resolve(this.ask(req))
|
|
44
72
|
.then(ok => {
|
|
45
|
-
if (ok &&
|
|
73
|
+
if (ok && remember && key != null) this._granted.set(key, this.now())
|
|
46
74
|
return !!ok
|
|
47
75
|
})
|
|
48
76
|
.finally(() => this._asking.delete(junta))
|
|
@@ -52,7 +80,8 @@ export class ApprovalGate {
|
|
|
52
80
|
}
|
|
53
81
|
|
|
54
82
|
granted (key) {
|
|
55
|
-
|
|
83
|
+
const last = this._granted.get(key)
|
|
84
|
+
return last != null && this.now() - last <= this.ttlMs
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
/** Retirar lo aprobado sin apagar la bóveda. Sin argumento, todo. */
|
|
@@ -107,7 +107,11 @@ export class SealedResponder {
|
|
|
107
107
|
// `allow`, no `ask`: es el que RECUERDA el sí y junta las simultáneas. `ask` es la
|
|
108
108
|
// función cruda de preguntar, y usarla directamente hacía sonar el teléfono en cada
|
|
109
109
|
// petición — que es como se enseña a aprobar sin mirar.
|
|
110
|
-
|
|
110
|
+
//
|
|
111
|
+
// Salvo borrar o pisar una passkey: eso no se deshace, así que pregunta CADA vez y
|
|
112
|
+
// ningún sí anterior del aparato lo cubre.
|
|
113
|
+
const remember = !(await this.replacesPasskey(op, payload))
|
|
114
|
+
const ok = await this.gate.allow({ op, payload, pubkey, remember })
|
|
111
115
|
if (!ok) { anota('rejected'); return this.#send(from, pubkey, replyError(rid, CODES.NOT_APPROVED, 'not approved on the trusted device')) }
|
|
112
116
|
}
|
|
113
117
|
const result = await this.#run(op, payload, pubkey)
|
|
@@ -119,8 +123,25 @@ export class SealedResponder {
|
|
|
119
123
|
}
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
/**
|
|
127
|
+
* ¿CAMBIA O QUITA UNA PASSKEY QUE YA ESTÁ? Añadir es directo y no pregunta; reemplazarla o
|
|
128
|
+
* borrarla, sí — como las variables de `dotrino-env` (dueño, 2026-10-16). Una passkey
|
|
129
|
+
* borrada o pisada deja a alguien fuera de un sitio, y no hay forma de deshacerlo.
|
|
130
|
+
*
|
|
131
|
+
* Subir el contador al entrar NO es reemplazar: cambia la vista, no la llave.
|
|
132
|
+
*/
|
|
133
|
+
async replacesPasskey (op, payload) {
|
|
134
|
+
if (op === 'pm2.remove') return this.store.hasPasskey(payload?.id)
|
|
135
|
+
if (op === 'pm2.put') return this.store.hasPasskey(payload?.entry?.id)
|
|
136
|
+
if (op === 'pm2.patch') {
|
|
137
|
+
return !!payload?.fields?.[PASSKEY_FIELD] || (payload?.drop || []).includes(PASSKEY_FIELD)
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
122
142
|
async #needsApproval (op, payload, pubkey) {
|
|
123
|
-
|
|
143
|
+
if (!await this.needsApproval(pubkey)) return false
|
|
144
|
+
return await this.wantsPrivate(op, payload) || await this.replacesPasskey(op, payload)
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
async #run (op, payload = {}, pubkey) {
|
|
@@ -262,7 +262,11 @@ export class SealedVault {
|
|
|
262
262
|
fields: actual.fieldKeys.some((k) => !FIXED.includes(k)),
|
|
263
263
|
webauthn: actual.hasWebauthn
|
|
264
264
|
},
|
|
265
|
-
|
|
265
|
+
// El contador de la passkey sube en cada firma y vive en la vista: cambiarlo no toca
|
|
266
|
+
// la llave, así que no es reemplazarla (no pide aprobación).
|
|
267
|
+
...(actual.webauthn
|
|
268
|
+
? { webauthn: Number.isInteger(changes.signCount) ? { ...actual.webauthn, signCount: changes.signCount } : actual.webauthn }
|
|
269
|
+
: {})
|
|
266
270
|
}
|
|
267
271
|
const idx = sites ? await Promise.all(sites.map((s) => siteIndex(keys.kidx, s))) : null
|
|
268
272
|
const parche = await buildSealedPatch({
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/roadmap@0.4.
|
|
1
|
+
Copia vendorizada de @dotrino/roadmap@0.4.29 (dotrino-roadmap/src/{index,manifests}.js).
|
|
2
2
|
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
3
|
El registro de versiones rotas que viaja con cada versión. Importa @dotrino/compat/ranges.
|