@dotrino/identity 0.37.1 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +19 -0
- package/src/node.js +1 -0
- package/vault/acta.js +46 -21
- package/vault/content.js +2 -2
- package/vault/core.js +64 -20
- package/vault/index.html +2 -1
- package/vault/peerStore.js +3 -3
- package/vault/remote.js +48 -11
- package/vault/vault.js +8 -4
- package/vault/vendor/vault/VERSION.txt +5 -5
- package/vault/vendor/vault/enroll.js +190 -32
- package/vault/vendor/vault/index.js +70 -18
- package/vault/vendor/vault/protocol.js +93 -0
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -278,6 +278,7 @@ export class Identity {
|
|
|
278
278
|
|
|
279
279
|
/** Cambia las capacidades de un miembro (solo el master). */
|
|
280
280
|
async setCaps (pub, caps) { return this._call('setCaps', { pub, caps }) }
|
|
281
|
+
async setLabel (pub, label) { return this._call('setLabel', { pub, label }) }
|
|
281
282
|
|
|
282
283
|
/** Expulsa a un miembro (solo el master; al master no se le puede expulsar). */
|
|
283
284
|
async removeMember (pub) { return this._call('removeMember', { pub }) }
|
|
@@ -371,6 +372,24 @@ export class Identity {
|
|
|
371
372
|
return this._call('vaultStore', { method, args }, 20000)
|
|
372
373
|
}
|
|
373
374
|
|
|
375
|
+
/**
|
|
376
|
+
* CONSOLA REMOTA: administra el perfil desde este dispositivo, contra la bóveda
|
|
377
|
+
* (`dotrino-vault/docs/consola-remota.md`). `op`: `pending` · `pair` · `approve` ·
|
|
378
|
+
* `reject` · `revoke` · `audit`.
|
|
379
|
+
*
|
|
380
|
+
* Requiere que el cert de este aparato lleve `vault:admin`, que **no se recibe al
|
|
381
|
+
* emparejar**: se concede a mano en la bóveda. Cambiar permisos y traspasar el mando
|
|
382
|
+
* NO se administran a distancia — siguen siendo del master, en su máquina.
|
|
383
|
+
*/
|
|
384
|
+
async vaultAdmin (op, args) {
|
|
385
|
+
return this._call('vaultAdmin', { op, ...(args || {}) }, 20000)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** ¿El cert de este dispositivo le permite administrar el perfil a distancia? */
|
|
389
|
+
async canAdminVault () {
|
|
390
|
+
return this._call('canAdminVault', {}, 20000)
|
|
391
|
+
}
|
|
392
|
+
|
|
374
393
|
/** Lista (solo lectura) los dispositivos enrolados en tu vault: { devices, revoked }. */
|
|
375
394
|
async listVaultDevices () {
|
|
376
395
|
return this._call('listVaultDevices', {}, 20000)
|
package/src/node.js
CHANGED
|
@@ -168,6 +168,7 @@ export class Identity {
|
|
|
168
168
|
isMaster () { return this._h('isMaster') }
|
|
169
169
|
admitMember (member) { return this._h('admitMember', member) }
|
|
170
170
|
setCaps (pub, caps) { return this._h('setCaps', { pub, caps }) }
|
|
171
|
+
setLabel (pub, label) { return this._h('setLabel', { pub, label }) }
|
|
171
172
|
removeMember (pub) { return this._h('removeMember', { pub }) }
|
|
172
173
|
handoverMaster (to, member = null) { return this._h('handoverMaster', { to, member }) }
|
|
173
174
|
renounceCaps (caps) { return this._h('renounceCaps', { caps }) }
|
package/vault/acta.js
CHANGED
|
@@ -35,13 +35,26 @@ export const ACTA_V = 1
|
|
|
35
35
|
/**
|
|
36
36
|
* Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master.
|
|
37
37
|
*
|
|
38
|
-
* `secrets` es distinta de las otras
|
|
38
|
+
* `secrets` es distinta de las otras: solo la pueden tener los miembros con **CN**
|
|
39
39
|
* (los servicios), y lo que abre es únicamente el cajón de SU nombre. Ver `cn` abajo.
|
|
40
|
+
*
|
|
41
|
+
* `admin` es la rendija que se le abre a la consola remota (`dotrino-vault/docs/
|
|
42
|
+
* consola-remota.md`): deja **admitir y expulsar** miembros a distancia, y nada más.
|
|
43
|
+
* NO deja cambiar permisos, traspasar el mando ni conceder `admin` — eso sigue siendo
|
|
44
|
+
* el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
|
|
45
|
+
* acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
|
|
40
46
|
*/
|
|
41
|
-
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets'])
|
|
47
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin'])
|
|
42
48
|
|
|
43
49
|
/** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
|
|
44
|
-
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read'])
|
|
50
|
+
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin'])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
|
|
54
|
+
* empareja, se concede después y a mano (`dotrino-vault caps <ID> +admin`), para que
|
|
55
|
+
* ningún QR que circule pueda otorgar administración.
|
|
56
|
+
*/
|
|
57
|
+
export const PAIRED_CAPS = Object.freeze(['sign', 'store', 'read'])
|
|
45
58
|
|
|
46
59
|
/** Capacidades de un SERVICIO (con CN): solo su propio cajón de secretos. */
|
|
47
60
|
export const SERVICE_CAPS = Object.freeze(['secrets'])
|
|
@@ -58,12 +71,13 @@ export function capScope (cap, cn = null) {
|
|
|
58
71
|
if (cap === 'sign') return 'vault:sign'
|
|
59
72
|
if (cap === 'store') return 'vault:store'
|
|
60
73
|
if (cap === 'read') return 'vault:read'
|
|
74
|
+
if (cap === 'admin') return 'vault:admin'
|
|
61
75
|
if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
|
|
62
76
|
return null
|
|
63
77
|
}
|
|
64
78
|
|
|
65
|
-
/** Compat: el mapa directo, para las
|
|
66
|
-
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read' })
|
|
79
|
+
/** Compat: el mapa directo, para las capacidades de dispositivo. */
|
|
80
|
+
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin' })
|
|
67
81
|
|
|
68
82
|
const enc = (s) => new TextEncoder().encode(s)
|
|
69
83
|
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
@@ -99,7 +113,7 @@ export const isHandover = (acta) => !!acta && acta.sealer !== acta.sealedBy
|
|
|
99
113
|
* estable para siempre y coincide con la identidad que el usuario ya tenía (cero migración).
|
|
100
114
|
*/
|
|
101
115
|
export function genesisActa ({ pub, encPub = null, label = '', now = Date.now() }) {
|
|
102
|
-
if (!isPub(pub)) throw new Error('genesisActa:
|
|
116
|
+
if (!isPub(pub)) throw new Error('genesisActa: missing genesis pubkey')
|
|
103
117
|
return {
|
|
104
118
|
v: ACTA_V,
|
|
105
119
|
profileId: pub,
|
|
@@ -107,7 +121,7 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
|
|
|
107
121
|
sealedBy: pub,
|
|
108
122
|
seq: 1,
|
|
109
123
|
prev: null,
|
|
110
|
-
members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...
|
|
124
|
+
members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...PAIRED_CAPS], addedAt: now, cert: null }],
|
|
111
125
|
revoked: [],
|
|
112
126
|
renounced: [],
|
|
113
127
|
// Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
|
|
@@ -147,7 +161,7 @@ export function checkShape (acta) {
|
|
|
147
161
|
/** Sella (firma) un acta. `privateKey` puede ser una CryptoKey no extractable. */
|
|
148
162
|
export async function sealActa ({ acta, privateKey, privateJwk }) {
|
|
149
163
|
const shape = checkShape(acta)
|
|
150
|
-
if (shape) throw new Error('
|
|
164
|
+
if (shape) throw new Error('invalid record: ' + shape)
|
|
151
165
|
const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: actaBody(acta) })
|
|
152
166
|
const sealed = { ...acta, sig: signature }
|
|
153
167
|
// La TARJETA se firma en el mismo gesto y viaja con el acta: así cualquier miembro puede
|
|
@@ -181,9 +195,9 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
|
|
|
181
195
|
*/
|
|
182
196
|
export async function applyChanges (acta, changes, { by, now = Date.now() } = {}) {
|
|
183
197
|
const shape = checkShape(acta)
|
|
184
|
-
if (shape) throw new Error('
|
|
185
|
-
if (!by) throw new Error('applyChanges:
|
|
186
|
-
if (by !== acta.sealer) throw new Error('
|
|
198
|
+
if (shape) throw new Error('invalid record: ' + shape)
|
|
199
|
+
if (!by) throw new Error('applyChanges: missing `by` (who seals)')
|
|
200
|
+
if (by !== acta.sealer) throw new Error('only the master can change the record; this device is not the master')
|
|
187
201
|
|
|
188
202
|
const list = Array.isArray(changes) ? changes : [changes]
|
|
189
203
|
if (list.length === 0) throw new Error('applyChanges: no hay cambios')
|
|
@@ -208,9 +222,9 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
208
222
|
case 'admit': {
|
|
209
223
|
const m = ch.member
|
|
210
224
|
if (!isPub(m?.pub)) throw new Error('admit: falta la pubkey del miembro')
|
|
211
|
-
if (find(m.pub)) throw new Error('admit:
|
|
225
|
+
if (find(m.pub)) throw new Error('admit: that member is already in the record')
|
|
212
226
|
const cn = m.cn != null ? String(m.cn) : null
|
|
213
|
-
if (cn !== null && !isValidCn(cn)) throw new Error('admit: CN
|
|
227
|
+
if (cn !== null && !isValidCn(cn)) throw new Error('admit: invalid CN (lowercase, digits and hyphens)')
|
|
214
228
|
next.members.push({
|
|
215
229
|
pub: m.pub,
|
|
216
230
|
encPub: m.encPub || null,
|
|
@@ -227,16 +241,27 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
227
241
|
}
|
|
228
242
|
case 'caps': {
|
|
229
243
|
const m = find(ch.pub)
|
|
230
|
-
if (!m) throw new Error('caps:
|
|
244
|
+
if (!m) throw new Error('caps: that member is not in the record')
|
|
231
245
|
// No se puede ascender un servicio a dispositivo cambiándole las capacidades: para
|
|
232
246
|
// eso hay que sacarlo y volver a admitirlo, que es un gesto visible.
|
|
233
247
|
m.caps = cleanCaps(ch.caps).filter((c) => (m.cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c))
|
|
234
248
|
break
|
|
235
249
|
}
|
|
250
|
+
case 'label': {
|
|
251
|
+
// RENOMBRAR un miembro. La etiqueta se escribía solo al admitir, con lo que el
|
|
252
|
+
// aparato se quedaba para siempre con el nombre que tuviera el día que entró
|
|
253
|
+
// (normalmente el apodo del usuario en ese momento), y para cambiarlo había que
|
|
254
|
+
// revocarlo y volver a emparejarlo. Es un nombre para el humano: no toca permisos
|
|
255
|
+
// ni llaves, pero se sella y se firma como cualquier otro cambio del acta.
|
|
256
|
+
const m = find(ch.pub)
|
|
257
|
+
if (!m) throw new Error('label: that member is not in the record')
|
|
258
|
+
m.label = String(ch.label || '').slice(0, 60)
|
|
259
|
+
break
|
|
260
|
+
}
|
|
236
261
|
case 'remove': {
|
|
237
262
|
const i = next.members.findIndex((m) => m.pub === ch.pub)
|
|
238
|
-
if (i < 0) throw new Error('remove:
|
|
239
|
-
if (next.members[i].pub === next.sealer) throw new Error('remove:
|
|
263
|
+
if (i < 0) throw new Error('remove: that member is not in the record')
|
|
264
|
+
if (next.members[i].pub === next.sealer) throw new Error('remove: cannot remove the master; hand the sealing over first')
|
|
240
265
|
const fuera = next.members.splice(i, 1)[0]
|
|
241
266
|
// Sus envolturas se van con él: sin ellas no puede abrir ninguna generación. (El
|
|
242
267
|
// acceso al contenido FUTURO se corta rotando, ver content.js; lo ya leído no vuelve.)
|
|
@@ -247,21 +272,21 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
247
272
|
break
|
|
248
273
|
}
|
|
249
274
|
case 'handover': {
|
|
250
|
-
if (!find(ch.to)) throw new Error('handover:
|
|
275
|
+
if (!find(ch.to)) throw new Error('handover: the new master must be a member (admit them in the same change)')
|
|
251
276
|
next.sealer = ch.to
|
|
252
277
|
break
|
|
253
278
|
}
|
|
254
279
|
case 'keyring': {
|
|
255
280
|
// Generación NUEVA de la clave de contenido (al rotar: expulsar a alguien).
|
|
256
281
|
const g = ch.generation
|
|
257
|
-
if (!g || !Number.isInteger(g.gen)) throw new Error('keyring:
|
|
282
|
+
if (!g || !Number.isInteger(g.gen)) throw new Error('keyring: invalid generation')
|
|
258
283
|
next.keyring = [...next.keyring.filter((x) => x.gen !== g.gen), g].sort((a, b) => a.gen - b.gen)
|
|
259
284
|
break
|
|
260
285
|
}
|
|
261
286
|
case 'wrap': {
|
|
262
287
|
// Envolver la clave YA existente para un miembro nuevo (al admitir: no hace falta rotar).
|
|
263
288
|
const g = next.keyring.find((x) => x.gen === ch.gen)
|
|
264
|
-
if (!g) throw new Error('wrap:
|
|
289
|
+
if (!g) throw new Error('wrap: that generation is not in the keyring')
|
|
265
290
|
g.wraps = { ...g.wraps, [ch.pub]: ch.wrap }
|
|
266
291
|
break
|
|
267
292
|
}
|
|
@@ -290,10 +315,10 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
|
|
|
290
315
|
|
|
291
316
|
// Reglas de cierre: sin firmante no se puede operar, y sin sellador no se puede cambiar.
|
|
292
317
|
if (!next.members.some((m) => m.caps.includes('sign'))) {
|
|
293
|
-
throw new Error('
|
|
318
|
+
throw new Error('the change would leave the profile with no member able to sign')
|
|
294
319
|
}
|
|
295
320
|
if (!next.members.some((m) => m.pub === next.sealer)) {
|
|
296
|
-
throw new Error('
|
|
321
|
+
throw new Error('the change would leave the record with no master')
|
|
297
322
|
}
|
|
298
323
|
return next
|
|
299
324
|
}
|
package/vault/content.js
CHANGED
|
@@ -70,7 +70,7 @@ export async function wrapForMember ({ cek, memberEncPub }) {
|
|
|
70
70
|
|
|
71
71
|
/** Abre la envoltura con la llave de cifrado privada de ESTE miembro. */
|
|
72
72
|
export async function openWrap ({ wrap, myEncPrivateKey }) {
|
|
73
|
-
if (!wrap?.epk || !wrap?.iv || !wrap?.ct) throw new Error('
|
|
73
|
+
if (!wrap?.epk || !wrap?.iv || !wrap?.ct) throw new Error('invalid wrap')
|
|
74
74
|
const key = await sharedKey(myEncPrivateKey, wrap.epk)
|
|
75
75
|
const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(wrap.iv) }, key, fromB64(wrap.ct))
|
|
76
76
|
return new TextDecoder().decode(pt)
|
|
@@ -119,7 +119,7 @@ export async function encryptWithCek ({ cek, gen, plaintext }) {
|
|
|
119
119
|
export async function decryptWithKeyring ({ envelope, keyring, myPub, myEncPrivateKey }) {
|
|
120
120
|
const g = (keyring || []).find((x) => x.gen === envelope?.gen)
|
|
121
121
|
const w = g?.wraps?.[myPub]
|
|
122
|
-
if (!w) throw new Error('
|
|
122
|
+
if (!w) throw new Error('this device does not hold the key for that content generation')
|
|
123
123
|
const cek = await openWrap({ wrap: w, myEncPrivateKey })
|
|
124
124
|
const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['decrypt'])
|
|
125
125
|
const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(envelope.iv) }, k, fromB64(envelope.ct))
|
package/vault/core.js
CHANGED
|
@@ -22,7 +22,7 @@ import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './
|
|
|
22
22
|
import * as Acta from './acta.js'
|
|
23
23
|
import * as Content from './content.js'
|
|
24
24
|
import { pubkeyId as pubkeyIdOf } from './capabilities.js'
|
|
25
|
-
import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew } from './remote.js'
|
|
25
|
+
import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin } from './remote.js'
|
|
26
26
|
|
|
27
27
|
export const KEY_STORAGE = 'dotrino.identity.keypair'
|
|
28
28
|
export const ENC_KEY_STORAGE = 'dotrino.identity.enc-keypair'
|
|
@@ -400,7 +400,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
400
400
|
*/
|
|
401
401
|
async function sealChanges (changes) {
|
|
402
402
|
const acta = loadActa()
|
|
403
|
-
if (!acta) throw new Error('
|
|
403
|
+
if (!acta) throw new Error('this profile has no record yet')
|
|
404
404
|
const next = await Acta.applyChanges(acta, changes, { by: publickeyJwkStr })
|
|
405
405
|
const sealed = await seal(next)
|
|
406
406
|
pushHistory(acta) // la que deja de ser vigente entra en la ventana de retención
|
|
@@ -815,7 +815,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
815
815
|
const proof = await derivePwd(password, pwd.salt, pwd.iter)
|
|
816
816
|
if (proof !== pwd.verifier) {
|
|
817
817
|
kv.setItem('dotrino.identity.pwd.tries', JSON.stringify({ n: tries.n + 1, at: Date.now() }))
|
|
818
|
-
throw new Error('
|
|
818
|
+
throw new Error('wrong password')
|
|
819
819
|
}
|
|
820
820
|
kv.removeItem('dotrino.identity.pwd.tries')
|
|
821
821
|
try { sessionKv?.setItem(_scoped(PWD_SESSION), proof) } catch (_) {}
|
|
@@ -825,7 +825,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
825
825
|
// Poner/cambiar contraseña (requiere estar desbloqueado; cambiar exige la actual vía unlock previo).
|
|
826
826
|
async setProfilePassword ({ password }) {
|
|
827
827
|
if (locked) throw new Error('perfil bloqueado')
|
|
828
|
-
if (!password || String(password).length < 4) throw new Error('
|
|
828
|
+
if (!password || String(password).length < 4) throw new Error('password must be at least 4 characters')
|
|
829
829
|
const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
|
|
830
830
|
const verifier = await derivePwd(password, salt, PWD_ITER)
|
|
831
831
|
kv.setItem(PWD_STORAGE, JSON.stringify({ v: 1, salt, iter: PWD_ITER, verifier }))
|
|
@@ -1004,7 +1004,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1004
1004
|
|
|
1005
1005
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1006
1006
|
if (!v?.cert || !device) {
|
|
1007
|
-
throw new Error('
|
|
1007
|
+
throw new Error('profile-without-signer: this device no longer signs for you and is not connected to any vault that can')
|
|
1008
1008
|
}
|
|
1009
1009
|
maybeRenewVaultCert()
|
|
1010
1010
|
try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload: data, onRevoked: wipeVaultLink }) }
|
|
@@ -1109,7 +1109,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1109
1109
|
},
|
|
1110
1110
|
async deleteProfile ({ id } = {}) {
|
|
1111
1111
|
let list = loadProfiles()
|
|
1112
|
-
if (list.length <= 1) throw new Error('
|
|
1112
|
+
if (list.length <= 1) throw new Error('cannot delete the only profile')
|
|
1113
1113
|
if (!list.find((p) => p.id === id)) throw new Error('perfil no existe')
|
|
1114
1114
|
list = list.filter((p) => p.id !== id); saveProfiles(list)
|
|
1115
1115
|
// Borrado directo del namespace del perfil (incluye su store del vault si lo tuviera).
|
|
@@ -1191,6 +1191,22 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1191
1191
|
return { ok: true, seq: acta.seq }
|
|
1192
1192
|
},
|
|
1193
1193
|
|
|
1194
|
+
/**
|
|
1195
|
+
* RENOMBRA un miembro (el nombre con el que lo reconoces). Se escribe en el acta y
|
|
1196
|
+
* TAMBIÉN en la delegación: son dos registros distintos —el acta dice quién es del
|
|
1197
|
+
* perfil, las delegaciones qué certs se emitieron— y las listas de dispositivos leen
|
|
1198
|
+
* la segunda, así que tocar solo una deja el nombre viejo a la vista.
|
|
1199
|
+
*/
|
|
1200
|
+
async setLabel ({ pub, label } = {}) {
|
|
1201
|
+
const limpio = String(label || '').slice(0, 60)
|
|
1202
|
+
const acta = await sealChanges([{ op: 'label', pub, label: limpio }])
|
|
1203
|
+
const store = loadDelegations()
|
|
1204
|
+
let tocadas = 0
|
|
1205
|
+
for (const d of Object.values(store)) { if (d.sub === pub) { d.label = limpio; tocadas++ } }
|
|
1206
|
+
if (tocadas) saveDelegations(store)
|
|
1207
|
+
return { ok: true, seq: acta.seq, label: limpio, delegations: tocadas }
|
|
1208
|
+
},
|
|
1209
|
+
|
|
1194
1210
|
async removeMember ({ pub } = {}) {
|
|
1195
1211
|
const acta = await sealChanges([{ op: 'remove', pub }])
|
|
1196
1212
|
// Expulsar rota la clave: el que sale no podrá abrir el contenido NUEVO. Lo que ya
|
|
@@ -1220,7 +1236,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1220
1236
|
*/
|
|
1221
1237
|
async renounceCaps ({ caps } = {}) {
|
|
1222
1238
|
const acta = loadActa()
|
|
1223
|
-
if (!acta) throw new Error('
|
|
1239
|
+
if (!acta) throw new Error('this profile has no record yet')
|
|
1224
1240
|
const record = await Acta.makeRenounce({ member: publickeyJwkStr, caps, privateKey: keypair.privateKey })
|
|
1225
1241
|
const pend = loadRenounces().filter((r) => r.member !== publickeyJwkStr)
|
|
1226
1242
|
pend.push(record)
|
|
@@ -1233,7 +1249,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1233
1249
|
|
|
1234
1250
|
/** Absorbe en el acta una renuncia ajena ya verificada (solo el master). */
|
|
1235
1251
|
async absorbRenounce ({ record } = {}) {
|
|
1236
|
-
if (!(await Acta.verifyRenounce(record))) throw new Error('
|
|
1252
|
+
if (!(await Acta.verifyRenounce(record))) throw new Error('invalid renounce: the signature is not the member own')
|
|
1237
1253
|
const acta = await sealChanges([{ op: 'renounce', record }])
|
|
1238
1254
|
return { ok: true, seq: acta.seq }
|
|
1239
1255
|
},
|
|
@@ -1250,7 +1266,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1250
1266
|
*/
|
|
1251
1267
|
async sealContent ({ plaintext } = {}) {
|
|
1252
1268
|
const mine = await myCek()
|
|
1253
|
-
if (!mine) throw new Error('
|
|
1269
|
+
if (!mine) throw new Error('this device does not hold the profile content key yet')
|
|
1254
1270
|
return Content.encryptWithCek({ cek: mine.cek, gen: mine.gen, plaintext: String(plaintext) })
|
|
1255
1271
|
},
|
|
1256
1272
|
|
|
@@ -1294,7 +1310,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1294
1310
|
* y la firmó el mismo master. Si el master cambió, se avisa en vez de aceptarlo callando.
|
|
1295
1311
|
*/
|
|
1296
1312
|
async adoptPeerCard ({ card } = {}) {
|
|
1297
|
-
if (!card?.profileId) throw new Error('
|
|
1313
|
+
if (!card?.profileId) throw new Error('invalid card')
|
|
1298
1314
|
const peers = loadPeers()
|
|
1299
1315
|
const prev = peers[card.profileId]?.card || null
|
|
1300
1316
|
const r = await Acta.canAdoptCard({ card, current: prev })
|
|
@@ -1339,7 +1355,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1339
1355
|
} else {
|
|
1340
1356
|
const yaConEsta = loadVaultCert()?.master === qr?.iss
|
|
1341
1357
|
if (loadActa() && !isPendingJoin() && !yaConEsta) {
|
|
1342
|
-
throw new Error('
|
|
1358
|
+
throw new Error('this device is already using an account: to also use your vault account, create a new account here (the open one is untouched)')
|
|
1343
1359
|
}
|
|
1344
1360
|
}
|
|
1345
1361
|
// Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
|
|
@@ -1381,8 +1397,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1381
1397
|
*/
|
|
1382
1398
|
async vaultAdopt ({ qr, label = '' } = {}) {
|
|
1383
1399
|
const mio = loadActa()
|
|
1384
|
-
if (!mio) throw new Error('
|
|
1385
|
-
if (!amMaster()) throw new Error('
|
|
1400
|
+
if (!mio) throw new Error('this device has no account to hand over yet')
|
|
1401
|
+
if (!amMaster()) throw new Error('not-the-master: another device or vault is in charge of this account; the handover is done from there')
|
|
1386
1402
|
|
|
1387
1403
|
const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
|
|
1388
1404
|
const res = await remoteEnroll({
|
|
@@ -1417,7 +1433,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1417
1433
|
const r = await adoptActa(res.acta)
|
|
1418
1434
|
const ok = r.adopted || r.reason === 'misma-acta'
|
|
1419
1435
|
emitVault({ phase: 'adopted', master: res.master, seq: res.acta?.seq, ok })
|
|
1420
|
-
if (!ok) throw new Error('
|
|
1436
|
+
if (!ok) throw new Error('the vault returned a record that does not fit: ' + r.reason)
|
|
1421
1437
|
return { ok: true, adopted: true, profileId: mio.profileId, seq: r.seq ?? res.acta?.seq, master: res.master, deviceId: res.deviceId }
|
|
1422
1438
|
},
|
|
1423
1439
|
|
|
@@ -1454,7 +1470,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1454
1470
|
// así nada se rompe si no estás emparejado o si el vault está apagado.
|
|
1455
1471
|
async vaultSign ({ payload }) {
|
|
1456
1472
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1457
|
-
if (!v?.cert || !device) throw new Error('
|
|
1473
|
+
if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
|
|
1458
1474
|
maybeRenewVaultCert()
|
|
1459
1475
|
try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload, onRevoked: wipeVaultLink }) }
|
|
1460
1476
|
catch (e) { return handleVaultError(e) }
|
|
@@ -1470,7 +1486,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1470
1486
|
*/
|
|
1471
1487
|
async vaultStore ({ method, args }) {
|
|
1472
1488
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1473
|
-
if (!v?.cert || !device) throw new Error('
|
|
1489
|
+
if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
|
|
1474
1490
|
maybeRenewVaultCert()
|
|
1475
1491
|
const mine = await myCek().catch(() => null)
|
|
1476
1492
|
let payload = { method, args }
|
|
@@ -1489,10 +1505,38 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1489
1505
|
} catch (e) { return handleVaultError(e) }
|
|
1490
1506
|
},
|
|
1491
1507
|
|
|
1508
|
+
/**
|
|
1509
|
+
* CONSOLA REMOTA: administrar el perfil desde este dispositivo (ver
|
|
1510
|
+
* `dotrino-vault/docs/consola-remota.md`). Requiere que el cert de este aparato
|
|
1511
|
+
* lleve `vault:admin`, que **no se recibe al emparejar**: se concede a mano en la
|
|
1512
|
+
* bóveda (`dotrino-vault caps <ID> +administra`).
|
|
1513
|
+
*
|
|
1514
|
+
* `op`: `pending` · `pair` · `approve` · `reject` · `revoke` · `audit`. Cambiar
|
|
1515
|
+
* permisos y traspasar el mando NO están, y no es un olvido: eso sigue siendo del
|
|
1516
|
+
* master, en su máquina.
|
|
1517
|
+
*/
|
|
1518
|
+
async vaultAdmin ({ op, ...rest } = {}) {
|
|
1519
|
+
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1520
|
+
if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
|
|
1521
|
+
maybeRenewVaultCert()
|
|
1522
|
+
try { return await remoteAdmin({ master: v.master, proxy: v.proxy, device, cert: v.cert, op, ...rest, onRevoked: wipeVaultLink }) }
|
|
1523
|
+
catch (e) { return handleVaultError(e) }
|
|
1524
|
+
},
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* ¿Puede ESTE dispositivo administrar el perfil a distancia? Sale del scope del
|
|
1528
|
+
* cert que le dio la bóveda, no de una preferencia: la interfaz pregunta para
|
|
1529
|
+
* saber qué pintar, pero quien decide es la bóveda al recibir la petición.
|
|
1530
|
+
*/
|
|
1531
|
+
canAdminVault () {
|
|
1532
|
+
const v = loadVaultCert()
|
|
1533
|
+
return !!v?.cert && (v.cert.scope || []).includes('vault:admin')
|
|
1534
|
+
},
|
|
1535
|
+
|
|
1492
1536
|
// Lista (solo lectura) de dispositivos enrolados en tu vault.
|
|
1493
1537
|
async listVaultDevices () {
|
|
1494
1538
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1495
|
-
if (!v?.cert || !device) throw new Error('
|
|
1539
|
+
if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
|
|
1496
1540
|
maybeRenewVaultCert()
|
|
1497
1541
|
try {
|
|
1498
1542
|
const res = await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, sinceSeq: loadActa()?.seq ?? 0, onRevoked: wipeVaultLink })
|
|
@@ -1603,7 +1647,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1603
1647
|
if (!envelope || (envelope.v !== 1 && envelope.v !== 2)) throw new Error('Unsupported envelope')
|
|
1604
1648
|
const myId = await encKeyId(encPublickeyJwkStr)
|
|
1605
1649
|
const myEntry = envelope.wrap && (envelope.wrap[myId] || (myToken ? envelope.wrap[myToken] : null))
|
|
1606
|
-
if (!myEntry) throw new Error('
|
|
1650
|
+
if (!myEntry) throw new Error('this device is not among the message recipients')
|
|
1607
1651
|
const senderPub = await importPeerEncPubkey(senderEncryptionPubkey)
|
|
1608
1652
|
const sharedKey = await deriveSharedAesKey(encKeypair.privateKey, senderPub)
|
|
1609
1653
|
const kRaw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: base64ToBuf(myEntry.iv) }, sharedKey, base64ToBuf(myEntry.ct))
|
|
@@ -1615,7 +1659,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1615
1659
|
async exportIdentity () {
|
|
1616
1660
|
const raw = kv.getItem(KEY_STORAGE)
|
|
1617
1661
|
if (!raw) {
|
|
1618
|
-
throw new Error('
|
|
1662
|
+
throw new Error('This profile stores its key as NON-exportable (theft protection). ' +
|
|
1619
1663
|
'Para usar tu identidad en otro navegador, conecta ese navegador a tu bóveda (vault) desde profile.dotrino.com.')
|
|
1620
1664
|
}
|
|
1621
1665
|
const keys = JSON.parse(raw)
|
|
@@ -1758,7 +1802,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1758
1802
|
const fn = handlers[name]
|
|
1759
1803
|
handlers[name] = async (params) => {
|
|
1760
1804
|
if (locked) refreshLockState() // otra pestaña pudo desbloquear… no: session es por pestaña; re-chequea por si se quitó el pwd
|
|
1761
|
-
if (locked) throw new Error('
|
|
1805
|
+
if (locked) throw new Error('profile locked: unlock it with your password (unlockProfile)')
|
|
1762
1806
|
return fn(params)
|
|
1763
1807
|
}
|
|
1764
1808
|
}
|
package/vault/index.html
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
{ "imports": {
|
|
26
26
|
"@dotrino/proxy-client": "./vendor/proxy-client/index.js",
|
|
27
27
|
"@dotrino/vault": "./vendor/vault/index.js",
|
|
28
|
-
"@dotrino/identity/capabilities": "./capabilities.js"
|
|
28
|
+
"@dotrino/identity/capabilities": "./capabilities.js",
|
|
29
|
+
"@dotrino/identity/acta": "./acta.js"
|
|
29
30
|
} }
|
|
30
31
|
</script>
|
|
31
32
|
<script type="module" src="./vault.js"></script>
|
package/vault/peerStore.js
CHANGED
|
@@ -96,7 +96,7 @@ export async function initPeerStorage () {
|
|
|
96
96
|
const stored = await idbGet(_idb, peersKey()) // peer book DEL perfil activo (namespaceado)
|
|
97
97
|
_peers = (stored && typeof stored === 'object') ? stored : {}
|
|
98
98
|
} catch (e) {
|
|
99
|
-
console.warn('[cc-identity] IndexedDB
|
|
99
|
+
console.warn('[cc-identity] IndexedDB unavailable, falling back to localStorage:', e?.message)
|
|
100
100
|
_fallback = true
|
|
101
101
|
_idb = null
|
|
102
102
|
try { const raw = localStorage.getItem(peersKey()); _peers = raw ? (JSON.parse(raw) || {}) : {} }
|
|
@@ -109,13 +109,13 @@ function persistPeers () {
|
|
|
109
109
|
const key = peersKey()
|
|
110
110
|
if (_fallback || !_idb) {
|
|
111
111
|
try { localStorage.setItem(key, JSON.stringify(_peers)) }
|
|
112
|
-
catch (e) { console.warn('[cc-identity] persist (ls)
|
|
112
|
+
catch (e) { console.warn('[cc-identity] persist (ls) failed:', e?.message) }
|
|
113
113
|
return _writeChain
|
|
114
114
|
}
|
|
115
115
|
const snapshot = _peers
|
|
116
116
|
_writeChain = _writeChain
|
|
117
117
|
.then(() => idbPut(_idb, key, snapshot))
|
|
118
|
-
.catch(e => console.warn('[cc-identity] persist (idb)
|
|
118
|
+
.catch(e => console.warn('[cc-identity] persist (idb) failed:', e?.message))
|
|
119
119
|
return _writeChain
|
|
120
120
|
}
|
|
121
121
|
|
package/vault/remote.js
CHANGED
|
@@ -28,6 +28,10 @@ const MSG = {
|
|
|
28
28
|
ACTA_SEALED: 'vault.acta.sealed',
|
|
29
29
|
ACTA_ADOPTED: 'vault.acta.adopted',
|
|
30
30
|
REVOKED: 'vault.revoked',
|
|
31
|
+
// Consola remota: administrar el perfil desde un dispositivo (scope `vault:admin`).
|
|
32
|
+
ADMIN: 'vault.admin',
|
|
33
|
+
ADMIN_RESULT: 'vault.admin.result',
|
|
34
|
+
ADMIN_EVENT: 'vault.admin.event',
|
|
31
35
|
ERROR: 'vault.error'
|
|
32
36
|
}
|
|
33
37
|
export { MSG as VAULT_MSG }
|
|
@@ -68,9 +72,9 @@ async function identifyAsDevice (client, device, { cert = null, acta = null } =
|
|
|
68
72
|
*/
|
|
69
73
|
async function verificarHola (p, sn) {
|
|
70
74
|
const b = p?.body
|
|
71
|
-
if (!b?.iss || b.sn !== sn) throw new Error('
|
|
75
|
+
if (!b?.iss || b.sn !== sn) throw new Error('the vault answered a different pairing')
|
|
72
76
|
if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
|
|
73
|
-
throw new Error('
|
|
77
|
+
throw new Error('the vault reply is not properly signed')
|
|
74
78
|
}
|
|
75
79
|
return b
|
|
76
80
|
}
|
|
@@ -82,7 +86,7 @@ async function verificarHola (p, sn) {
|
|
|
82
86
|
*/
|
|
83
87
|
async function canjearCita (client, code) {
|
|
84
88
|
const r = await client.redeemPairingCode(code)
|
|
85
|
-
if (!r?.ok || !r.instance) throw new Error(r?.error || '
|
|
89
|
+
if (!r?.ok || !r.instance) throw new Error(r?.error || 'that pairing code is no longer valid')
|
|
86
90
|
return r.instance
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -94,7 +98,7 @@ async function askVault (client, qr) {
|
|
|
94
98
|
if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then((b) => resolve({ iss: b.iss, proxy: b.proxy || qr.proxy, acct: b.acct || '', m: b.m || qr.m }), reject) }
|
|
95
99
|
else if (p?.type === MSG.ERROR) { fin(); reject(new Error(p.error)) }
|
|
96
100
|
})
|
|
97
|
-
const t = setTimeout(() => { fin(); reject(new Error('
|
|
101
|
+
const t = setTimeout(() => { fin(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
98
102
|
const fin = () => { off(); clearTimeout(t) }
|
|
99
103
|
try { client.send(destino, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
|
|
100
104
|
})
|
|
@@ -109,7 +113,7 @@ async function askVault (client, qr) {
|
|
|
109
113
|
* @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string}>}
|
|
110
114
|
*/
|
|
111
115
|
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null } = {}) {
|
|
112
|
-
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr
|
|
116
|
+
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing vault or nonce')
|
|
113
117
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
114
118
|
const client = new WebSocketProxyClient({ url: qr.proxy || 'wss://proxy.dotrino.com', enableWebRTC: false, autoReconnect: false })
|
|
115
119
|
await client.connect()
|
|
@@ -172,7 +176,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
172
176
|
else if (p.type === MSG.ACTA_ADOPTED && adoptar) { cleanup(); resolve(p) }
|
|
173
177
|
else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
174
178
|
})
|
|
175
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('timeout
|
|
179
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval at the vault')) }, approveTimeoutMs)
|
|
176
180
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
177
181
|
})
|
|
178
182
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
@@ -181,14 +185,14 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
181
185
|
// Camino A: aquí no hay cert que validar — este aparato NO delega su identidad, sigue
|
|
182
186
|
// siendo la cuenta. Lo que vuelve es el acta ya sellada por la bóveda.
|
|
183
187
|
if (adoptar) {
|
|
184
|
-
if (!res.acta) throw new Error('
|
|
185
|
-
if (res.acta.sealer !== qr.iss) throw new Error('
|
|
188
|
+
if (!res.acta) throw new Error('the vault did not return the adopted record')
|
|
189
|
+
if (res.acta.sealer !== qr.iss) throw new Error('the record is sealed by a vault other than the one you saw')
|
|
186
190
|
return { device: dev, cert: null, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta, adopted: true }
|
|
187
191
|
}
|
|
188
192
|
|
|
189
193
|
// Validación estricta antes de guardar (cierra inyección de cert / sustitución de maestra).
|
|
190
194
|
const v = await verifyDelegation({ cert: res.cert, expectedSub: dev.publickey })
|
|
191
|
-
if (!v.ok) throw new Error('cert
|
|
195
|
+
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
192
196
|
if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la que viste')
|
|
193
197
|
if (res.cert.sub !== dev.publickey) throw new Error('cert emitido para otro dispositivo')
|
|
194
198
|
return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta || null }
|
|
@@ -237,7 +241,7 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
|
|
|
237
241
|
if (p.type === okType) { cleanup(); resolve(p) }
|
|
238
242
|
else if (p.type === 'vault.error') { cleanup(); reject(new Error(p.error)) }
|
|
239
243
|
})
|
|
240
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('
|
|
244
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('the vault did not reply (is it running?)')) }, timeoutMs)
|
|
241
245
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
242
246
|
})
|
|
243
247
|
client.sendByPubkey(master, { type: sendType, data: signed, signature, cert })
|
|
@@ -267,6 +271,39 @@ export async function requestDevices ({ master, proxy, device, cert, sinceSeq, o
|
|
|
267
271
|
*/
|
|
268
272
|
export async function requestRenew ({ master, proxy, device, cert, onRevoked } = {}) {
|
|
269
273
|
const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.renew', okType: 'vault.renewed', data: { op: 'renew' } })
|
|
270
|
-
if (!res.cert || res.cert.sub !== device.publickey || res.cert.iss !== master) throw new Error('
|
|
274
|
+
if (!res.cert || res.cert.sub !== device.publickey || res.cert.iss !== master) throw new Error('invalid renewed cert')
|
|
271
275
|
return { cert: res.cert }
|
|
272
276
|
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* CONSOLA REMOTA (`dotrino-vault/docs/consola-remota.md`): administrar el perfil desde
|
|
280
|
+
* este dispositivo, sin ir al PC. Requiere un cert con scope `vault:admin`, que **no se
|
|
281
|
+
* recibe al emparejar** — se concede a mano en la bóveda.
|
|
282
|
+
*
|
|
283
|
+
* `op`: `pending` · `pair` · `approve` · `reject` · `revoke` · `audit`. Lo que NO existe
|
|
284
|
+
* aquí es tan importante como lo que sí: cambiar permisos, traspasar el mando y los
|
|
285
|
+
* secretos de servicios no se administran a distancia.
|
|
286
|
+
*
|
|
287
|
+
* El `nonce` de un solo uso va en cada petición porque `approve` y `revoke` cambian
|
|
288
|
+
* estado, y para eso la ventana de frescura de ±5 min no alcanza.
|
|
289
|
+
*/
|
|
290
|
+
export async function requestAdmin ({ master, proxy, device, cert, op, onRevoked, ...rest } = {}) {
|
|
291
|
+
const nonce = [...crypto.getRandomValues(new Uint8Array(16))]
|
|
292
|
+
.map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
293
|
+
const res = await vaultRpc({
|
|
294
|
+
master, proxy, device, cert, onRevoked,
|
|
295
|
+
sendType: MSG.ADMIN, okType: MSG.ADMIN_RESULT,
|
|
296
|
+
data: { op, ...rest, nonce }
|
|
297
|
+
})
|
|
298
|
+
return res.result
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* ¿Es AUTÉNTICO este `vault.admin.event` (entró o salió alguien del perfil)? Solo si va
|
|
303
|
+
* firmado por la maestra PINEADA. Un aviso sin firma no se muestra: si no, cualquiera
|
|
304
|
+
* podría llenar de alarmas falsas los dispositivos del usuario.
|
|
305
|
+
*/
|
|
306
|
+
export async function isAuthenticAdminEvent ({ body, signature, master }) {
|
|
307
|
+
if (!body || typeof body.ev !== 'string') return false
|
|
308
|
+
return verifyDeviceSig({ publickey: master, data: body, signature })
|
|
309
|
+
}
|