@dotrino/identity 0.23.0 → 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 +44 -0
- package/src/node.js +12 -0
- package/vault/acta.js +315 -0
- package/vault/core.js +227 -9
- package/vault/remote.js +2 -2
- package/vault/vault.js +5 -1
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/enroll.js +20 -1
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
|
/**
|
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
|
+
}
|
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
|
|
787
|
-
|
|
788
|
-
|
|
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).
|
|
@@ -890,9 +1099,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
890
1099
|
const res = await remoteEnroll({ qr, device, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
891
1100
|
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
892
1101
|
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
893
|
-
|
|
1102
|
+
// Conectarse a una bóveda es ENTRAR A SU PERFIL: el acta viene con el cert.
|
|
1103
|
+
const join = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
|
|
1104
|
+
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join })
|
|
894
1105
|
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 }
|
|
1106
|
+
return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join }
|
|
896
1107
|
},
|
|
897
1108
|
|
|
898
1109
|
async vaultStatus () {
|
|
@@ -935,8 +1146,12 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
935
1146
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
936
1147
|
if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
|
|
937
1148
|
maybeRenewVaultCert()
|
|
938
|
-
try {
|
|
939
|
-
|
|
1149
|
+
try {
|
|
1150
|
+
const res = await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, onRevoked: wipeVaultLink })
|
|
1151
|
+
// El acta viaja con la lista: así los cambios de política llegan sin canal aparte.
|
|
1152
|
+
if (res.acta) { try { await (res.acta.profileId === loadActa()?.profileId ? adoptActa(res.acta) : joinProfile(res.acta)) } catch (_) {} }
|
|
1153
|
+
return res
|
|
1154
|
+
} catch (e) { return handleVaultError(e) }
|
|
940
1155
|
},
|
|
941
1156
|
|
|
942
1157
|
// El cert de delegación de este dispositivo (para presentarlo al proxy en `identify`
|
|
@@ -1128,6 +1343,9 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1128
1343
|
me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
|
|
1129
1344
|
kv.setItem(ME_STORAGE, JSON.stringify(me))
|
|
1130
1345
|
}
|
|
1346
|
+
// Acta de perfil: si no existe, nace ahora (un miembro, este dispositivo, que es el master).
|
|
1347
|
+
try { await ensureActa() } catch (e) { console.warn('[identity] no se pudo crear el acta de perfil:', e.message) }
|
|
1348
|
+
|
|
1131
1349
|
// Perfil compartido: jalar del vault en background (gana el más nuevo).
|
|
1132
1350
|
pullProfileFromVault()
|
|
1133
1351
|
|
package/vault/remote.js
CHANGED
|
@@ -98,7 +98,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', appro
|
|
|
98
98
|
if (!v.ok) throw new Error('cert inválido: ' + v.reason)
|
|
99
99
|
if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la que viste')
|
|
100
100
|
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 }
|
|
101
|
+
return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta || null }
|
|
102
102
|
} finally { try { client.close() } catch (_) {} }
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -161,7 +161,7 @@ export async function requestStore ({ master, proxy, device, cert, method, args,
|
|
|
161
161
|
/** Lista (solo lectura) los dispositivos enrolados en tu vault. */
|
|
162
162
|
export async function requestDevices ({ master, proxy, device, cert, onRevoked } = {}) {
|
|
163
163
|
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 || [] }
|
|
164
|
+
return { devices: res.devices || [], revoked: res.revoked || [], acta: res.acta || null }
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
/**
|
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.
|
|
1
|
+
Copia vendorizada de @dotrino/vault@0.5.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
|
|
@@ -44,6 +44,11 @@ export const MSG_ENROLLED = 'vault.enrolled'
|
|
|
44
44
|
export const MSG_REVOKED = 'vault.revoked'
|
|
45
45
|
export const MSG_ERROR = 'vault.error'
|
|
46
46
|
|
|
47
|
+
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
48
|
+
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
|
|
49
|
+
export const scopeToCaps = (scope) =>
|
|
50
|
+
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
51
|
+
|
|
47
52
|
/** Token aleatorio de 128 bits en hex. */
|
|
48
53
|
export function randToken () {
|
|
49
54
|
const b = crypto.getRandomValues(new Uint8Array(16))
|
|
@@ -193,10 +198,24 @@ export function createEnrollDesk ({
|
|
|
193
198
|
}
|
|
194
199
|
|
|
195
200
|
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
201
|
+
|
|
202
|
+
// Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
|
|
203
|
+
// credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
|
|
204
|
+
// Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
|
|
205
|
+
let acta = null
|
|
206
|
+
try {
|
|
207
|
+
if (typeof identity.admitMember === 'function') {
|
|
208
|
+
const caps = scopeToCaps(pend.scope)
|
|
209
|
+
if (caps.length) await identity.admitMember({ pub: pend.dpub, label: pend.label || '', caps, cert })
|
|
210
|
+
}
|
|
211
|
+
acta = (await identity.profileActa?.())?.acta || null
|
|
212
|
+
} catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
|
|
213
|
+
|
|
196
214
|
audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
|
|
197
215
|
// Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
|
|
198
216
|
// con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
|
|
199
|
-
|
|
217
|
+
// El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
|
|
218
|
+
reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta })
|
|
200
219
|
pend.state = 'DONE'
|
|
201
220
|
pending.delete(pend.token)
|
|
202
221
|
fire(onPendingChange)
|