@dotrino/identity 0.22.1 → 0.24.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 +5 -2
- package/src/index.js +49 -5
- package/src/node.js +12 -0
- package/vault/acta.js +315 -0
- package/vault/core.js +286 -22
- package/vault/remote.js +66 -38
- package/vault/vault.js +5 -1
- package/vault/vendor/vault/VERSION.txt +6 -5
- package/vault/vendor/vault/enroll.js +269 -0
- package/vault/vendor/vault/index.js +35 -120
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
"import": "./vault/avatar.js"
|
|
22
22
|
},
|
|
23
23
|
"./vault/core.js": "./vault/core.js",
|
|
24
|
-
"./vault/remote.js": "./vault/remote.js"
|
|
24
|
+
"./vault/remote.js": "./vault/remote.js",
|
|
25
|
+
"./acta": {
|
|
26
|
+
"import": "./vault/acta.js"
|
|
27
|
+
}
|
|
25
28
|
},
|
|
26
29
|
"files": [
|
|
27
30
|
"src",
|
package/src/index.js
CHANGED
|
@@ -256,6 +256,50 @@ export class Identity {
|
|
|
256
256
|
return this._call('listDelegations')
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// ----- Acta de perfil: qué llaves son tuyas y qué puede hacer cada una -----
|
|
260
|
+
// Un perfil es un conjunto de llaves ligadas por certificados, con una política firmada
|
|
261
|
+
// por UN solo sellador (el «master»). Ninguna llave privada viaja nunca.
|
|
262
|
+
// Diseño: dotrino-vault/docs/acta-de-perfil.md
|
|
263
|
+
|
|
264
|
+
/** El acta vigente + si este dispositivo es el master + sus capacidades efectivas. */
|
|
265
|
+
async profileActa () { return this._call('profileActa') }
|
|
266
|
+
|
|
267
|
+
/** Miembros del perfil, ya con id legible y capacidades efectivas. */
|
|
268
|
+
async profileMembers () { return this._call('profileMembers') }
|
|
269
|
+
|
|
270
|
+
/** Dónde estoy yo: { inProfile, profileId, seq, isMaster, caps, id }. */
|
|
271
|
+
async myMembership () { return this._call('myMembership') }
|
|
272
|
+
|
|
273
|
+
/** ¿Es ESTE dispositivo el master (el único que puede cambiar el acta)? */
|
|
274
|
+
async isMaster () { return this._call('isMaster') }
|
|
275
|
+
|
|
276
|
+
/** Admite un miembro nuevo (solo el master). */
|
|
277
|
+
async admitMember (member) { return this._call('admitMember', member) }
|
|
278
|
+
|
|
279
|
+
/** Cambia las capacidades de un miembro (solo el master). */
|
|
280
|
+
async setCaps (pub, caps) { return this._call('setCaps', { pub, caps }) }
|
|
281
|
+
|
|
282
|
+
/** Expulsa a un miembro (solo el master; al master no se le puede expulsar). */
|
|
283
|
+
async removeMember (pub) { return this._call('removeMember', { pub }) }
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Traspasa el master a otro miembro — dispositivo → bóveda, o bóveda → bóveda al mudarse
|
|
287
|
+
* de PC. Si `member` viene, se admite y se nombra en el MISMO seq (sin ventana intermedia).
|
|
288
|
+
*/
|
|
289
|
+
async handoverMaster (to, member = null) { return this._call('handoverMaster', { to, member }) }
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Este dispositivo se quita capacidades a sí mismo (p. ej. dejar de firmar). Es
|
|
293
|
+
* unilateral y funciona con la bóveda apagada, que es justo cuando hace falta.
|
|
294
|
+
*/
|
|
295
|
+
async renounceCaps (caps) { return this._call('renounceCaps', { caps }) }
|
|
296
|
+
|
|
297
|
+
/** Absorbe en el acta una renuncia de otro miembro (solo el master). */
|
|
298
|
+
async absorbRenounce (record) { return this._call('absorbRenounce', { record }) }
|
|
299
|
+
|
|
300
|
+
/** Adopta un acta recibida de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
|
|
301
|
+
async adoptActa (acta) { return this._call('adoptActa', { acta }) }
|
|
302
|
+
|
|
259
303
|
// ----- Emparejar ESTE navegador/dispositivo con el vault del usuario (Fase 1) -----
|
|
260
304
|
|
|
261
305
|
/**
|
|
@@ -319,11 +363,11 @@ export class Identity {
|
|
|
319
363
|
}
|
|
320
364
|
|
|
321
365
|
// ----- Self-vault: ESTE dispositivo actúa como su propia bóveda/CA -----
|
|
322
|
-
// El daemon device-vault vive dentro del iframe (no requiere el binario del PC
|
|
323
|
-
//
|
|
324
|
-
// emparejamiento, aprobar SAS y revocar máquinas — todo por RPC
|
|
325
|
-
// El daemon sólo corre en una pestaña visible a la vez (navigator.locks),
|
|
326
|
-
// getters (status/pending/machines) y revoke sirven desde cualquier pestaña.
|
|
366
|
+
// El daemon device-vault vive dentro del iframe (no requiere el binario del PC).
|
|
367
|
+
// Se gestiona desde profile.dotrino.com/#myvault. Cualquier app puede activarlo,
|
|
368
|
+
// generar códigos de emparejamiento, aprobar SAS y revocar máquinas — todo por RPC
|
|
369
|
+
// al iframe. El daemon sólo corre en una pestaña visible a la vez (navigator.locks),
|
|
370
|
+
// pero los getters (status/pending/machines) y revoke sirven desde cualquier pestaña.
|
|
327
371
|
/** { enabled, running }: si el modo self está activado y si esta pestaña sostiene el daemon. */
|
|
328
372
|
async selfVaultStatus () { return this._call('selfVaultStatus') }
|
|
329
373
|
/** Activa/desactiva el modo self-vault en este dispositivo. */
|
package/src/node.js
CHANGED
|
@@ -150,6 +150,18 @@ export class Identity {
|
|
|
150
150
|
signDelegation (sub, scope, opts = {}) { return this._h('signDelegation', { sub, scope, ...opts }) }
|
|
151
151
|
revokeDelegation (nonce) { return this._h('revokeDelegation', { nonce }) }
|
|
152
152
|
listDelegations () { return this._h('listDelegations') }
|
|
153
|
+
// Acta de perfil (qué llaves son del perfil y qué puede cada una; ver acta-de-perfil.md)
|
|
154
|
+
profileActa () { return this._h('profileActa') }
|
|
155
|
+
profileMembers () { return this._h('profileMembers') }
|
|
156
|
+
myMembership () { return this._h('myMembership') }
|
|
157
|
+
isMaster () { return this._h('isMaster') }
|
|
158
|
+
admitMember (member) { return this._h('admitMember', member) }
|
|
159
|
+
setCaps (pub, caps) { return this._h('setCaps', { pub, caps }) }
|
|
160
|
+
removeMember (pub) { return this._h('removeMember', { pub }) }
|
|
161
|
+
handoverMaster (to, member = null) { return this._h('handoverMaster', { to, member }) }
|
|
162
|
+
renounceCaps (caps) { return this._h('renounceCaps', { caps }) }
|
|
163
|
+
absorbRenounce (record) { return this._h('absorbRenounce', { record }) }
|
|
164
|
+
adoptActa (acta) { return this._h('adoptActa', { acta }) }
|
|
153
165
|
// Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
|
|
154
166
|
enrollDevice (qr) { return this._h('vaultPair', { qr }) }
|
|
155
167
|
vaultStatus () { return this._h('vaultStatus') }
|
package/vault/acta.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* acta.js — el ACTA DE PERFIL: qué llaves son del mismo perfil y qué puede hacer cada una.
|
|
3
|
+
*
|
|
4
|
+
* Un perfil no es una llave: es un CONJUNTO de llaves miembro, ligadas por certificados,
|
|
5
|
+
* más una política firmada. Ninguna llave privada viaja nunca; lo que se mueve es el acta.
|
|
6
|
+
* Diseño completo y decisiones en `dotrino-vault/docs/acta-de-perfil.md`.
|
|
7
|
+
*
|
|
8
|
+
* Las cuatro reglas que este módulo hace cumplir:
|
|
9
|
+
* 1. **Un solo sellador** (el «master»). Solo el sellador vigente puede producir la
|
|
10
|
+
* siguiente acta. Como las llaves son intransferibles no se puede clonar → dos actas
|
|
11
|
+
* legítimas con el mismo `seq` son imposibles: no hay bifurcaciones que resolver.
|
|
12
|
+
* 2. **`seq` monotónico + `prev`** (hash del acta anterior): cadena verificable.
|
|
13
|
+
* 3. **Nunca dejar el perfil sin quien firme**: un cambio que quite el último `sign` se
|
|
14
|
+
* rechaza.
|
|
15
|
+
* 4. **Renunciar es aparte**: un miembro se quita capacidades a sí mismo con un registro
|
|
16
|
+
* suelto que solo QUITA (§2.2). No toca el acta ni el `seq`, así que funciona con la
|
|
17
|
+
* bóveda apagada; el master lo absorbe después.
|
|
18
|
+
*
|
|
19
|
+
* DOS CAMPOS, NO UNO: `sealedBy` es la llave que FIRMÓ esta acta y `sealer` es quien queda
|
|
20
|
+
* como master de aquí en adelante. Normalmente coinciden; en un TRASPASO no, porque el acta
|
|
21
|
+
* que nombra al nuevo master la firma el saliente. Esa distinción es la que hace el traspaso
|
|
22
|
+
* auto-verificable: la firma del saliente es la prueba de su propia degradación.
|
|
23
|
+
*
|
|
24
|
+
* Los tiempos son epoch en ms (UTC) e INFORMATIVOS: la precedencia va por `seq` y por la
|
|
25
|
+
* regla de traspaso (§2.4.1), nunca por reloj — si dependiera de la hora, un master mintiendo
|
|
26
|
+
* reescribiría el orden cambiando el reloj de su máquina.
|
|
27
|
+
*
|
|
28
|
+
* Módulo PURO: sin kv, sin red, sin disco. Cripto de `./capabilities.js`.
|
|
29
|
+
*/
|
|
30
|
+
import { canonicalStringify } from './core.js'
|
|
31
|
+
import { signWithDevice, verifyDeviceSig, pubkeyId } from './capabilities.js'
|
|
32
|
+
|
|
33
|
+
export const ACTA_V = 1
|
|
34
|
+
|
|
35
|
+
/** Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master. */
|
|
36
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read'])
|
|
37
|
+
|
|
38
|
+
/** Cada capacidad es uno de los scopes que ya existen en los certs (sin scopes nuevos). */
|
|
39
|
+
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read' })
|
|
40
|
+
|
|
41
|
+
const enc = (s) => new TextEncoder().encode(s)
|
|
42
|
+
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
43
|
+
const isPub = (v) => typeof v === 'string' && v.length > 0
|
|
44
|
+
const cleanCaps = (caps) => [...new Set((Array.isArray(caps) ? caps : []).filter((c) => CAPS.includes(c)))].sort()
|
|
45
|
+
|
|
46
|
+
/** El acta SIN la firma: es lo que se sella y sobre lo que se calcula el hash. */
|
|
47
|
+
export function actaBody (acta) {
|
|
48
|
+
const { sig, ...body } = acta || {}
|
|
49
|
+
return body
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Hash del acta (hex de SHA-256 sobre el cuerpo canónico). Es lo que apunta el `prev`. */
|
|
53
|
+
export async function actaHash (acta) {
|
|
54
|
+
return hex(await crypto.subtle.digest('SHA-256', enc(canonicalStringify(actaBody(acta)))))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Id legible de un miembro (mismo formato que el deviceId del emparejamiento). */
|
|
58
|
+
export async function memberId (pub) {
|
|
59
|
+
const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
|
|
60
|
+
return id.slice(0, 4) + '-' + id.slice(4, 8)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** ¿Esta acta es un traspaso? (la firmó uno y nombra master a otro) */
|
|
64
|
+
export const isHandover = (acta) => !!acta && acta.sealer !== acta.sealedBy
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Acta de génesis: un perfil recién nacido tiene UN miembro (esta llave), que además es el
|
|
68
|
+
* sellador, con todas las capacidades. `profileId` = su pubkey → el nombre del perfil es
|
|
69
|
+
* estable para siempre y coincide con la identidad que el usuario ya tenía (cero migración).
|
|
70
|
+
*/
|
|
71
|
+
export function genesisActa ({ pub, encPub = null, label = '', now = Date.now() }) {
|
|
72
|
+
if (!isPub(pub)) throw new Error('genesisActa: falta la pubkey de la génesis')
|
|
73
|
+
return {
|
|
74
|
+
v: ACTA_V,
|
|
75
|
+
profileId: pub,
|
|
76
|
+
sealer: pub,
|
|
77
|
+
sealedBy: pub,
|
|
78
|
+
seq: 1,
|
|
79
|
+
prev: null,
|
|
80
|
+
members: [{ pub, encPub, label: String(label || '').slice(0, 60), caps: [...CAPS], addedAt: now, cert: null }],
|
|
81
|
+
revoked: [],
|
|
82
|
+
renounced: [],
|
|
83
|
+
updatedAt: now
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Comprobaciones de FORMA (sin cripto): que el acta sea un acta. */
|
|
88
|
+
export function checkShape (acta) {
|
|
89
|
+
if (!acta || typeof acta !== 'object') return 'no-acta'
|
|
90
|
+
if (acta.v !== ACTA_V) return 'version'
|
|
91
|
+
if (!isPub(acta.profileId) || !isPub(acta.sealer) || !isPub(acta.sealedBy)) return 'shape'
|
|
92
|
+
if (!Number.isInteger(acta.seq) || acta.seq < 1) return 'seq'
|
|
93
|
+
if (acta.seq > 1 && typeof acta.prev !== 'string') return 'prev'
|
|
94
|
+
if (!Array.isArray(acta.members) || acta.members.length === 0) return 'members'
|
|
95
|
+
for (const m of acta.members) {
|
|
96
|
+
if (!isPub(m?.pub) || !Array.isArray(m?.caps)) return 'member'
|
|
97
|
+
if (m.caps.some((c) => !CAPS.includes(c))) return 'cap-desconocida'
|
|
98
|
+
}
|
|
99
|
+
if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'miembro-duplicado'
|
|
100
|
+
if (!acta.members.some((m) => m.pub === acta.sealer)) return 'sealer-no-es-miembro'
|
|
101
|
+
if (!acta.members.some((m) => m.caps.includes('sign'))) return 'sin-firmante'
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Sella (firma) un acta. `privateKey` puede ser una CryptoKey no extractable. */
|
|
106
|
+
export async function sealActa ({ acta, privateKey, privateJwk }) {
|
|
107
|
+
const shape = checkShape(acta)
|
|
108
|
+
if (shape) throw new Error('acta inválida: ' + shape)
|
|
109
|
+
const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: actaBody(acta) })
|
|
110
|
+
return { ...acta, sig: signature }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verifica un acta: forma + firma de `sealedBy`. Que ESE firmante estuviera autorizado no se
|
|
115
|
+
* decide aquí sino al adoptarla (`canAdopt`), comparándola con la que ya tienes.
|
|
116
|
+
* @returns {{ok:boolean, reason?:string}}
|
|
117
|
+
*/
|
|
118
|
+
export async function verifyActa ({ acta, expectedProfileId } = {}) {
|
|
119
|
+
const shape = checkShape(acta)
|
|
120
|
+
if (shape) return { ok: false, reason: shape }
|
|
121
|
+
if (typeof acta.sig !== 'string') return { ok: false, reason: 'sin-firma' }
|
|
122
|
+
if (expectedProfileId != null && acta.profileId !== expectedProfileId) return { ok: false, reason: 'otro-perfil' }
|
|
123
|
+
const ok = await verifyDeviceSig({ publickey: acta.sealedBy, data: actaBody(acta), signature: acta.sig })
|
|
124
|
+
return ok ? { ok: true } : { ok: false, reason: 'firma-invalida' }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Aplica cambios y devuelve el acta SIGUIENTE, **sin firmar** (hay que `sealActa`).
|
|
129
|
+
* `by` es quien va a sellar: si no es el sellador vigente, se rechaza (regla 1).
|
|
130
|
+
*
|
|
131
|
+
* Cambios: `{op:'admit', member}` · `{op:'caps', pub, caps}` · `{op:'remove', pub}` ·
|
|
132
|
+
* `{op:'handover', to}` · `{op:'revoke', nonce, until}` · `{op:'renounce', record}`.
|
|
133
|
+
* Van en ARRAY porque hay combinaciones que deben ser atómicas: admitir al nuevo sellador y
|
|
134
|
+
* traspasarle el master ocurre en el MISMO `seq` (§2.1.3).
|
|
135
|
+
*/
|
|
136
|
+
export async function applyChanges (acta, changes, { by, now = Date.now() } = {}) {
|
|
137
|
+
const shape = checkShape(acta)
|
|
138
|
+
if (shape) throw new Error('acta inválida: ' + shape)
|
|
139
|
+
if (!by) throw new Error('applyChanges: falta `by` (quién sella)')
|
|
140
|
+
if (by !== acta.sealer) throw new Error('solo el master puede cambiar el acta; este dispositivo no lo es')
|
|
141
|
+
|
|
142
|
+
const list = Array.isArray(changes) ? changes : [changes]
|
|
143
|
+
if (list.length === 0) throw new Error('applyChanges: no hay cambios')
|
|
144
|
+
|
|
145
|
+
const next = {
|
|
146
|
+
...acta,
|
|
147
|
+
sealedBy: by,
|
|
148
|
+
seq: acta.seq + 1,
|
|
149
|
+
prev: await actaHash(acta),
|
|
150
|
+
members: acta.members.map((m) => ({ ...m, caps: [...m.caps] })),
|
|
151
|
+
revoked: [...(acta.revoked || [])],
|
|
152
|
+
renounced: [...(acta.renounced || [])],
|
|
153
|
+
updatedAt: now
|
|
154
|
+
}
|
|
155
|
+
delete next.sig
|
|
156
|
+
|
|
157
|
+
const find = (pub) => next.members.find((m) => m.pub === pub)
|
|
158
|
+
|
|
159
|
+
for (const ch of list) {
|
|
160
|
+
switch (ch?.op) {
|
|
161
|
+
case 'admit': {
|
|
162
|
+
const m = ch.member
|
|
163
|
+
if (!isPub(m?.pub)) throw new Error('admit: falta la pubkey del miembro')
|
|
164
|
+
if (find(m.pub)) throw new Error('admit: ese miembro ya está en el acta')
|
|
165
|
+
next.members.push({
|
|
166
|
+
pub: m.pub,
|
|
167
|
+
encPub: m.encPub || null,
|
|
168
|
+
label: String(m.label || '').slice(0, 60),
|
|
169
|
+
caps: cleanCaps(m.caps),
|
|
170
|
+
addedAt: now,
|
|
171
|
+
cert: m.cert || null
|
|
172
|
+
})
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
case 'caps': {
|
|
176
|
+
const m = find(ch.pub)
|
|
177
|
+
if (!m) throw new Error('caps: ese miembro no está en el acta')
|
|
178
|
+
m.caps = cleanCaps(ch.caps)
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
case 'remove': {
|
|
182
|
+
const i = next.members.findIndex((m) => m.pub === ch.pub)
|
|
183
|
+
if (i < 0) throw new Error('remove: ese miembro no está en el acta')
|
|
184
|
+
if (next.members[i].pub === next.sealer) throw new Error('remove: no puedes expulsar al master; primero traspasa el sellado')
|
|
185
|
+
next.members.splice(i, 1)
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
case 'handover': {
|
|
189
|
+
if (!find(ch.to)) throw new Error('handover: el nuevo master tiene que ser miembro (admítelo en el mismo cambio)')
|
|
190
|
+
next.sealer = ch.to
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
case 'revoke': {
|
|
194
|
+
if (typeof ch.nonce !== 'string') throw new Error('revoke: falta el nonce')
|
|
195
|
+
next.revoked.push({ nonce: ch.nonce, until: ch.until || (now + 30 * 24 * 60 * 60 * 1000) })
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
case 'renounce': {
|
|
199
|
+
// Absorbe un registro de renuncia ya verificado (§2.2): solo puede QUITAR.
|
|
200
|
+
const r = ch.record
|
|
201
|
+
const m = find(r?.member)
|
|
202
|
+
if (!m) break // el que renunció ya no está: nada que hacer
|
|
203
|
+
m.caps = m.caps.filter((c) => !(r.caps || []).includes(c))
|
|
204
|
+
next.renounced = next.renounced.filter((x) => x.member !== r.member)
|
|
205
|
+
next.renounced.push(r)
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
throw new Error('cambio desconocido: ' + ch?.op)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Poda: las revocaciones vencidas ya no hacen falta (el cert al que apuntan expiró).
|
|
214
|
+
next.revoked = next.revoked.filter((r) => !r.until || r.until > now)
|
|
215
|
+
|
|
216
|
+
// Reglas de cierre: sin firmante no se puede operar, y sin sellador no se puede cambiar.
|
|
217
|
+
if (!next.members.some((m) => m.caps.includes('sign'))) {
|
|
218
|
+
throw new Error('el cambio dejaría el perfil sin ningún miembro que pueda firmar')
|
|
219
|
+
}
|
|
220
|
+
if (!next.members.some((m) => m.pub === next.sealer)) {
|
|
221
|
+
throw new Error('el cambio dejaría el acta sin master')
|
|
222
|
+
}
|
|
223
|
+
return next
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ----- renuncia (§2.2): el único cambio que no pasa por el master -----
|
|
227
|
+
|
|
228
|
+
/** Crea el registro firmado con el que un miembro se QUITA capacidades a sí mismo. */
|
|
229
|
+
export async function makeRenounce ({ member, caps, privateKey, privateJwk, now = Date.now() }) {
|
|
230
|
+
const body = { op: 'renounce', member, caps: cleanCaps(caps), ts: now }
|
|
231
|
+
if (body.caps.length === 0) throw new Error('renuncia: no hay capacidades que quitar')
|
|
232
|
+
const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: member, data: body })
|
|
233
|
+
return { ...body, sig: signature }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* ¿Es válida esta renuncia? La firma tiene que ser del PROPIO miembro. Como solo puede
|
|
238
|
+
* quitar, cualquiera puede honrarla sin riesgo, sin esperar a que el master la selle.
|
|
239
|
+
*/
|
|
240
|
+
export async function verifyRenounce (record) {
|
|
241
|
+
if (!record || record.op !== 'renounce' || !isPub(record.member) || typeof record.sig !== 'string') return false
|
|
242
|
+
if (!Array.isArray(record.caps) || record.caps.length === 0) return false
|
|
243
|
+
const { sig, ...body } = record
|
|
244
|
+
return verifyDeviceSig({ publickey: record.member, data: body, signature: sig })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Capacidad EFECTIVA de un miembro: lo que dice el acta menos lo que haya renunciado.
|
|
249
|
+
* `extraRenounces` son renuncias sueltas ya verificadas que todavía no absorbió el master.
|
|
250
|
+
*/
|
|
251
|
+
export function effectiveCaps (acta, pub, extraRenounces = []) {
|
|
252
|
+
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
253
|
+
if (!m) return []
|
|
254
|
+
const quitadas = new Set()
|
|
255
|
+
for (const r of [...(acta.renounced || []), ...extraRenounces]) {
|
|
256
|
+
if (r?.member === pub) for (const c of (r.caps || [])) quitadas.add(c)
|
|
257
|
+
}
|
|
258
|
+
return m.caps.filter((c) => !quitadas.has(c))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** ¿Puede este miembro hacer `cap` según el acta? (con el cert se cruza aparte: cert ∩ acta). */
|
|
262
|
+
export function memberCan (acta, pub, cap, extraRenounces = []) {
|
|
263
|
+
return effectiveCaps(acta, pub, extraRenounces).includes(cap)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ----- adopción y empates (§2.4.1) -----
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* ¿Adopto `candidate` en lugar de `current`?
|
|
270
|
+
*
|
|
271
|
+
* · `seq` mayor y contigua → sí, si encadena (`prev` = hash de la mía) y la firmó quien
|
|
272
|
+
* era master en la mía (`sealedBy === current.sealer`).
|
|
273
|
+
* · `seq` mayor con HUECO (estuve apagado) → solo si la firmó el master que conozco; si
|
|
274
|
+
* hubo un traspaso durante el hueco no puedo comprobarlo y toca re-admitirse (§1.3).
|
|
275
|
+
* · MISMO `seq` → gana la que TRASPASA. Es el caso del master obsoleto: un vault restaurado
|
|
276
|
+
* de un respaldo anterior al traspaso sella su propio `seq` creyéndose master, pero el
|
|
277
|
+
* acta del traspaso la firmó él mismo y esa firma prueba su degradación. Si las dos
|
|
278
|
+
* traspasan a destinos distintos (master mintiendo), desempata el hash menor —
|
|
279
|
+
* determinista, y nada salva de un master hostil.
|
|
280
|
+
* · `seq` menor → nunca (jamás retroceder).
|
|
281
|
+
*
|
|
282
|
+
* @returns {Promise<{adopt:boolean, reason:string}>}
|
|
283
|
+
*/
|
|
284
|
+
export async function canAdopt ({ candidate, current }) {
|
|
285
|
+
const v = await verifyActa({ acta: candidate, expectedProfileId: current?.profileId })
|
|
286
|
+
if (!v.ok) return { adopt: false, reason: v.reason }
|
|
287
|
+
if (!current) return { adopt: true, reason: 'sin-acta-previa' }
|
|
288
|
+
|
|
289
|
+
if (candidate.seq > current.seq) {
|
|
290
|
+
if (candidate.sealedBy !== current.sealer) return { adopt: false, reason: 'sellador-no-autorizado' }
|
|
291
|
+
if (candidate.seq === current.seq + 1 && candidate.prev !== await actaHash(current)) {
|
|
292
|
+
return { adopt: false, reason: 'no-encadena' }
|
|
293
|
+
}
|
|
294
|
+
return { adopt: true, reason: 'seq-mayor' }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (candidate.seq === current.seq) {
|
|
298
|
+
const [hCan, hCur] = [await actaHash(candidate), await actaHash(current)]
|
|
299
|
+
if (hCan === hCur) return { adopt: false, reason: 'misma-acta' }
|
|
300
|
+
if (candidate.sealedBy !== current.sealedBy) return { adopt: false, reason: 'otro-sellador' }
|
|
301
|
+
const canT = isHandover(candidate)
|
|
302
|
+
const curT = isHandover(current)
|
|
303
|
+
if (canT && !curT) return { adopt: true, reason: 'traspaso-gana' }
|
|
304
|
+
if (!canT && curT) return { adopt: false, reason: 'traspaso-gana' }
|
|
305
|
+
return hCan < hCur ? { adopt: true, reason: 'desempate-hash' } : { adopt: false, reason: 'desempate-hash' }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { adopt: false, reason: 'seq-menor' }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export default {
|
|
312
|
+
ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, isHandover,
|
|
313
|
+
sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
|
|
314
|
+
effectiveCaps, memberCan, canAdopt
|
|
315
|
+
}
|