@dotrino/identity 0.41.0 → 0.43.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 +8 -0
- package/src/node.js +2 -0
- package/vault/acta.js +9 -0
- package/vault/core.js +58 -3
- package/vault/remote.js +23 -0
- package/vault/vault.js +1 -0
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -251,6 +251,14 @@ export class Identity {
|
|
|
251
251
|
return this._call('revokeDelegation', { nonce })
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/**
|
|
255
|
+
* QUITA EL DISPOSITIVO: retira **todos** los certificados vigentes de esa llave.
|
|
256
|
+
* Revocar por `nonce` retira un papel; un aparato puede tener otros y seguir entrando.
|
|
257
|
+
*/
|
|
258
|
+
async revokeDevice (sub) {
|
|
259
|
+
return this._call('revokeDevice', { sub })
|
|
260
|
+
}
|
|
261
|
+
|
|
254
262
|
/** Lista las delegaciones emitidas + la lista de revocación (para el gestor de dispositivos). */
|
|
255
263
|
async listDelegations () {
|
|
256
264
|
return this._call('listDelegations')
|
package/src/node.js
CHANGED
|
@@ -160,6 +160,8 @@ export class Identity {
|
|
|
160
160
|
// Delegación de capacidad (sub-clave de dispositivo con scope/exp/revocación)
|
|
161
161
|
signDelegation (sub, scope, opts = {}) { return this._h('signDelegation', { sub, scope, ...opts }) }
|
|
162
162
|
revokeDelegation (nonce) { return this._h('revokeDelegation', { nonce }) }
|
|
163
|
+
/** Quitar el DISPOSITIVO: retira todos sus certificados vigentes, no solo uno. */
|
|
164
|
+
revokeDevice (sub) { return this._h('revokeDevice', { sub }) }
|
|
163
165
|
listDelegations () { return this._h('listDelegations') }
|
|
164
166
|
// Acta de perfil (qué llaves son del perfil y qué puede cada una; ver acta-de-perfil.md)
|
|
165
167
|
profileActa () { return this._h('profileActa') }
|
package/vault/acta.js
CHANGED
|
@@ -245,6 +245,15 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
245
245
|
// No se puede ascender un servicio a dispositivo cambiándole las capacidades: para
|
|
246
246
|
// eso hay que sacarlo y volver a admitirlo, que es un gesto visible.
|
|
247
247
|
m.caps = cleanCaps(ch.caps).filter((c) => (m.cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c))
|
|
248
|
+
// CONCEDER LIMPIA LA RENUNCIA. `effectiveCaps` resta lo renunciado, así que sin
|
|
249
|
+
// esto una renuncia sellada era IRREVERSIBLE: el master le devolvía el permiso en
|
|
250
|
+
// `caps` y la resta seguía dejándolo fuera, para siempre. La interfaz promete
|
|
251
|
+
// «puedes devolvérselo desde el Master», así que tiene que ser verdad.
|
|
252
|
+
// Se tira el registro entero y no se le recortan capacidades: va FIRMADO por el
|
|
253
|
+
// miembro, y editarlo lo dejaría sin firma válida.
|
|
254
|
+
const renunciadas = new Set()
|
|
255
|
+
for (const r of next.renounced) if (r.member === m.pub) for (const c of (r.caps || [])) renunciadas.add(c)
|
|
256
|
+
if (m.caps.some((c) => renunciadas.has(c))) next.renounced = next.renounced.filter((r) => r.member !== m.pub)
|
|
248
257
|
break
|
|
249
258
|
}
|
|
250
259
|
case 'label': {
|
package/vault/core.js
CHANGED
|
@@ -22,7 +22,7 @@ import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './
|
|
|
22
22
|
import * as Acta from './acta.js'
|
|
23
23
|
import * as Content from './content.js'
|
|
24
24
|
import { pubkeyId as pubkeyIdOf } from './capabilities.js'
|
|
25
|
-
import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin } from './remote.js'
|
|
25
|
+
import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin, requestRenounce as remoteRenounce } from './remote.js'
|
|
26
26
|
|
|
27
27
|
export const KEY_STORAGE = 'dotrino.identity.keypair'
|
|
28
28
|
export const ENC_KEY_STORAGE = 'dotrino.identity.enc-keypair'
|
|
@@ -314,6 +314,27 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
314
314
|
}
|
|
315
315
|
const saveRevocations = (o) => kv.setItem(REVOCATIONS_STORAGE, JSON.stringify(o))
|
|
316
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Retira todos los certificados vigentes de la llave `sub`, menos `keepNonce` (el recién
|
|
319
|
+
* emitido, cuando esto se llama desde `signDelegation`). Devuelve los nonces retirados.
|
|
320
|
+
* Solo toca las listas locales: avisar al aparato es cosa del mostrador de enrolamiento,
|
|
321
|
+
* que es quien puede firmar la orden de autoborrado.
|
|
322
|
+
*/
|
|
323
|
+
function revokePriorCertsFor (sub, keepNonce) {
|
|
324
|
+
const store = loadDelegations()
|
|
325
|
+
const rev = loadRevocations()
|
|
326
|
+
const now = Date.now()
|
|
327
|
+
const hit = []
|
|
328
|
+
for (const [nonce, d] of Object.entries(store)) {
|
|
329
|
+
if (nonce === keepNonce || d?.sub !== sub || d?.revokedAt) continue
|
|
330
|
+
rev[nonce] = now
|
|
331
|
+
d.revokedAt = now
|
|
332
|
+
hit.push(nonce)
|
|
333
|
+
}
|
|
334
|
+
if (hit.length) { saveRevocations(rev); saveDelegations(store) }
|
|
335
|
+
return hit
|
|
336
|
+
}
|
|
337
|
+
|
|
317
338
|
// ----- emparejamiento con el vault del usuario (este dispositivo enrolado) -----
|
|
318
339
|
// Canal de eventos 'vault' (p.ej. el código a tipear durante el emparejamiento).
|
|
319
340
|
const vaultListeners = new Set()
|
|
@@ -1043,7 +1064,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1043
1064
|
// de dispositivo `sub`, acotado por `scope` y `exp`, revocable por `nonce`.
|
|
1044
1065
|
// Es la ÚNICA forma en que la autoridad sale de la clave maestra, y va limitada.
|
|
1045
1066
|
|
|
1046
|
-
async signDelegation ({ sub, scope, ttlMs, exp, nonce, label }) {
|
|
1067
|
+
async signDelegation ({ sub, scope, ttlMs, exp, nonce, label, supersede }) {
|
|
1047
1068
|
if (!sub || typeof sub !== 'string') throw new Error('sub (device pubkey) required')
|
|
1048
1069
|
if (!scope || (typeof scope !== 'string' && !Array.isArray(scope))) throw new Error('scope required')
|
|
1049
1070
|
const iat = Date.now()
|
|
@@ -1054,9 +1075,27 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1054
1075
|
const store = loadDelegations()
|
|
1055
1076
|
store[cert.nonce] = { nonce: cert.nonce, sub, scope, iat, exp: cappedExp, label: typeof label === 'string' ? label.slice(0, 60) : '' }
|
|
1056
1077
|
saveDelegations(store)
|
|
1078
|
+
// UNA LLAVE, UN CERTIFICADO VIGENTE. Renovar emitía uno nuevo y dejaba vivo el
|
|
1079
|
+
// anterior: el mismo aparato salía dos veces en la lista (parecían dos máquinas) y,
|
|
1080
|
+
// peor, «quitar el dispositivo» revocaba UN cert y el aparato seguía entrando con el
|
|
1081
|
+
// otro — a veces justo con el que llevaba `vault:admin`. Al firmar, los certs previos
|
|
1082
|
+
// de esa misma `sub` se retiran. Silencioso a propósito: NO se emite el aviso de
|
|
1083
|
+
// autoborrado (eso solo lo dispara una revocación de verdad, desde el mostrador).
|
|
1084
|
+
if (supersede !== false) revokePriorCertsFor(sub, cert.nonce)
|
|
1057
1085
|
return { cert }
|
|
1058
1086
|
},
|
|
1059
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* Retira TODOS los certificados vigentes de un dispositivo (por su llave `sub`).
|
|
1090
|
+
* Es lo que significa «quitar el dispositivo»: revocar por `nonce` retira un papel,
|
|
1091
|
+
* no al aparato, que puede tener otros.
|
|
1092
|
+
*/
|
|
1093
|
+
async revokeDevice ({ sub }) {
|
|
1094
|
+
if (!sub || typeof sub !== 'string') throw new Error('sub (device pubkey) required')
|
|
1095
|
+
const nonces = revokePriorCertsFor(sub, null)
|
|
1096
|
+
return { ok: true, nonces, revokedAt: Date.now() }
|
|
1097
|
+
},
|
|
1098
|
+
|
|
1060
1099
|
async revokeDelegation ({ nonce }) {
|
|
1061
1100
|
if (!nonce || typeof nonce !== 'string') throw new Error('nonce required')
|
|
1062
1101
|
const rev = loadRevocations()
|
|
@@ -1067,10 +1106,16 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1067
1106
|
return { ok: true, revokedAt: rev[nonce] }
|
|
1068
1107
|
},
|
|
1069
1108
|
|
|
1109
|
+
// `issued` = lo que HOY sirve para entrar. Antes devolvía el almacén entero, revocados
|
|
1110
|
+
// incluidos (revocar solo estampa `revokedAt`), así que la consola seguía pintando como
|
|
1111
|
+
// activo un cert ya retirado: pulsabas «quitar» y la fila no se movía. Los caducados ya
|
|
1112
|
+
// los poda `loadDelegations`. El histórico retirado va aparte, en `revokedCerts`.
|
|
1070
1113
|
async listDelegations () {
|
|
1071
1114
|
const store = loadDelegations(); const rev = loadRevocations()
|
|
1115
|
+
const all = Object.values(store).sort((a, b) => (b.iat || 0) - (a.iat || 0))
|
|
1072
1116
|
return {
|
|
1073
|
-
issued:
|
|
1117
|
+
issued: all.filter((d) => !d.revokedAt && !rev[d.nonce]),
|
|
1118
|
+
revokedCerts: all.filter((d) => d.revokedAt || rev[d.nonce]),
|
|
1074
1119
|
revoked: Object.keys(rev).map(nonce => ({ nonce, revokedAt: rev[nonce] }))
|
|
1075
1120
|
}
|
|
1076
1121
|
},
|
|
@@ -1270,6 +1315,16 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1270
1315
|
pend.push(record)
|
|
1271
1316
|
saveRenounces(pend)
|
|
1272
1317
|
emitVault({ phase: 'renounced', caps: record.caps })
|
|
1318
|
+
// Y se le MANDA a la bóveda para que la selle en el acta. Sin esto la renuncia solo
|
|
1319
|
+
// valía aquí dentro: la bóveda seguía teniendo escrito que este aparato puede firmar
|
|
1320
|
+
// y le seguía aceptando peticiones. Best-effort: si la bóveda está apagada, la
|
|
1321
|
+
// renuncia ya está en pie localmente (para eso no necesita a nadie) y se reintenta
|
|
1322
|
+
// la próxima vez que se renuncie.
|
|
1323
|
+
const v = loadVaultCert(); const dev = loadVaultDevice()
|
|
1324
|
+
if (v?.cert && dev) {
|
|
1325
|
+
remoteRenounce({ master: v.master, proxy: v.proxy, device: dev, cert: v.cert, record })
|
|
1326
|
+
.catch(() => {})
|
|
1327
|
+
}
|
|
1273
1328
|
// Si además soy el master, la absorbo ya en el acta.
|
|
1274
1329
|
if (amMaster()) { try { await sealChanges([{ op: 'renounce', record }]) } catch (_) {} }
|
|
1275
1330
|
return { ok: true, record, caps: Acta.effectiveCaps(loadActa(), publickeyJwkStr, loadRenounces()) }
|
package/vault/remote.js
CHANGED
|
@@ -32,6 +32,9 @@ const MSG = {
|
|
|
32
32
|
ADMIN: 'vault.admin',
|
|
33
33
|
ADMIN_RESULT: 'vault.admin.result',
|
|
34
34
|
ADMIN_EVENT: 'vault.admin.event',
|
|
35
|
+
// Renuncia: el miembro se quita capacidades a sí mismo y la bóveda la sella en el acta.
|
|
36
|
+
RENOUNCE: 'vault.renounce',
|
|
37
|
+
RENOUNCE_RESULT: 'vault.renounce.result',
|
|
35
38
|
ERROR: 'vault.error'
|
|
36
39
|
}
|
|
37
40
|
export { MSG as VAULT_MSG }
|
|
@@ -275,6 +278,26 @@ export async function requestRenew ({ master, proxy, device, cert, onRevoked } =
|
|
|
275
278
|
return { cert: res.cert }
|
|
276
279
|
}
|
|
277
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Manda a la bóveda la RENUNCIA de este dispositivo para que la selle en el acta.
|
|
283
|
+
*
|
|
284
|
+
* Sin esto, renunciar solo valía en el propio aparato: la bóveda seguía teniendo escrito
|
|
285
|
+
* que podía firmar, le seguía aceptando peticiones, y cualquiera que mirara el acta lo
|
|
286
|
+
* seguía viendo como firmante. Una renuncia que no llega al acta no es oponible a nadie —
|
|
287
|
+
* y en el caso que la justifica (te robaron el aparato) su registro local no vale nada,
|
|
288
|
+
* porque lo borra quien lo tenga en la mano.
|
|
289
|
+
*
|
|
290
|
+
* El `record` va FIRMADO por el propio miembro y solo puede QUITAR, así que la bóveda no
|
|
291
|
+
* necesita comprobar ningún certificado para honrarlo: le basta la firma.
|
|
292
|
+
*/
|
|
293
|
+
export async function requestRenounce ({ master, proxy, device, cert, record, onRevoked } = {}) {
|
|
294
|
+
const res = await vaultRpc({
|
|
295
|
+
master, proxy, device, cert, onRevoked,
|
|
296
|
+
sendType: MSG.RENOUNCE, okType: MSG.RENOUNCE_RESULT, data: { op: 'renounce', record }
|
|
297
|
+
})
|
|
298
|
+
return { ok: !!res.ok, seq: res.seq ?? null }
|
|
299
|
+
}
|
|
300
|
+
|
|
278
301
|
/**
|
|
279
302
|
* CONSOLA REMOTA (`dotrino-vault/docs/consola-remota.md`): administrar el perfil desde
|
|
280
303
|
* este dispositivo, sin ir al PC. Requiere un cert con scope `vault:admin`, que **no se
|
package/vault/vault.js
CHANGED
|
@@ -118,6 +118,7 @@ import { pubkeyId } from './capabilities.js'
|
|
|
118
118
|
signDelegation: (sub, scope, opts) => handlers.signDelegation({ sub, scope, ...(opts || {}) }),
|
|
119
119
|
listDelegations: () => handlers.listDelegations({}),
|
|
120
120
|
revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce }),
|
|
121
|
+
revokeDevice: (sub) => handlers.revokeDevice({ sub }),
|
|
121
122
|
admitMember: (m) => handlers.admitMember(m),
|
|
122
123
|
profileActa: () => handlers.profileActa({}),
|
|
123
124
|
// Camino A (`mode: 'adopt'`): la bóveda se queda con la cuenta que trae el aparato,
|