@dotrino/vaultd 0.95.0 → 0.96.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/service.js +45 -8
- package/package.json +2 -2
- package/src/vault.js +14 -20
package/lib/src/service.js
CHANGED
|
@@ -668,7 +668,13 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
|
|
|
668
668
|
// sería aceptar un valor en claro dentro de un paquete que se supone todo sellado, o
|
|
669
669
|
// sea la puerta para colar uno.
|
|
670
670
|
if (typeof e.v === 'string' && !e.e) {
|
|
671
|
-
|
|
671
|
+
// TAMPOCO SE ARREGLA ESPERANDO, igual que `no-wrapping`. El valor está guardado en
|
|
672
|
+
// claro en la bóveda, de antes de que todo viajara sellado, y solo vuelve a
|
|
673
|
+
// escribirlo quien puede abrirla. Reintentar no lo sella: solo vuelve a timbrar.
|
|
674
|
+
throw Object.assign(
|
|
675
|
+
new Error(`${key} arrived in the clear: since 2026-09-02 every variable travels sealed` +
|
|
676
|
+
` — rewrite it in the vault (dotrino-vault unlock, then secret set ${ident?.ns || '<ns>'} ${key} …) or remove it`),
|
|
677
|
+
{ code: 'plaintext-var' })
|
|
672
678
|
}
|
|
673
679
|
// `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
|
|
674
680
|
const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
|
|
@@ -1026,21 +1032,52 @@ function fingerprintOf (secrets) {
|
|
|
1026
1032
|
* Se decide por `e.code` donde lo hay: emparejar un mensaje por su texto se rompe en
|
|
1027
1033
|
* silencio en cuanto alguien lo reescribe (ver `dotrino-error-strings`).
|
|
1028
1034
|
*/
|
|
1035
|
+
const FINAL_CODES = new Set([
|
|
1036
|
+
'no-wrapping', // falta la envoltura, y solo la fabrica el dueño abriendo la bóveda
|
|
1037
|
+
'plaintext-var' // el valor está en claro en la bóveda: hay que volver a escribirlo
|
|
1038
|
+
])
|
|
1039
|
+
|
|
1029
1040
|
export function isFinal (e) {
|
|
1030
|
-
if (e?.code
|
|
1041
|
+
if (FINAL_CODES.has(e?.code)) return true
|
|
1031
1042
|
return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
|
|
1032
1043
|
}
|
|
1033
1044
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1045
|
+
/**
|
|
1046
|
+
* CUÁNTO ESPERAR ANTES DE VOLVER A PEDIR, y por qué no siempre es el backoff.
|
|
1047
|
+
*
|
|
1048
|
+
* Un cajón con aprobación hace sonar el teléfono en CADA petición. Con el reintento a
|
|
1049
|
+
* cinco segundos, un fallo posterior a la aprobación volvía a pedir enseguida y el dueño
|
|
1050
|
+
* veía dos avisos del mismo cajón: aprobaba el primero para nada. La regla es que un
|
|
1051
|
+
* reintento no llegue nunca antes de que venza el pedido que ya está sonando.
|
|
1052
|
+
*
|
|
1053
|
+
* La otra mitad importa igual: si el intento se pasó la ventana entera esperando a que
|
|
1054
|
+
* alguien aprobara, no se le suma otra ventana — ahí ya no hay nada sonando, y hacerle
|
|
1055
|
+
* esperar cinco minutos más sería un cuelgue.
|
|
1056
|
+
*
|
|
1057
|
+
* @param {number} backoffMs lo que tocaría por el backoff normal
|
|
1058
|
+
* @param {number} rangAt cuándo timbró este intento (0 si no timbró)
|
|
1059
|
+
* @param {number} approvalTimeoutMs lo que dura un pedido de aprobación
|
|
1060
|
+
* @param {number} now
|
|
1061
|
+
*/
|
|
1062
|
+
export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, now = Date.now()) {
|
|
1063
|
+
if (!rangAt) return backoffMs
|
|
1064
|
+
return Math.max(backoffMs, approvalTimeoutMs - (now - rangAt))
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, onRetry, onPending, onCert, publicOnly = false } = {}) {
|
|
1068
|
+
let backoff = retryMs
|
|
1069
|
+
let rangAt = 0 // cuándo timbró ESTE intento; 0 = no hubo aprobación de por medio
|
|
1070
|
+
const notePending = (info) => { rangAt = Date.now(); onPending?.(info) }
|
|
1036
1071
|
for (;;) {
|
|
1037
1072
|
try {
|
|
1038
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert, publicOnly })
|
|
1073
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, approvalTimeoutMs, onPending: notePending, onCert, publicOnly })
|
|
1039
1074
|
} catch (e) {
|
|
1040
1075
|
if (isFinal(e)) throw e
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1076
|
+
const wait = retryDelay(backoff, rangAt, approvalTimeoutMs)
|
|
1077
|
+
rangAt = 0
|
|
1078
|
+
try { onRetry?.(e, wait) } catch (_) {}
|
|
1079
|
+
await new Promise((r) => setTimeout(r, wait))
|
|
1080
|
+
backoff = Math.min(maxRetryMs, Math.round(backoff * 1.6))
|
|
1044
1081
|
}
|
|
1045
1082
|
}
|
|
1046
1083
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.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": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@dotrino/identity": "0.76.0",
|
|
24
|
-
"@dotrino/passmanager": "^0.
|
|
24
|
+
"@dotrino/passmanager": "^0.10.1",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
|
27
27
|
},
|
package/src/vault.js
CHANGED
|
@@ -19,6 +19,8 @@ import * as Acta from '@dotrino/identity/acta'
|
|
|
19
19
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
20
20
|
import { createAdminDesk, authorBody } from '../lib/src/admin.js'
|
|
21
21
|
import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
22
|
+
// El sobre del gestor, el mismo que usan la bóveda-en-pestaña y la extensión.
|
|
23
|
+
import { identitySealing } from '@dotrino/passmanager'
|
|
22
24
|
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
23
25
|
import { startSealersPublisher } from './sealers.js'
|
|
24
26
|
import { assertKeyOwnsDir } from './keyowner.js'
|
|
@@ -1200,30 +1202,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1200
1202
|
* EL SELLADO del gestor: la misma cripto de los sobres del ecosistema
|
|
1201
1203
|
* (`@dotrino/identity/content`), atada a la llave de cifrado de esta bóveda.
|
|
1202
1204
|
*
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1205
|
+
* Lo arma el PILAR (`identitySealing`), que es la MISMA pieza que usan las otras dos
|
|
1206
|
+
* bóvedas —la de la pestaña y la de dentro de la extensión—. Estaba escrito a mano aquí,
|
|
1207
|
+
* y son las tres puntas del mismo sobre: tres copias son dos que se pueden quedar atrás,
|
|
1208
|
+
* y ese fallo no hace ruido — la petición sale, al otro lado «no es para mí», y desde
|
|
1209
|
+
* fuera se ve como que nadie respondió.
|
|
1210
|
+
*
|
|
1211
|
+
* NO se pone `requireSealed` en el cliente, y no es un olvido: el cliente del vault es
|
|
1212
|
+
* UNO y lo comparte todo, y el protocolo de la CA viaja en claro a propósito (un
|
|
1213
|
+
* enrolamiento es público hasta que hay cert). Lo que corta aquí es `VaultResponder`,
|
|
1214
|
+
* que tira lo que llegue sin la marca `sealed`. Por eso `isSealed` mira la marca del
|
|
1215
|
+
* gestor y no otra.
|
|
1207
1216
|
*
|
|
1208
1217
|
* Sin esto la bóveda RECIBÍA los sobres y los tiraba —el cliente no tenía con qué
|
|
1209
1218
|
* abrirlos— y el aparato se quedaba esperando sin que nada lo dijera.
|
|
1210
1219
|
*/
|
|
1211
|
-
const passwordSealing =
|
|
1212
|
-
async seal (msg, peerEncPub) {
|
|
1213
|
-
if (!peerEncPub) throw Object.assign(new Error('no encryption key'), { code: 'unsealed' })
|
|
1214
|
-
return {
|
|
1215
|
-
app: 'passmanager',
|
|
1216
|
-
// Destinatarios como OBJETOS y no como llaves sueltas: `encrypt` expande cada
|
|
1217
|
-
// uno a todos los aparatos de esa persona, y una cadena a secas se le cae por
|
|
1218
|
-
// el `continue` sin envolver nada — el sobre salía vacío y sin error.
|
|
1219
|
-
sealed: await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg)),
|
|
1220
|
-
from: await identity.getEncryptionPubkey()
|
|
1221
|
-
}
|
|
1222
|
-
},
|
|
1223
|
-
// `decrypt` devuelve `{ plaintext }`, no la cadena.
|
|
1224
|
-
async open (env) { return JSON.parse((await identity.decrypt(env.from, null, env.sealed)).plaintext) },
|
|
1225
|
-
isSealed: (m) => !!m && m.app === 'passmanager' && !!m.sealed
|
|
1226
|
-
}
|
|
1220
|
+
const passwordSealing = identitySealing(identity)
|
|
1227
1221
|
const startPasswordDesk = async () => {
|
|
1228
1222
|
if (passwords || !passwordDevices().length) return
|
|
1229
1223
|
const { createPasswordDesk } = await import('./passwords.js')
|