@dotrino/identity 0.23.0 → 0.25.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.23.0",
3
+ "version": "0.25.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
  /**
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,342 @@
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
+ // Puente con la identidad que este miembro traía de antes (ver makeContinuity).
173
+ ...(m.continuity ? { continuity: m.continuity } : {})
174
+ })
175
+ break
176
+ }
177
+ case 'caps': {
178
+ const m = find(ch.pub)
179
+ if (!m) throw new Error('caps: ese miembro no está en el acta')
180
+ m.caps = cleanCaps(ch.caps)
181
+ break
182
+ }
183
+ case 'remove': {
184
+ const i = next.members.findIndex((m) => m.pub === ch.pub)
185
+ if (i < 0) throw new Error('remove: ese miembro no está en el acta')
186
+ if (next.members[i].pub === next.sealer) throw new Error('remove: no puedes expulsar al master; primero traspasa el sellado')
187
+ next.members.splice(i, 1)
188
+ break
189
+ }
190
+ case 'handover': {
191
+ if (!find(ch.to)) throw new Error('handover: el nuevo master tiene que ser miembro (admítelo en el mismo cambio)')
192
+ next.sealer = ch.to
193
+ break
194
+ }
195
+ case 'revoke': {
196
+ if (typeof ch.nonce !== 'string') throw new Error('revoke: falta el nonce')
197
+ next.revoked.push({ nonce: ch.nonce, until: ch.until || (now + 30 * 24 * 60 * 60 * 1000) })
198
+ break
199
+ }
200
+ case 'renounce': {
201
+ // Absorbe un registro de renuncia ya verificado (§2.2): solo puede QUITAR.
202
+ const r = ch.record
203
+ const m = find(r?.member)
204
+ if (!m) break // el que renunció ya no está: nada que hacer
205
+ m.caps = m.caps.filter((c) => !(r.caps || []).includes(c))
206
+ next.renounced = next.renounced.filter((x) => x.member !== r.member)
207
+ next.renounced.push(r)
208
+ break
209
+ }
210
+ default:
211
+ throw new Error('cambio desconocido: ' + ch?.op)
212
+ }
213
+ }
214
+
215
+ // Poda: las revocaciones vencidas ya no hacen falta (el cert al que apuntan expiró).
216
+ next.revoked = next.revoked.filter((r) => !r.until || r.until > now)
217
+
218
+ // Reglas de cierre: sin firmante no se puede operar, y sin sellador no se puede cambiar.
219
+ if (!next.members.some((m) => m.caps.includes('sign'))) {
220
+ throw new Error('el cambio dejaría el perfil sin ningún miembro que pueda firmar')
221
+ }
222
+ if (!next.members.some((m) => m.pub === next.sealer)) {
223
+ throw new Error('el cambio dejaría el acta sin master')
224
+ }
225
+ return next
226
+ }
227
+
228
+ // ----- renuncia (§2.2): el único cambio que no pasa por el master -----
229
+
230
+ /** Crea el registro firmado con el que un miembro se QUITA capacidades a sí mismo. */
231
+ export async function makeRenounce ({ member, caps, privateKey, privateJwk, now = Date.now() }) {
232
+ const body = { op: 'renounce', member, caps: cleanCaps(caps), ts: now }
233
+ if (body.caps.length === 0) throw new Error('renuncia: no hay capacidades que quitar')
234
+ const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: member, data: body })
235
+ return { ...body, sig: signature }
236
+ }
237
+
238
+ /**
239
+ * ¿Es válida esta renuncia? La firma tiene que ser del PROPIO miembro. Como solo puede
240
+ * quitar, cualquiera puede honrarla sin riesgo, sin esperar a que el master la selle.
241
+ */
242
+ export async function verifyRenounce (record) {
243
+ if (!record || record.op !== 'renounce' || !isPub(record.member) || typeof record.sig !== 'string') return false
244
+ if (!Array.isArray(record.caps) || record.caps.length === 0) return false
245
+ const { sig, ...body } = record
246
+ return verifyDeviceSig({ publickey: record.member, data: body, signature: sig })
247
+ }
248
+
249
+ /**
250
+ * Capacidad EFECTIVA de un miembro: lo que dice el acta menos lo que haya renunciado.
251
+ * `extraRenounces` son renuncias sueltas ya verificadas que todavía no absorbió el master.
252
+ */
253
+ export function effectiveCaps (acta, pub, extraRenounces = []) {
254
+ const m = (acta?.members || []).find((x) => x.pub === pub)
255
+ if (!m) return []
256
+ const quitadas = new Set()
257
+ for (const r of [...(acta.renounced || []), ...extraRenounces]) {
258
+ if (r?.member === pub) for (const c of (r.caps || [])) quitadas.add(c)
259
+ }
260
+ return m.caps.filter((c) => !quitadas.has(c))
261
+ }
262
+
263
+ /** ¿Puede este miembro hacer `cap` según el acta? (con el cert se cruza aparte: cert ∩ acta). */
264
+ export function memberCan (acta, pub, cap, extraRenounces = []) {
265
+ return effectiveCaps(acta, pub, extraRenounces).includes(cap)
266
+ }
267
+
268
+ // ----- continuidad: unir una identidad que ya existía -----
269
+
270
+ /**
271
+ * CERTIFICADO DE CONTINUIDAD. Cuando una identidad que ya existía entra en otro perfil,
272
+ * firma —con su propia llave— que a partir de ahora es miembro de él. Sirve de puente:
273
+ * lo que hizo antes (su reputación, lo que firmó, quien la tenía de contacto) se puede
274
+ * seguir atribuyendo a la misma persona en vez de quedar huérfano.
275
+ *
276
+ * No otorga nada por sí solo: es una declaración del que se une, y solo tiene efecto
277
+ * dentro del acta donde el master la mete.
278
+ */
279
+ export async function makeContinuity ({ member, from, privateKey, privateJwk, now = Date.now() }) {
280
+ const body = { op: 'continuity', member, from: from || member, ts: now }
281
+ const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: member, data: body })
282
+ return { ...body, sig: signature }
283
+ }
284
+
285
+ /** ¿La firmó de verdad la identidad que dice venir? (única comprobación posible). */
286
+ export async function verifyContinuity (record) {
287
+ if (!record || record.op !== 'continuity' || !isPub(record.member) || typeof record.sig !== 'string') return false
288
+ const { sig, ...body } = record
289
+ return verifyDeviceSig({ publickey: record.member, data: body, signature: sig })
290
+ }
291
+
292
+ // ----- adopción y empates (§2.4.1) -----
293
+
294
+ /**
295
+ * ¿Adopto `candidate` en lugar de `current`?
296
+ *
297
+ * · `seq` mayor y contigua → sí, si encadena (`prev` = hash de la mía) y la firmó quien
298
+ * era master en la mía (`sealedBy === current.sealer`).
299
+ * · `seq` mayor con HUECO (estuve apagado) → solo si la firmó el master que conozco; si
300
+ * hubo un traspaso durante el hueco no puedo comprobarlo y toca re-admitirse (§1.3).
301
+ * · MISMO `seq` → gana la que TRASPASA. Es el caso del master obsoleto: un vault restaurado
302
+ * de un respaldo anterior al traspaso sella su propio `seq` creyéndose master, pero el
303
+ * acta del traspaso la firmó él mismo y esa firma prueba su degradación. Si las dos
304
+ * traspasan a destinos distintos (master mintiendo), desempata el hash menor —
305
+ * determinista, y nada salva de un master hostil.
306
+ * · `seq` menor → nunca (jamás retroceder).
307
+ *
308
+ * @returns {Promise<{adopt:boolean, reason:string}>}
309
+ */
310
+ export async function canAdopt ({ candidate, current }) {
311
+ const v = await verifyActa({ acta: candidate, expectedProfileId: current?.profileId })
312
+ if (!v.ok) return { adopt: false, reason: v.reason }
313
+ if (!current) return { adopt: true, reason: 'sin-acta-previa' }
314
+
315
+ if (candidate.seq > current.seq) {
316
+ if (candidate.sealedBy !== current.sealer) return { adopt: false, reason: 'sellador-no-autorizado' }
317
+ if (candidate.seq === current.seq + 1 && candidate.prev !== await actaHash(current)) {
318
+ return { adopt: false, reason: 'no-encadena' }
319
+ }
320
+ return { adopt: true, reason: 'seq-mayor' }
321
+ }
322
+
323
+ if (candidate.seq === current.seq) {
324
+ const [hCan, hCur] = [await actaHash(candidate), await actaHash(current)]
325
+ if (hCan === hCur) return { adopt: false, reason: 'misma-acta' }
326
+ if (candidate.sealedBy !== current.sealedBy) return { adopt: false, reason: 'otro-sellador' }
327
+ const canT = isHandover(candidate)
328
+ const curT = isHandover(current)
329
+ if (canT && !curT) return { adopt: true, reason: 'traspaso-gana' }
330
+ if (!canT && curT) return { adopt: false, reason: 'traspaso-gana' }
331
+ return hCan < hCur ? { adopt: true, reason: 'desempate-hash' } : { adopt: false, reason: 'desempate-hash' }
332
+ }
333
+
334
+ return { adopt: false, reason: 'seq-menor' }
335
+ }
336
+
337
+ export default {
338
+ ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, isHandover,
339
+ sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
340
+ makeContinuity, verifyContinuity,
341
+ effectiveCaps, memberCan, canAdopt
342
+ }
package/vault/core.js CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './capabilities.js'
22
+ import * as Acta from './acta.js'
22
23
  import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew } from './remote.js'
23
24
 
24
25
  export const KEY_STORAGE = 'dotrino.identity.keypair'
@@ -29,6 +30,8 @@ export const DELEGATIONS_STORAGE = 'dotrino.identity.delegations' // caps emit
29
30
  export const REVOCATIONS_STORAGE = 'dotrino.identity.revocations' // nonces revocados
30
31
  export const VAULT_DEVICE_STORAGE = 'dotrino.identity.vault.device' // sub-clave D de ESTE dispositivo (custodia en el iframe)
31
32
  export const VAULT_CERT_STORAGE = 'dotrino.identity.vault.cert' // { cert, master, proxy, deviceId, pairedAt }
33
+ export const ACTA_STORAGE = 'dotrino.identity.acta' // acta de perfil vigente (quién es del perfil y qué puede)
34
+ export const RENOUNCE_STORAGE = 'dotrino.identity.renounced' // renuncias propias aún no absorbidas por el master
32
35
  // Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
33
36
  // namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
34
37
  export const PROFILES_STORAGE = 'dotrino.identity.profiles' // [{ id, name, pubkey }]
@@ -335,6 +338,86 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
335
338
  } catch (_) { return null }
336
339
  }
337
340
 
341
+ // ----- ACTA DE PERFIL: qué llaves son de este perfil y qué puede hacer cada una -----
342
+ // Diseño en `dotrino-vault/docs/acta-de-perfil.md`. Aquí solo se guarda, se lee y se
343
+ // sella; las reglas (sellador único, seq/prev, no dejar el perfil sin firmante) viven en
344
+ // `acta.js`, que es puro y está probado aparte.
345
+ const loadActa = () => { try { return JSON.parse(kv.getItem(ACTA_STORAGE) || 'null') } catch (_) { return null } }
346
+ const saveActa = (a) => kv.setItem(ACTA_STORAGE, JSON.stringify(a))
347
+ const loadRenounces = () => { try { return JSON.parse(kv.getItem(RENOUNCE_STORAGE) || '[]') || [] } catch (_) { return [] } }
348
+ const saveRenounces = (l) => kv.setItem(RENOUNCE_STORAGE, JSON.stringify(l))
349
+
350
+ /** ¿Es ESTE dispositivo el master (el único que puede sellar)? */
351
+ const amMaster = () => loadActa()?.sealer === publickeyJwkStr
352
+
353
+ /** Sella con la llave del perfil (CryptoKey, puede ser no extractable). */
354
+ const seal = (acta) => Acta.sealActa({ acta, privateKey: keypair.privateKey })
355
+
356
+ /**
357
+ * Aplica cambios, sella y guarda. Solo funciona si este dispositivo es el master: es la
358
+ * regla 1 del modelo, y `applyChanges` la vuelve a comprobar por su cuenta.
359
+ */
360
+ async function sealChanges (changes) {
361
+ const acta = loadActa()
362
+ if (!acta) throw new Error('este perfil todavía no tiene acta')
363
+ const next = await Acta.applyChanges(acta, changes, { by: publickeyJwkStr })
364
+ const sealed = await seal(next)
365
+ saveActa(sealed)
366
+ emitVault({ phase: 'acta', seq: sealed.seq, sealer: sealed.sealer })
367
+ return sealed
368
+ }
369
+
370
+ /**
371
+ * Si el perfil todavía no tiene acta, la crea: un miembro (esta llave), que es el master,
372
+ * con todas las capacidades. `profileId` = la pubkey de este perfil → el perfil se llama
373
+ * como la identidad que el usuario ya tenía, así que no hay nada que migrar.
374
+ */
375
+ async function ensureActa (label = '') {
376
+ if (loadActa()) return loadActa()
377
+ const acta = await seal(Acta.genesisActa({
378
+ pub: publickeyJwkStr, encPub: encPublickeyJwkStr, label: label || me?.nickname || ''
379
+ }))
380
+ saveActa(acta)
381
+ return acta
382
+ }
383
+
384
+ /**
385
+ * UNIRSE a otro perfil (el de la bóveda a la que te acabas de conectar). No es adoptar
386
+ * una versión nueva de TU acta: es cambiar de perfil, así que solo procede si aquí no
387
+ * hay nada que perder — es decir, si este dispositivo es el único miembro del suyo.
388
+ *
389
+ * Si ya tienes otros dispositivos, los dos lados tienen master y hay que ELEGIR cuál
390
+ * manda (§2.4.3): eso es una decisión del dueño, no un efecto colateral de escanear un
391
+ * código, así que se devuelve el conflicto para que lo resuelva la consola.
392
+ */
393
+ async function joinProfile (candidate) {
394
+ const v = await Acta.verifyActa({ acta: candidate })
395
+ if (!v.ok) return { joined: false, reason: 'acta-invalida:' + v.reason }
396
+ if (!candidate.members.some((m) => m.pub === publickeyJwkStr)) {
397
+ return { joined: false, reason: 'no-soy-miembro' }
398
+ }
399
+ const current = loadActa()
400
+ if (current && current.profileId !== candidate.profileId && current.members.length > 1) {
401
+ return { joined: false, reason: 'ya-tienes-perfil-propio', members: current.members.length }
402
+ }
403
+ saveActa(candidate)
404
+ emitVault({ phase: 'acta', seq: candidate.seq, sealer: candidate.sealer, joined: true })
405
+ return { joined: true, profileId: candidate.profileId, seq: candidate.seq }
406
+ }
407
+
408
+ /**
409
+ * Adopta un acta que llega de otro miembro, si gana según §2.4.1 (seq mayor que encadene,
410
+ * o el traspaso a igual seq). Nunca retrocede.
411
+ */
412
+ async function adoptActa (candidate) {
413
+ const current = loadActa()
414
+ const r = await Acta.canAdopt({ candidate, current })
415
+ if (!r.adopt) return { adopted: false, reason: r.reason, seq: current?.seq ?? null }
416
+ saveActa(candidate)
417
+ emitVault({ phase: 'acta', seq: candidate.seq, sealer: candidate.sealer, adopted: r.reason })
418
+ return { adopted: true, reason: r.reason, seq: candidate.seq }
419
+ }
420
+
338
421
  // ----- renovación AUTOMÁTICA del cert (sin QR ni aprobación) -----
339
422
  // Con el cert aún vigente y quedando <15 días, cualquier uso del vault dispara en
340
423
  // segundo plano un `vault.renew`: el vault firma un cert fresco (30 días) para la
@@ -594,7 +677,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
594
677
  // nada que lea datos o firme).
595
678
  const LOCK_EXEMPT = new Set([
596
679
  'profileLockStatus', 'unlockProfile', 'listProfiles', 'currentProfile',
597
- 'switchProfile', 'createProfile'
680
+ 'switchProfile', 'createProfile',
681
+ 'profileActa', 'profileMembers', 'myMembership', 'isMaster'
598
682
  ])
599
683
 
600
684
  const handlers = {
@@ -781,11 +865,33 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
781
865
  return rec
782
866
  },
783
867
 
868
+ /**
869
+ * Firma. Con `sign` en el acta, firma aquí mismo (como siempre). Si este dispositivo
870
+ * RENUNCIÓ a firmar —o el master se lo quitó— la petición se re-enruta a quien sí
871
+ * firma (tu bóveda) y vuelve su firma: la identidad de cara a los demás sigue siendo
872
+ * UNA, y este aparato deja de poder firmar por ti aunque lo roben.
873
+ *
874
+ * El `identify` del transporte es la excepción y SIEMPRE se firma en local: es lo que
875
+ * identifica esta conexión ante el proxy, no una firma tuya de cara a nadie, y sin él
876
+ * el dispositivo no podría ni hablar con la bóveda para pedirle que firme.
877
+ */
784
878
  async signData ({ data }) {
785
879
  if (data == null) throw new Error('data required')
786
- const bytes = new TextEncoder().encode(canonicalStringify(data))
787
- const signature = await signBytes(keypair.privateKey, bytes)
788
- return { signature, publickey: publickeyJwkStr }
880
+ const local = async () => {
881
+ const bytes = new TextEncoder().encode(canonicalStringify(data))
882
+ return { signature: await signBytes(keypair.privateKey, bytes), publickey: publickeyJwkStr }
883
+ }
884
+ const acta = loadActa()
885
+ const puedeFirmar = !acta || Acta.memberCan(acta, publickeyJwkStr, 'sign', loadRenounces())
886
+ if (puedeFirmar || data?.op === 'identify') return local()
887
+
888
+ const v = loadVaultCert(); const device = loadVaultDevice()
889
+ if (!v?.cert || !device) {
890
+ throw new Error('perfil-sin-firmante: este dispositivo ya no firma por ti y no está conectado a ninguna bóveda que pueda hacerlo')
891
+ }
892
+ maybeRenewVaultCert()
893
+ try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload: data, onRevoked: wipeVaultLink }) }
894
+ catch (e) { return handleVaultError(e) }
789
895
  },
790
896
 
791
897
  // ----- delegación de capacidad: la maestra firma un cert para una sub-clave -----
@@ -845,6 +951,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
845
951
  me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(name || '').slice(0, 40) }
846
952
  saveMe(me)
847
953
  const list = loadProfiles(); list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr }); saveProfiles(list)
954
+ await ensureActa(me.nickname) // el perfil nuevo nace con su acta (él mismo es el master)
848
955
  return { id: pid, name: me.nickname, pubkey: publickeyJwkStr }
849
956
  },
850
957
  async switchProfile ({ id } = {}) {
@@ -865,7 +972,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
865
972
  if (!list.find((p) => p.id === id)) throw new Error('perfil no existe')
866
973
  list = list.filter((p) => p.id !== id); saveProfiles(list)
867
974
  // Borrado directo del namespace del perfil (incluye su store del vault si lo tuviera).
868
- for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert']) {
975
+ for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert', 'acta', 'renounced']) {
869
976
  rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
870
977
  }
871
978
  // …y sus CryptoKeys no extractables del keyStore (IndexedDB).
@@ -878,6 +985,108 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
878
985
  return { ok: true, current: currentPid }
879
986
  },
880
987
 
988
+ // ----- ACTA DE PERFIL -----
989
+ // Quién es de este perfil y qué puede hacer cada uno. Solo el master sella; los demás
990
+ // adoptan. Ver `dotrino-vault/docs/acta-de-perfil.md`.
991
+
992
+ async profileActa () {
993
+ const acta = loadActa()
994
+ if (!acta) return null
995
+ return { acta, isMaster: amMaster(), myCaps: Acta.effectiveCaps(acta, publickeyJwkStr, loadRenounces()) }
996
+ },
997
+
998
+ async profileMembers () {
999
+ const acta = loadActa()
1000
+ if (!acta) return { members: [], profileId: null, seq: 0, sealer: null }
1001
+ const pend = loadRenounces()
1002
+ const members = await Promise.all(acta.members.map(async (m) => ({
1003
+ pub: m.pub,
1004
+ id: await Acta.memberId(m.pub),
1005
+ label: m.label || '',
1006
+ caps: Acta.effectiveCaps(acta, m.pub, pend),
1007
+ addedAt: m.addedAt || null,
1008
+ isMe: m.pub === publickeyJwkStr,
1009
+ isMaster: m.pub === acta.sealer
1010
+ })))
1011
+ return { members, profileId: acta.profileId, seq: acta.seq, sealer: acta.sealer, updatedAt: acta.updatedAt }
1012
+ },
1013
+
1014
+ async myMembership () {
1015
+ const acta = loadActa()
1016
+ if (!acta) return { inProfile: false }
1017
+ const m = acta.members.find((x) => x.pub === publickeyJwkStr)
1018
+ return {
1019
+ inProfile: !!m,
1020
+ profileId: acta.profileId,
1021
+ seq: acta.seq,
1022
+ isMaster: acta.sealer === publickeyJwkStr,
1023
+ caps: Acta.effectiveCaps(acta, publickeyJwkStr, loadRenounces()),
1024
+ id: m ? await Acta.memberId(m.pub) : null
1025
+ }
1026
+ },
1027
+
1028
+ async isMaster () { return amMaster() },
1029
+
1030
+ /** Admite un miembro (solo el master). El cert lo emite quien llama, antes o después. */
1031
+ async admitMember ({ pub, encPub = null, label = '', caps = ['store', 'read'], cert = null } = {}) {
1032
+ const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, caps, cert } }])
1033
+ return { ok: true, seq: acta.seq }
1034
+ },
1035
+
1036
+ async setCaps ({ pub, caps } = {}) {
1037
+ const acta = await sealChanges([{ op: 'caps', pub, caps }])
1038
+ return { ok: true, seq: acta.seq }
1039
+ },
1040
+
1041
+ async removeMember ({ pub } = {}) {
1042
+ const acta = await sealChanges([{ op: 'remove', pub }])
1043
+ return { ok: true, seq: acta.seq }
1044
+ },
1045
+
1046
+ /**
1047
+ * Traspasa el master a otro miembro. Admitir y nombrar van en el MISMO seq: el nuevo
1048
+ * sellador tiene que ser miembro para poder serlo, y así no hay ventana intermedia.
1049
+ * Cubre igual dispositivo → bóveda y bóveda → bóveda (mudarse de PC).
1050
+ */
1051
+ async handoverMaster ({ to, member = null } = {}) {
1052
+ const changes = []
1053
+ if (member) changes.push({ op: 'admit', member: { ...member, pub: to } })
1054
+ changes.push({ op: 'handover', to })
1055
+ const acta = await sealChanges(changes)
1056
+ return { ok: true, seq: acta.seq, sealer: acta.sealer }
1057
+ },
1058
+
1059
+ /**
1060
+ * RENUNCIA (§2.2): este dispositivo se quita capacidades a sí mismo. No pasa por el
1061
+ * master —por eso funciona con la bóveda apagada, que es justo cuando hace falta (te
1062
+ * robaron el aparato)— y solo puede QUITAR, así que cualquiera puede honrarla.
1063
+ */
1064
+ async renounceCaps ({ caps } = {}) {
1065
+ const acta = loadActa()
1066
+ if (!acta) throw new Error('este perfil todavía no tiene acta')
1067
+ const record = await Acta.makeRenounce({ member: publickeyJwkStr, caps, privateKey: keypair.privateKey })
1068
+ const pend = loadRenounces().filter((r) => r.member !== publickeyJwkStr)
1069
+ pend.push(record)
1070
+ saveRenounces(pend)
1071
+ emitVault({ phase: 'renounced', caps: record.caps })
1072
+ // Si además soy el master, la absorbo ya en el acta.
1073
+ if (amMaster()) { try { await sealChanges([{ op: 'renounce', record }]) } catch (_) {} }
1074
+ return { ok: true, record, caps: Acta.effectiveCaps(loadActa(), publickeyJwkStr, loadRenounces()) }
1075
+ },
1076
+
1077
+ /** Absorbe en el acta una renuncia ajena ya verificada (solo el master). */
1078
+ async absorbRenounce ({ record } = {}) {
1079
+ if (!(await Acta.verifyRenounce(record))) throw new Error('renuncia inválida: la firma no es del propio miembro')
1080
+ const acta = await sealChanges([{ op: 'renounce', record }])
1081
+ return { ok: true, seq: acta.seq }
1082
+ },
1083
+
1084
+ /** Adopta un acta que llega de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
1085
+ async adoptActa ({ acta } = {}) { return adoptActa(acta) },
1086
+
1087
+ /** Une este dispositivo al perfil de otra bóveda (solo si aquí no hay nada que perder). */
1088
+ async joinProfile ({ acta } = {}) { return joinProfile(acta) },
1089
+
881
1090
  // ----- emparejar ESTE dispositivo con el vault del usuario (Fase 1) -----
882
1091
  // Genera D aquí dentro (su privada NUNCA sale de la identidad), hace el enroll
883
1092
  // endurecido por el proxy y guarda el cert. NO cambia signData todavía (Fase 2).
@@ -887,12 +1096,20 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
887
1096
  // La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
888
1097
  // y NO se persiste ningún JWK del dispositivo (marcador useIdentityKey).
889
1098
  const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
890
- const res = await remoteEnroll({ qr, device, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
1099
+ // Si esta identidad ya existía por su cuenta, se lleva un certificado de continuidad
1100
+ // firmado por ella misma: es el puente para que su reputación previa siga contando.
1101
+ const mio = loadActa()
1102
+ const continuity = (mio && mio.members.length === 1)
1103
+ ? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
1104
+ : null
1105
+ const res = await remoteEnroll({ qr, device, continuity, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
891
1106
  kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
892
1107
  kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
893
- emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master })
1108
+ // Conectarse a una bóveda es ENTRAR A SU PERFIL: el acta viene con el cert.
1109
+ const join = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
1110
+ emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join })
894
1111
  pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
895
- return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope }
1112
+ return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join }
896
1113
  },
897
1114
 
898
1115
  async vaultStatus () {
@@ -935,8 +1152,12 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
935
1152
  const v = loadVaultCert(); const device = loadVaultDevice()
936
1153
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
937
1154
  maybeRenewVaultCert()
938
- try { return await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, onRevoked: wipeVaultLink }) }
939
- catch (e) { return handleVaultError(e) }
1155
+ try {
1156
+ const res = await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, onRevoked: wipeVaultLink })
1157
+ // El acta viaja con la lista: así los cambios de política llegan sin canal aparte.
1158
+ if (res.acta) { try { await (res.acta.profileId === loadActa()?.profileId ? adoptActa(res.acta) : joinProfile(res.acta)) } catch (_) {} }
1159
+ return res
1160
+ } catch (e) { return handleVaultError(e) }
940
1161
  },
941
1162
 
942
1163
  // El cert de delegación de este dispositivo (para presentarlo al proxy en `identify`
@@ -1128,6 +1349,9 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1128
1349
  me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
1129
1350
  kv.setItem(ME_STORAGE, JSON.stringify(me))
1130
1351
  }
1352
+ // Acta de perfil: si no existe, nace ahora (un miembro, este dispositivo, que es el master).
1353
+ try { await ensureActa() } catch (e) { console.warn('[identity] no se pudo crear el acta de perfil:', e.message) }
1354
+
1131
1355
  // Perfil compartido: jalar del vault en background (gana el más nuevo).
1132
1356
  pullProfileFromVault()
1133
1357
 
package/vault/remote.js CHANGED
@@ -56,7 +56,7 @@ async function identifyAsDevice (client, device) {
56
56
  * @param {number} [opts.approveTimeoutMs] Espera de la aprobación humana (def 3 min).
57
57
  * @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string}>}
58
58
  */
59
- export async function enrollDevice ({ qr, device, onChallenge, label = '', approveTimeoutMs = 180000 } = {}) {
59
+ export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, approveTimeoutMs = 180000 } = {}) {
60
60
  if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
61
61
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
62
62
  const client = new WebSocketProxyClient({ url: qr.proxy, enableWebRTC: false, autoReconnect: false })
@@ -75,7 +75,9 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', appro
75
75
  // haber leído el código de ESTA pantalla. Y al ECHARLO de vuelta, el dispositivo confía:
76
76
  // una bóveda falsa no conoce el código y no puede enrolarlo.
77
77
  const commit = await commitCode({ code, dpub: dev.publickey, sn: qr.sn })
78
- const data = { op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
78
+ // `continuity`: si esta identidad ya existía, va firmada por ella misma para que lo
79
+ // que hizo antes se pueda seguir atribuyendo a la misma persona (ver acta.js).
80
+ const data = { op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now(), ...(continuity ? { continuity } : {}) }
79
81
  const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, data })
80
82
 
81
83
  const enrolled = new Promise((resolve, reject) => {
@@ -98,7 +100,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', appro
98
100
  if (!v.ok) throw new Error('cert inválido: ' + v.reason)
99
101
  if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la que viste')
100
102
  if (res.cert.sub !== dev.publickey) throw new Error('cert emitido para otro dispositivo')
101
- return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId }
103
+ return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta || null }
102
104
  } finally { try { client.close() } catch (_) {} }
103
105
  }
104
106
 
@@ -161,7 +163,7 @@ export async function requestStore ({ master, proxy, device, cert, method, args,
161
163
  /** Lista (solo lectura) los dispositivos enrolados en tu vault. */
162
164
  export async function requestDevices ({ master, proxy, device, cert, onRevoked } = {}) {
163
165
  const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.devices', okType: 'vault.devices.result', data: { op: 'devices' } })
164
- return { devices: res.devices || [], revoked: res.revoked || [] }
166
+ return { devices: res.devices || [], revoked: res.revoked || [], acta: res.acta || null }
165
167
  }
166
168
 
167
169
  /**
package/vault/vault.js CHANGED
@@ -110,12 +110,16 @@ import { pubkeyId } from './capabilities.js'
110
110
 
111
111
  // Adaptador: startDeviceVault exige identity.{me.publickey, signData, signDelegation,
112
112
  // listDelegations, revokeDelegation}; el core los expone vía handlers + getter me.
113
+ // `admitMember`/`profileActa` son opcionales para el mostrador de enrolamiento, pero sin
114
+ // ellos aprobar emitiría el cert SIN meter al dispositivo en el acta: se pasan también.
113
115
  const selfIdentity = {
114
116
  get me () { return core.me },
115
117
  signData: (data) => handlers.signData({ data }),
116
118
  signDelegation: (sub, scope, opts) => handlers.signDelegation({ sub, scope, ...(opts || {}) }),
117
119
  listDelegations: () => handlers.listDelegations({}),
118
- revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce })
120
+ revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce }),
121
+ admitMember: (m) => handlers.admitMember(m),
122
+ profileActa: () => handlers.profileActa({})
119
123
  }
120
124
 
121
125
  async function startSelfDaemon () {
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.3.0 (lib/src/{index,enroll}.js, sin dependencias).
1
+ Copia vendorizada de @dotrino/vault@0.6.0 (lib/src/{index,enroll}.js, sin dependencias).
2
2
  El iframe de identity se sirve estatico (vanilla, sin build); asi startDeviceVault
3
3
  resuelve en el navegador sin bundler. index.js importa ./enroll.js (relativo, se
4
4
  vendoriza tambien) y @dotrino/identity/capabilities (=../../capabilities.js) y
@@ -30,6 +30,7 @@
30
30
  * `docs/pairing-protocol.md`).
31
31
  */
32
32
  import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
33
+ import { verifyContinuity } from '@dotrino/identity/acta'
33
34
 
34
35
  /** Un token de emparejamiento vale 5 min. */
35
36
  export const PAIRING_TTL_MS = 5 * 60 * 1000
@@ -44,6 +45,11 @@ export const MSG_ENROLLED = 'vault.enrolled'
44
45
  export const MSG_REVOKED = 'vault.revoked'
45
46
  export const MSG_ERROR = 'vault.error'
46
47
 
48
+ /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
49
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
50
+ export const scopeToCaps = (scope) =>
51
+ (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
52
+
47
53
  /** Token aleatorio de 128 bits en hex. */
48
54
  export function randToken () {
49
55
  const b = crypto.getRandomValues(new Uint8Array(16))
@@ -152,6 +158,12 @@ export function createEnrollDesk ({
152
158
  pend.dpub = d.dpub
153
159
  pend.deviceId = deviceId
154
160
  pend.commit = d.commit
161
+ // Certificado de continuidad (opcional): lo firma la identidad que se une, con su
162
+ // propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
163
+ if (d.continuity) {
164
+ const okC = await verifyContinuity(d.continuity)
165
+ pend.continuity = (okC && d.continuity.member === d.dpub) ? d.continuity : null
166
+ }
155
167
  pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
156
168
  if (d.label) pend.label = String(d.label).slice(0, 60)
157
169
 
@@ -193,10 +205,24 @@ export function createEnrollDesk ({
193
205
  }
194
206
 
195
207
  const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
208
+
209
+ // Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
210
+ // credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
211
+ // Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
212
+ let acta = null
213
+ try {
214
+ if (typeof identity.admitMember === 'function') {
215
+ const caps = scopeToCaps(pend.scope)
216
+ if (caps.length) await identity.admitMember({ pub: pend.dpub, label: pend.label || '', caps, cert, continuity: pend.continuity || null })
217
+ }
218
+ acta = (await identity.profileActa?.())?.acta || null
219
+ } catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
220
+
196
221
  audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
197
222
  // Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
198
223
  // con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
199
- reply(pend.from, { type: MSG_ENROLLED, code, cert, iss })
224
+ // El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
225
+ reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta })
200
226
  pend.state = 'DONE'
201
227
  pending.delete(pend.token)
202
228
  fire(onPendingChange)