@dotrino/identity 0.28.0 → 0.30.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/vault/acta.js +69 -7
- package/vault/core.js +57 -4
- package/vault/remote.js +4 -3
- package/vault/vendor/vault/enroll.js +16 -2
package/package.json
CHANGED
package/vault/acta.js
CHANGED
|
@@ -32,10 +32,37 @@ import { signWithDevice, verifyDeviceSig, pubkeyId } from './capabilities.js'
|
|
|
32
32
|
|
|
33
33
|
export const ACTA_V = 1
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master.
|
|
37
|
+
*
|
|
38
|
+
* `secrets` es distinta de las otras tres: solo la pueden tener los miembros con **CN**
|
|
39
|
+
* (los servicios), y lo que abre es únicamente el cajón de SU nombre. Ver `cn` abajo.
|
|
40
|
+
*/
|
|
41
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets'])
|
|
42
|
+
|
|
43
|
+
/** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
|
|
44
|
+
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read'])
|
|
45
|
+
|
|
46
|
+
/** Capacidades de un SERVICIO (con CN): solo su propio cajón de secretos. */
|
|
47
|
+
export const SERVICE_CAPS = Object.freeze(['secrets'])
|
|
48
|
+
|
|
49
|
+
/** Un CN válido: minúsculas, números y guiones (igual que el namespace de secretos). */
|
|
50
|
+
export const isValidCn = (cn) => typeof cn === 'string' && /^[a-z0-9-]{1,32}$/.test(cn)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scope del cert que corresponde a cada capacidad. `secrets` necesita el CN para
|
|
54
|
+
* completarse: el miembro `proxy` obtiene `vault:secrets:proxy` y nada más — no existe un
|
|
55
|
+
* scope de secretos «de todos».
|
|
56
|
+
*/
|
|
57
|
+
export function capScope (cap, cn = null) {
|
|
58
|
+
if (cap === 'sign') return 'vault:sign'
|
|
59
|
+
if (cap === 'store') return 'vault:store'
|
|
60
|
+
if (cap === 'read') return 'vault:read'
|
|
61
|
+
if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
37
64
|
|
|
38
|
-
/**
|
|
65
|
+
/** Compat: el mapa directo, para las tres capacidades de dispositivo. */
|
|
39
66
|
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read' })
|
|
40
67
|
|
|
41
68
|
const enc = (s) => new TextEncoder().encode(s)
|
|
@@ -77,7 +104,7 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
|
|
|
77
104
|
sealedBy: pub,
|
|
78
105
|
seq: 1,
|
|
79
106
|
prev: null,
|
|
80
|
-
members: [{ pub, encPub, label: String(label || '').slice(0, 60), caps: [...
|
|
107
|
+
members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...DEVICE_CAPS], addedAt: now, cert: null }],
|
|
81
108
|
revoked: [],
|
|
82
109
|
renounced: [],
|
|
83
110
|
// Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
|
|
@@ -98,6 +125,15 @@ export function checkShape (acta) {
|
|
|
98
125
|
for (const m of acta.members) {
|
|
99
126
|
if (!isPub(m?.pub) || !Array.isArray(m?.caps)) return 'member'
|
|
100
127
|
if (m.caps.some((c) => !CAPS.includes(c))) return 'cap-desconocida'
|
|
128
|
+
// El CN es la frontera: un SERVICIO solo puede abrir su propio cajón, y un
|
|
129
|
+
// DISPOSITIVO no tiene cajón que abrir. Que no se pueda escribir un acta que
|
|
130
|
+
// mezcle las dos cosas es lo que hace que el límite sea real y no una costumbre.
|
|
131
|
+
if (m.cn != null) {
|
|
132
|
+
if (!isValidCn(m.cn)) return 'cn-invalido'
|
|
133
|
+
if (m.caps.some((c) => !SERVICE_CAPS.includes(c))) return 'servicio-con-capacidades-de-dispositivo'
|
|
134
|
+
} else if (m.caps.includes('secrets')) {
|
|
135
|
+
return 'secretos-sin-cn'
|
|
136
|
+
}
|
|
101
137
|
}
|
|
102
138
|
if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'miembro-duplicado'
|
|
103
139
|
if (!acta.members.some((m) => m.pub === acta.sealer)) return 'sealer-no-es-miembro'
|
|
@@ -166,11 +202,15 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
166
202
|
const m = ch.member
|
|
167
203
|
if (!isPub(m?.pub)) throw new Error('admit: falta la pubkey del miembro')
|
|
168
204
|
if (find(m.pub)) throw new Error('admit: ese miembro ya está en el acta')
|
|
205
|
+
const cn = m.cn != null ? String(m.cn) : null
|
|
206
|
+
if (cn !== null && !isValidCn(cn)) throw new Error('admit: CN inválido (minúsculas, números y guiones)')
|
|
169
207
|
next.members.push({
|
|
170
208
|
pub: m.pub,
|
|
171
209
|
encPub: m.encPub || null,
|
|
172
210
|
label: String(m.label || '').slice(0, 60),
|
|
173
|
-
|
|
211
|
+
cn,
|
|
212
|
+
// Un servicio (con CN) solo puede tener `secrets`; un dispositivo, las otras tres.
|
|
213
|
+
caps: cleanCaps(m.caps).filter((c) => (cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c)),
|
|
174
214
|
addedAt: now,
|
|
175
215
|
cert: m.cert || null,
|
|
176
216
|
// Puente con la identidad que este miembro traía de antes (ver makeContinuity).
|
|
@@ -181,7 +221,9 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
181
221
|
case 'caps': {
|
|
182
222
|
const m = find(ch.pub)
|
|
183
223
|
if (!m) throw new Error('caps: ese miembro no está en el acta')
|
|
184
|
-
|
|
224
|
+
// No se puede ascender un servicio a dispositivo cambiándole las capacidades: para
|
|
225
|
+
// eso hay que sacarlo y volver a admitirlo, que es un gesto visible.
|
|
226
|
+
m.caps = cleanCaps(ch.caps).filter((c) => (m.cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c))
|
|
185
227
|
break
|
|
186
228
|
}
|
|
187
229
|
case 'remove': {
|
|
@@ -284,6 +326,26 @@ export function effectiveCaps (acta, pub, extraRenounces = []) {
|
|
|
284
326
|
return m.caps.filter((c) => !quitadas.has(c))
|
|
285
327
|
}
|
|
286
328
|
|
|
329
|
+
/**
|
|
330
|
+
* ¿Puede este miembro leer el cajón de secretos `ns`? Solo si su CN es exactamente ése.
|
|
331
|
+
* Es la frontera que pediste: la llave del proxy no ve nada más que lo del proxy.
|
|
332
|
+
*/
|
|
333
|
+
export function memberCanReadSecrets (acta, pub, ns) {
|
|
334
|
+
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
335
|
+
if (!m || !m.cn) return false
|
|
336
|
+
return m.cn === ns && effectiveCaps(acta, pub).includes('secrets')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Los scopes de cert que le corresponden a un miembro según el acta. */
|
|
340
|
+
export function memberScopes (acta, pub, extraRenounces = []) {
|
|
341
|
+
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
342
|
+
if (!m) return []
|
|
343
|
+
return effectiveCaps(acta, pub, extraRenounces).map((c) => capScope(c, m.cn)).filter(Boolean)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** ¿Es un servicio (tiene CN) o un dispositivo del usuario? */
|
|
347
|
+
export const isService = (acta, pub) => !!(acta?.members || []).find((x) => x.pub === pub)?.cn
|
|
348
|
+
|
|
287
349
|
/** ¿Puede este miembro hacer `cap` según el acta? (con el cert se cruza aparte: cert ∩ acta). */
|
|
288
350
|
export function memberCan (acta, pub, cap, extraRenounces = []) {
|
|
289
351
|
return effectiveCaps(acta, pub, extraRenounces).includes(cap)
|
|
@@ -362,5 +424,5 @@ export default {
|
|
|
362
424
|
ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, isHandover,
|
|
363
425
|
sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
|
|
364
426
|
makeContinuity, verifyContinuity,
|
|
365
|
-
effectiveCaps, memberCan, canAdopt
|
|
427
|
+
effectiveCaps, memberCan, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt
|
|
366
428
|
}
|
package/vault/core.js
CHANGED
|
@@ -32,6 +32,7 @@ export const REVOCATIONS_STORAGE = 'dotrino.identity.revocations' // nonces re
|
|
|
32
32
|
export const VAULT_DEVICE_STORAGE = 'dotrino.identity.vault.device' // sub-clave D de ESTE dispositivo (custodia en el iframe)
|
|
33
33
|
export const VAULT_CERT_STORAGE = 'dotrino.identity.vault.cert' // { cert, master, proxy, deviceId, pairedAt }
|
|
34
34
|
export const ACTA_STORAGE = 'dotrino.identity.acta' // acta de perfil vigente (quién es del perfil y qué puede)
|
|
35
|
+
export const ACTA_HISTORY_STORAGE = 'dotrino.identity.acta.history' // últimas actas selladas (§1.3)
|
|
35
36
|
export const RENOUNCE_STORAGE = 'dotrino.identity.renounced' // renuncias propias aún no absorbidas por el master
|
|
36
37
|
// Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
|
|
37
38
|
// namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
|
|
@@ -345,6 +346,20 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
345
346
|
// `acta.js`, que es puro y está probado aparte.
|
|
346
347
|
const loadActa = () => { try { return JSON.parse(kv.getItem(ACTA_STORAGE) || 'null') } catch (_) { return null } }
|
|
347
348
|
const saveActa = (a) => kv.setItem(ACTA_STORAGE, JSON.stringify(a))
|
|
349
|
+
// VENTANA DE RETENCIÓN (§1.3): el master conserva las últimas actas para que un miembro
|
|
350
|
+
// que estuvo apagado pueda comprobar el encadenamiento al volver. Un tercero no las
|
|
351
|
+
// necesita —le basta el snapshot actual—, pero entre miembros hay que poder verificar
|
|
352
|
+
// que la nueva desciende de la que uno tenía. Más viejo que la ventana ⇒ re-admitirse.
|
|
353
|
+
const ACTA_WINDOW = 50
|
|
354
|
+
const loadHistory = () => { try { return JSON.parse(kv.getItem(ACTA_HISTORY_STORAGE) || '[]') || [] } catch (_) { return [] } }
|
|
355
|
+
const pushHistory = (acta) => {
|
|
356
|
+
if (!acta) return
|
|
357
|
+
const h = loadHistory().filter((a) => a.seq !== acta.seq)
|
|
358
|
+
h.push(acta)
|
|
359
|
+
h.sort((a, b) => a.seq - b.seq)
|
|
360
|
+
kv.setItem(ACTA_HISTORY_STORAGE, JSON.stringify(h.slice(-ACTA_WINDOW)))
|
|
361
|
+
}
|
|
362
|
+
|
|
348
363
|
const loadRenounces = () => { try { return JSON.parse(kv.getItem(RENOUNCE_STORAGE) || '[]') || [] } catch (_) { return [] } }
|
|
349
364
|
const saveRenounces = (l) => kv.setItem(RENOUNCE_STORAGE, JSON.stringify(l))
|
|
350
365
|
|
|
@@ -363,6 +378,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
363
378
|
if (!acta) throw new Error('este perfil todavía no tiene acta')
|
|
364
379
|
const next = await Acta.applyChanges(acta, changes, { by: publickeyJwkStr })
|
|
365
380
|
const sealed = await seal(next)
|
|
381
|
+
pushHistory(acta) // la que deja de ser vigente entra en la ventana de retención
|
|
366
382
|
saveActa(sealed)
|
|
367
383
|
emitVault({ phase: 'acta', seq: sealed.seq, sealer: sealed.sealer })
|
|
368
384
|
return sealed
|
|
@@ -447,6 +463,20 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
447
463
|
* Adopta un acta que llega de otro miembro, si gana según §2.4.1 (seq mayor que encadene,
|
|
448
464
|
* o el traspaso a igual seq). Nunca retrocede.
|
|
449
465
|
*/
|
|
466
|
+
/**
|
|
467
|
+
* Adopta una CADENA de actas, una a una. Es lo que permite ponerse al día tras estar
|
|
468
|
+
* apagado sin bajar la guardia: cada eslabón se comprueba contra el anterior en vez de
|
|
469
|
+
* aceptar un salto a ciegas.
|
|
470
|
+
*/
|
|
471
|
+
async function adoptChain (chain) {
|
|
472
|
+
let last = null
|
|
473
|
+
for (const a of [...(chain || [])].sort((x, y) => x.seq - y.seq)) {
|
|
474
|
+
const r = await adoptActa(a)
|
|
475
|
+
if (r.adopted) last = r
|
|
476
|
+
}
|
|
477
|
+
return last || { adopted: false, reason: 'nada-que-adoptar', seq: loadActa()?.seq ?? null }
|
|
478
|
+
}
|
|
479
|
+
|
|
450
480
|
async function adoptActa (candidate) {
|
|
451
481
|
const current = loadActa()
|
|
452
482
|
const r = await Acta.canAdopt({ candidate, current })
|
|
@@ -1041,6 +1071,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1041
1071
|
pub: m.pub,
|
|
1042
1072
|
id: await Acta.memberId(m.pub),
|
|
1043
1073
|
label: m.label || '',
|
|
1074
|
+
cn: m.cn || null,
|
|
1075
|
+
isService: !!m.cn,
|
|
1044
1076
|
caps: Acta.effectiveCaps(acta, m.pub, pend),
|
|
1045
1077
|
addedAt: m.addedAt || null,
|
|
1046
1078
|
isMe: m.pub === publickeyJwkStr,
|
|
@@ -1069,8 +1101,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1069
1101
|
* Admite un miembro (solo el master). El cert lo emite quien llama, antes o después.
|
|
1070
1102
|
* `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).
|
|
1071
1103
|
*/
|
|
1072
|
-
async admitMember ({ pub, encPub = null, label = '',
|
|
1073
|
-
|
|
1104
|
+
async admitMember ({ pub, encPub = null, label = '', cn = null, caps = null, cert = null, continuity = null } = {}) {
|
|
1105
|
+
// `cn`: si viene, este miembro es un SERVICIO y su único permiso es abrir el cajón de
|
|
1106
|
+
// secretos de ESE nombre — no ve nada más del usuario. Sin `cn` es un dispositivo.
|
|
1107
|
+
const finales = caps || (cn ? ['secrets'] : ['store', 'read'])
|
|
1108
|
+
const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity } }])
|
|
1074
1109
|
// Que entre al perfil incluye poder LEER lo que ya hay: se le envuelve la clave
|
|
1075
1110
|
// vigente (no hace falta rotar; rotar es para cuando alguien SALE).
|
|
1076
1111
|
let wrapped = false
|
|
@@ -1162,9 +1197,23 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1162
1197
|
return { ok: true, ...r }
|
|
1163
1198
|
},
|
|
1164
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* Las actas que este master conserva desde `sinceSeq` (sin incluirla), para que un
|
|
1202
|
+
* miembro que volvió pueda comprobar el encadenamiento. Vacío si se salió de la ventana.
|
|
1203
|
+
*/
|
|
1204
|
+
async actaHistory ({ sinceSeq = 0 } = {}) {
|
|
1205
|
+
const cur = loadActa()
|
|
1206
|
+
const hist = loadHistory().filter((a) => a.seq > sinceSeq)
|
|
1207
|
+
const all = cur && cur.seq > sinceSeq ? [...hist, cur] : hist
|
|
1208
|
+
return { chain: all.sort((a, b) => a.seq - b.seq), window: ACTA_WINDOW }
|
|
1209
|
+
},
|
|
1210
|
+
|
|
1165
1211
|
/** Adopta un acta que llega de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
|
|
1166
1212
|
async adoptActa ({ acta } = {}) { return adoptActa(acta) },
|
|
1167
1213
|
|
|
1214
|
+
/** Adopta una cadena completa (para ponerse al día tras estar apagado). */
|
|
1215
|
+
async adoptActaChain ({ chain } = {}) { return adoptChain(chain) },
|
|
1216
|
+
|
|
1168
1217
|
/** Une este dispositivo al perfil de otra bóveda (solo si aquí no hay nada que perder). */
|
|
1169
1218
|
async joinProfile ({ acta } = {}) { return joinProfile(acta) },
|
|
1170
1219
|
|
|
@@ -1253,9 +1302,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1253
1302
|
if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
|
|
1254
1303
|
maybeRenewVaultCert()
|
|
1255
1304
|
try {
|
|
1256
|
-
const res = await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, onRevoked: wipeVaultLink })
|
|
1305
|
+
const res = await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, sinceSeq: loadActa()?.seq ?? 0, onRevoked: wipeVaultLink })
|
|
1257
1306
|
// El acta viaja con la lista: así los cambios de política llegan sin canal aparte.
|
|
1258
|
-
|
|
1307
|
+
// Si estuve apagado, viene la CADENA y se adopta eslabón a eslabón (§1.3).
|
|
1308
|
+
try {
|
|
1309
|
+
if (res.chain?.length && res.chain[0].profileId === loadActa()?.profileId) await adoptChain(res.chain)
|
|
1310
|
+
else if (res.acta) await (res.acta.profileId === loadActa()?.profileId ? adoptActa(res.acta) : joinProfile(res.acta))
|
|
1311
|
+
} catch (_) {}
|
|
1259
1312
|
return res
|
|
1260
1313
|
} catch (e) { return handleVaultError(e) }
|
|
1261
1314
|
},
|
package/vault/remote.js
CHANGED
|
@@ -166,9 +166,10 @@ export async function requestStore ({ master, proxy, device, cert, method, args,
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/** Lista (solo lectura) los dispositivos enrolados en tu vault. */
|
|
169
|
-
export async function requestDevices ({ master, proxy, device, cert, onRevoked } = {}) {
|
|
170
|
-
const
|
|
171
|
-
|
|
169
|
+
export async function requestDevices ({ master, proxy, device, cert, sinceSeq, onRevoked } = {}) {
|
|
170
|
+
const data = typeof sinceSeq === 'number' ? { op: 'devices', sinceSeq } : { op: 'devices' }
|
|
171
|
+
const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.devices', okType: 'vault.devices.result', data })
|
|
172
|
+
return { devices: res.devices || [], revoked: res.revoked || [], acta: res.acta || null, chain: res.chain || null }
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
/**
|
|
@@ -50,6 +50,19 @@ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read
|
|
|
50
50
|
export const scopeToCaps = (scope) =>
|
|
51
51
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* El CN de un servicio sale de su scope: `vault:secrets:proxy` ⇒ CN `proxy`. Con CN, el
|
|
55
|
+
* miembro entra al acta como SERVICIO —solo abre su propio cajón— en vez de como un
|
|
56
|
+
* dispositivo del usuario. Es la frontera, y vive en el acta para que se pueda comprobar.
|
|
57
|
+
*/
|
|
58
|
+
export function scopeToCn (scope) {
|
|
59
|
+
for (const s of (Array.isArray(scope) ? scope : [scope])) {
|
|
60
|
+
const m = /^vault:secrets:([a-z0-9-]{1,32})$/.exec(String(s || ''))
|
|
61
|
+
if (m) return m[1]
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
/** Token aleatorio de 128 bits en hex. */
|
|
54
67
|
export function randToken () {
|
|
55
68
|
const b = crypto.getRandomValues(new Uint8Array(16))
|
|
@@ -215,8 +228,9 @@ export function createEnrollDesk ({
|
|
|
215
228
|
let acta = null
|
|
216
229
|
try {
|
|
217
230
|
if (typeof identity.admitMember === 'function') {
|
|
218
|
-
const
|
|
219
|
-
|
|
231
|
+
const cn = scopeToCn(pend.scope)
|
|
232
|
+
const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
|
|
233
|
+
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
220
234
|
}
|
|
221
235
|
acta = (await identity.profileActa?.())?.acta || null
|
|
222
236
|
} catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
|