@dotrino/identity 0.77.0 → 0.79.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 +2 -1
- package/src/index.js +9 -0
- package/vault/acta.js +17 -11
- package/vault/index.html +2 -1
- package/vault/vendor/proxy-client/VERSION.txt +4 -1
- package/vault/vendor/proxy-client/client.js +103 -2
- package/vault/vendor/proxy-client/index.js +5 -1
- package/vault/vendor/proxy-client/sealing.js +77 -0
- package/vault/vendor/proxy-client/signature.js +127 -3
- package/vault/vendor/vault/VERSION.txt +5 -6
- package/vault/vendor/vault/enroll.js +14 -6
- package/vault/vendor/vault/index.js +45 -12
- package/vault/vendor/vault/protocol.js +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"LICENSE"
|
|
40
40
|
],
|
|
41
41
|
"scripts": {
|
|
42
|
+
"vendor": "node vendor.mjs",
|
|
42
43
|
"test": "node --test \"test/*.test.js\" \"test/*.test.mjs\"",
|
|
43
44
|
"type-check": "tsc --noEmit"
|
|
44
45
|
},
|
package/src/index.js
CHANGED
|
@@ -343,6 +343,15 @@ export class Identity {
|
|
|
343
343
|
async openContent (envelope) { return this._call('openContent', { envelope }) }
|
|
344
344
|
/** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
|
|
345
345
|
async rotateContentKey () { return this._call('rotateContentKey') }
|
|
346
|
+
/**
|
|
347
|
+
* Abre un sobre de un CAJÓN DE SECRETOS con la envoltura dirigida a este aparato.
|
|
348
|
+
*
|
|
349
|
+
* Distinto de `openContent`, que usa el llavero del perfil: aquí la envoltura llega suelta
|
|
350
|
+
* y la privada de cifrado de este dispositivo —que nunca sale del iframe— la abre. Es lo
|
|
351
|
+
* que le permite a quien administra LEER una variable pública sin teclear ninguna
|
|
352
|
+
* contraseña, y solo las que la bóveda le haya envuelto.
|
|
353
|
+
*/
|
|
354
|
+
async openSealedValue ({ wrap, envelope } = {}) { return this._call('openSealedValue', { wrap, envelope }) }
|
|
346
355
|
|
|
347
356
|
// ----- Emparejar ESTE navegador/dispositivo con el vault del usuario (Fase 1) -----
|
|
348
357
|
|
package/vault/acta.js
CHANGED
|
@@ -103,7 +103,7 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
|
|
|
103
103
|
* el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
|
|
104
104
|
* acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
|
|
105
105
|
*/
|
|
106
|
-
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords', 'sealer', 'unattended'])
|
|
106
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
|
|
107
107
|
|
|
108
108
|
/** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
|
|
109
109
|
/**
|
|
@@ -117,6 +117,15 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin',
|
|
|
117
117
|
* pedirle algo a la bóveda es exactamente lo que decide el acta — tener dos registros
|
|
118
118
|
* de lo mismo obliga a acordarse de los dos al quitar un aparato.
|
|
119
119
|
*
|
|
120
|
+
* `replica` es REPARTIR, NO DECIDIR. Un replicador no tiene maestra: guarda el acta y los
|
|
121
|
+
* sobres —que ya vienen sellados a su destinatario, así que tampoco puede abrirlos— y los
|
|
122
|
+
* entrega cuando la bóveda no está. Firma su respuesta con su propia llave de aparato, y
|
|
123
|
+
* es este permiso el que hace que un cliente la acepte como respondedor.
|
|
124
|
+
*
|
|
125
|
+
* Lo que NO le concede, y por eso es estrecho: no sella actas, no emite certificados, no
|
|
126
|
+
* abre nada. Un replicador comprometido cuesta disponibilidad, no confidencialidad.
|
|
127
|
+
* Diseño: `dotrino-vault/docs/replicas.md` §8.bis.
|
|
128
|
+
*
|
|
120
129
|
* `unattended` es RECIBIR CLAVES PRIVADAS SIN QUE NADIE APRUEBE. Sin él, la bóveda no
|
|
121
130
|
* entrega nada hasta que un aparato con `approve` lo firme — una vez por arranque del
|
|
122
131
|
* servicio, no por petición.
|
|
@@ -131,7 +140,7 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin',
|
|
|
131
140
|
* de la cuenta, se ve en la pantalla de permisos como los demás, y se quita quitándolo —
|
|
132
141
|
* sin acordarse de un segundo registro escondido.
|
|
133
142
|
*/
|
|
134
|
-
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended'])
|
|
143
|
+
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
|
|
135
144
|
|
|
136
145
|
/**
|
|
137
146
|
* Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
|
|
@@ -158,19 +167,16 @@ export const isValidCn = (cn) => typeof cn === 'string' && /^[a-z0-9-]{1,32}$/.t
|
|
|
158
167
|
* scope de secretos «de todos».
|
|
159
168
|
*/
|
|
160
169
|
export function capScope (cap, cn = null) {
|
|
161
|
-
if
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (cap === 'approve') return 'vault:approve'
|
|
166
|
-
if (cap === 'passwords') return 'vault:passwords'
|
|
167
|
-
if (cap === 'sealer') return 'vault:sealer'
|
|
170
|
+
// SALE DE `CAP_SCOPE`, no de una cadena de `if` escrita a mano. Esto era lo segundo: la
|
|
171
|
+
// lista de arriba y esta se escribían por separado, así que un permiso nuevo entraba en
|
|
172
|
+
// una y no en la otra y se quedaba sin scope en silencio. Ya pasó con `sealer`,
|
|
173
|
+
// `unattended` y `secrets`; con `replica` se cortó aquí.
|
|
168
174
|
if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
|
|
169
|
-
return null
|
|
175
|
+
return CAP_SCOPE[cap] || null
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
/** Compat: el mapa directo, para las capacidades de dispositivo. */
|
|
173
|
-
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve', passwords: 'vault:passwords', sealer: 'vault:sealer' })
|
|
179
|
+
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve', passwords: 'vault:passwords', sealer: 'vault:sealer', replica: 'vault:replica' })
|
|
174
180
|
|
|
175
181
|
const enc = (s) => new TextEncoder().encode(s)
|
|
176
182
|
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
package/vault/index.html
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"@dotrino/proxy-client": "./vendor/proxy-client/index.js",
|
|
27
27
|
"@dotrino/vault": "./vendor/vault/index.js",
|
|
28
28
|
"@dotrino/identity/capabilities": "./capabilities.js",
|
|
29
|
-
"@dotrino/identity/acta": "./acta.js"
|
|
29
|
+
"@dotrino/identity/acta": "./acta.js",
|
|
30
|
+
"@dotrino/identity/content": "./content.js"
|
|
30
31
|
} }
|
|
31
32
|
</script>
|
|
32
33
|
<script type="module" src="./vault.js"></script>
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
0.
|
|
1
|
+
Copia vendorizada de @dotrino/proxy-client@0.13.1 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.js).
|
|
2
|
+
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
|
+
sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
|
|
4
|
+
por el import map): solo se carga si de verdad se sella algo.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
|
|
2
|
+
import { seal, open, isSealed } from './sealing.js'
|
|
2
3
|
import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS } from './webrtc.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -43,6 +44,33 @@ export class WebSocketProxyClient {
|
|
|
43
44
|
this.enableWebRTC = options.enableWebRTC !== false
|
|
44
45
|
this.iceServers = options.iceServers || null
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Refuse to send or accept directed messages in the clear.
|
|
49
|
+
*
|
|
50
|
+
* The proxy does not encrypt payloads, so anything sensitive sent with
|
|
51
|
+
* `sendByPubkey` is readable by whoever runs the proxy. With this on:
|
|
52
|
+
* · `sendSealed()` is the only way out — plain `sendByPubkey` throws
|
|
53
|
+
* · unsealed directed messages are dropped and reported as 'unsealed'
|
|
54
|
+
*
|
|
55
|
+
* Off by default so existing apps keep working; public channels are unaffected
|
|
56
|
+
* either way, since they are public by design.
|
|
57
|
+
*/
|
|
58
|
+
this.requireSealed = options.requireSealed === true
|
|
59
|
+
this.myEncPrivateKey = options.myEncPrivateKey || null
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Who does the sealing. Two worlds, and only one of them holds the key:
|
|
63
|
+
*
|
|
64
|
+
* · headless devices (a CLI, an agent) have their own encryption private key,
|
|
65
|
+
* so `myEncPrivateKey` is enough
|
|
66
|
+
* · browser apps do NOT: the private key lives in the vault, and they delegate
|
|
67
|
+
* with `identity.encrypt` / `identity.decrypt`
|
|
68
|
+
*
|
|
69
|
+
* Pass `sealing: { seal(msg, peerEncPub), open(envelope), isSealed(msg) }` to
|
|
70
|
+
* plug the second case in. Without it, the built-in sealing is used.
|
|
71
|
+
*/
|
|
72
|
+
this.sealing = options.sealing || null
|
|
73
|
+
|
|
46
74
|
// Heartbeat de aplicación: el WebSocket del browser NO expone ping/pong de
|
|
47
75
|
// protocolo, así que mandamos `{type:'ping'}` y esperamos cualquier tráfico
|
|
48
76
|
// de vuelta (el server responde `pong`). Si no hay respuesta en
|
|
@@ -67,7 +95,7 @@ export class WebSocketProxyClient {
|
|
|
67
95
|
this._rtc = this.enableWebRTC ? new WebRTCManager({
|
|
68
96
|
getSelfToken: () => this.token,
|
|
69
97
|
signalSend: (to, payload) => this._proxySendOne(to, payload),
|
|
70
|
-
deliverMessage: (from, parsed, meta) => this.
|
|
98
|
+
deliverMessage: (from, parsed, meta) => this._deliver(from, parsed, meta),
|
|
71
99
|
emit: (event, ...args) => this._emit(event, ...args),
|
|
72
100
|
config: this.iceServers ? { iceServers: this.iceServers } : null
|
|
73
101
|
}) : null
|
|
@@ -127,6 +155,19 @@ export class WebSocketProxyClient {
|
|
|
127
155
|
if (typeof options.autoReconnect === 'boolean') this.autoReconnect = options.autoReconnect
|
|
128
156
|
if (typeof options.maxReconnectAttempts === 'number') this.maxReconnectAttempts = options.maxReconnectAttempts
|
|
129
157
|
if (typeof options.reconnectDelay === 'number') this.reconnectDelay = options.reconnectDelay
|
|
158
|
+
|
|
159
|
+
// La configuración de sellado TAMBIÉN se aplica aquí. Casi todas las apps piden el
|
|
160
|
+
// cliente con `getWebSocketProxyClient()`, que es un singleton: si el primero en
|
|
161
|
+
// pedirlo no puso `requireSealed`, el que sí lo pide después se quedaba sin él y
|
|
162
|
+
// sin enterarse — la garantía perdida en silencio, que es la peor forma de
|
|
163
|
+
// perderla.
|
|
164
|
+
if (options.sealing) this.sealing = options.sealing
|
|
165
|
+
if (options.myEncPrivateKey) this.myEncPrivateKey = options.myEncPrivateKey
|
|
166
|
+
|
|
167
|
+
// Se puede ENCENDER, no apagar. Bajar la exigencia en caliente dejaría que
|
|
168
|
+
// cualquier otro módulo de la app la desactivara sin querer, y no hay ningún
|
|
169
|
+
// motivo legítimo para hacerlo a mitad de una sesión.
|
|
170
|
+
if (options.requireSealed === true) this.requireSealed = true
|
|
130
171
|
}
|
|
131
172
|
|
|
132
173
|
on (event, handler) {
|
|
@@ -265,7 +306,67 @@ export class WebSocketProxyClient {
|
|
|
265
306
|
* incorrecto (reinicia negociaciones imposibles y muestra movimientos fuera de
|
|
266
307
|
* contexto). NO lo uses para mensajes de chat, que sí quieren esperar.
|
|
267
308
|
*/
|
|
309
|
+
/**
|
|
310
|
+
* Seal a payload towards a peer's encryption key and send it. This is what an app
|
|
311
|
+
* should use for anything that is not meant for the proxy's eyes.
|
|
312
|
+
*/
|
|
313
|
+
async sendSealed (toPubkeys, payload, { peerEncPub, ...opts } = {}) {
|
|
314
|
+
if (this.sealing) {
|
|
315
|
+
this._sendByPubkeyRaw(toPubkeys, await this.sealing.seal(payload, peerEncPub), opts)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
if (!peerEncPub) throw Object.assign(new Error('sendSealed: missing peerEncPub'), { code: 'unsealed' })
|
|
319
|
+
this._sendByPubkeyRaw(toPubkeys, await seal(payload, peerEncPub), opts)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
_isSealed (msg) {
|
|
323
|
+
return this.sealing ? this.sealing.isSealed(msg) : isSealed(msg)
|
|
324
|
+
}
|
|
325
|
+
|
|
268
326
|
sendByPubkey (toPubkeys, payload, opts = {}) {
|
|
327
|
+
if (this.requireSealed && !this._isSealed(payload)) {
|
|
328
|
+
throw Object.assign(
|
|
329
|
+
new Error('requireSealed: refusing to send a directed message in the clear — use sendSealed()'),
|
|
330
|
+
{ code: 'unsealed' })
|
|
331
|
+
}
|
|
332
|
+
this._sendByPubkeyRaw(toPubkeys, payload, opts)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Hands a message to the app, opening it first when it is sealed.
|
|
337
|
+
*
|
|
338
|
+
* With `requireSealed`, anything that arrives in the clear is DROPPED and reported
|
|
339
|
+
* as `{ type: 'unsealed' }`. Sealing on the way out is not enough on its own: if the
|
|
340
|
+
* receiving end still accepts plaintext, sending it that way bypasses the sealing
|
|
341
|
+
* entirely — and a peer that never read anything could still push a forged payload
|
|
342
|
+
* into the app.
|
|
343
|
+
*/
|
|
344
|
+
async _deliver (from, payload, meta) {
|
|
345
|
+
if (this._isSealed(payload)) {
|
|
346
|
+
if (!this.sealing && !this.myEncPrivateKey) {
|
|
347
|
+
this._emit('error', { type: 'unsealed', reason: 'no_encryption_key', from })
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const opened = this.sealing
|
|
352
|
+
? await this.sealing.open(payload, meta)
|
|
353
|
+
: await open(payload, this.myEncPrivateKey)
|
|
354
|
+
this._emit('message', from, opened, { ...meta, sealed: true })
|
|
355
|
+
} catch (e) {
|
|
356
|
+
// Sealed to somebody else, or tampered with. Staying quiet is the point.
|
|
357
|
+
this._emit('error', { type: 'undecipherable', from, error: e })
|
|
358
|
+
}
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (this.requireSealed) {
|
|
363
|
+
this._emit('error', { type: 'unsealed', reason: 'plaintext_rejected', from })
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
this._emit('message', from, payload, { ...meta, sealed: false })
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_sendByPubkeyRaw (toPubkeys, payload, opts = {}) {
|
|
269
370
|
const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
|
|
270
371
|
const msg = {
|
|
271
372
|
to_publickey: list,
|
|
@@ -713,7 +814,7 @@ export class WebSocketProxyClient {
|
|
|
713
814
|
this._rtc.handleIncoming(from, parsed)
|
|
714
815
|
break
|
|
715
816
|
}
|
|
716
|
-
this.
|
|
817
|
+
this._deliver(from, parsed ?? message, {
|
|
717
818
|
raw: message, timestamp, via: 'proxy',
|
|
718
819
|
fromPubkey: from_publickey || null,
|
|
719
820
|
queued: !!queued,
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { WebSocketProxyClient } from './client.js'
|
|
2
2
|
export { canonicalStringify } from './canonical.js'
|
|
3
|
-
export { getPublicKeyJwk, signData, buildSignedChannel } from './signature.js'
|
|
3
|
+
export { getPublicKeyJwk, signData, buildSignedChannel, setKeypairStore } from './signature.js'
|
|
4
|
+
export {
|
|
5
|
+
seal, open, isSealed, makeEncKeypair, importEncPrivate, exportEncPrivate,
|
|
6
|
+
setSealingPrimitives,
|
|
7
|
+
} from './sealing.js'
|
|
4
8
|
|
|
5
9
|
import { WebSocketProxyClient } from './client.js'
|
|
6
10
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end sealing for directed messages.
|
|
3
|
+
*
|
|
4
|
+
* The proxy routes by public key but does NOT encrypt the payload: `sendByPubkey`
|
|
5
|
+
* serializes it and sends it as-is. Anything sensitive that travels this way is
|
|
6
|
+
* readable by whoever runs the proxy — which is exactly what the ecosystem promises
|
|
7
|
+
* does not happen.
|
|
8
|
+
*
|
|
9
|
+
* This is NOT new cryptography. It is `wrapForMember`/`openWrap` from
|
|
10
|
+
* `@dotrino/identity/content`, the same primitives the vault uses for sealed secrets:
|
|
11
|
+
* ephemeral ECDH P-256 against the recipient's encryption public key, plus AES-GCM.
|
|
12
|
+
* Each message carries its own ephemeral key, so there is no shared state to keep.
|
|
13
|
+
*
|
|
14
|
+
* `@dotrino/identity` is a PEER dependency on purpose: bundling it here would ship a
|
|
15
|
+
* second, older copy of a pillar inside every consumer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
|
|
19
|
+
const VERSION = 1
|
|
20
|
+
|
|
21
|
+
let primitives = null
|
|
22
|
+
|
|
23
|
+
async function crypto_ () {
|
|
24
|
+
if (primitives) return primitives
|
|
25
|
+
try {
|
|
26
|
+
primitives = await import('@dotrino/identity/content')
|
|
27
|
+
} catch (e) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'sealing requires @dotrino/identity (peer dependency) — install it, or pass ' +
|
|
30
|
+
'your own primitives to setSealingPrimitives()')
|
|
31
|
+
}
|
|
32
|
+
return primitives
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Inject the primitives instead of resolving `@dotrino/identity` (bundlers, tests). */
|
|
36
|
+
export function setSealingPrimitives (mod) {
|
|
37
|
+
primitives = mod
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A durable encryption keypair for this device. Its public half goes in the pairing code. */
|
|
41
|
+
export async function makeEncKeypair () {
|
|
42
|
+
const pair = await globalThis.crypto.subtle.generateKey(ECDH, true, ['deriveBits'])
|
|
43
|
+
const pub = await globalThis.crypto.subtle.exportKey('jwk', pair.publicKey)
|
|
44
|
+
return {
|
|
45
|
+
privateKey: pair.privateKey,
|
|
46
|
+
publicKey: pair.publicKey,
|
|
47
|
+
encPub: JSON.stringify({ kty: pub.kty, crv: pub.crv, x: pub.x, y: pub.y }),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function importEncPrivate (jwk) {
|
|
52
|
+
return globalThis.crypto.subtle.importKey('jwk', jwk, ECDH, true, ['deriveBits'])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function exportEncPrivate (privateKey) {
|
|
56
|
+
return globalThis.crypto.subtle.exportKey('jwk', privateKey)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Seal a message towards a peer's encryption public key. */
|
|
60
|
+
export async function seal (message, peerEncPub) {
|
|
61
|
+
if (!peerEncPub) throw new Error('seal: missing peer encryption key')
|
|
62
|
+
const { wrapForMember } = await crypto_()
|
|
63
|
+
const sealed = await wrapForMember({ cek: JSON.stringify(message), memberEncPub: peerEncPub })
|
|
64
|
+
return { v: VERSION, sealed }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Open a message sealed to me. Throws if it is not mine or was tampered with. */
|
|
68
|
+
export async function open (envelope, myEncPrivateKey) {
|
|
69
|
+
if (!isSealed(envelope)) throw new Error('open: not a sealed envelope')
|
|
70
|
+
if (!myEncPrivateKey) throw new Error('open: missing my encryption key')
|
|
71
|
+
const { openWrap } = await crypto_()
|
|
72
|
+
return JSON.parse(await openWrap({ wrap: envelope.sealed, myEncPrivateKey }))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isSealed (msg) {
|
|
76
|
+
return !!msg && msg.v === VERSION && !!msg.sealed?.ct && !!msg.sealed?.epk
|
|
77
|
+
}
|
|
@@ -1,17 +1,141 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ECDSA P-256 keypair management using SubtleCrypto
|
|
2
|
+
* ECDSA P-256 keypair management using SubtleCrypto.
|
|
3
|
+
*
|
|
4
|
+
* Persisted in localStorage as JWK where it exists. Where it does NOT — a service
|
|
5
|
+
* worker, which is where a browser extension keeps its background logic — the pair
|
|
6
|
+
* used to be regenerated on every call and never stored, so the identity changed
|
|
7
|
+
* every time the worker went to sleep. Any peer that knows a device by its public
|
|
8
|
+
* key would see a stranger each time. IndexedDB is the fallback there: it is
|
|
9
|
+
* available in workers, and it can store the CryptoKey itself, so the private key
|
|
10
|
+
* stays non-extractable instead of being written out as a JWK.
|
|
11
|
+
*
|
|
3
12
|
* Public key in JWK form is what the proxy expects in `channel.data.publickey`.
|
|
4
13
|
*/
|
|
5
14
|
import { canonicalStringify } from './canonical.js'
|
|
6
15
|
|
|
7
16
|
const STORAGE_KEY = 'dotrino.proxy-client.keypair'
|
|
17
|
+
const DB_NAME = 'dotrino.proxy-client'
|
|
18
|
+
const DB_STORE = 'keypair'
|
|
8
19
|
|
|
9
20
|
let cachedKeypair = null
|
|
21
|
+
let injectedStore = null
|
|
22
|
+
let injectedExtractable = false
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Override where the keypair is kept. Takes `{ get(), set(pair) }` handling
|
|
26
|
+
* `{ privateKey, publicKey, publicJwk }`. Rarely needed: the defaults already cover
|
|
27
|
+
* pages (localStorage) and workers (IndexedDB).
|
|
28
|
+
*
|
|
29
|
+
* `extractable` matters: a store that keeps CryptoKeys as-is (IndexedDB) does not
|
|
30
|
+
* need it and is safer without, but a store that serializes to disk or to text has
|
|
31
|
+
* to export the private key as a JWK, and that throws on a non-extractable key. Pass
|
|
32
|
+
* `{ extractable: true }` for those.
|
|
33
|
+
*/
|
|
34
|
+
export function setKeypairStore (store, { extractable = false } = {}) {
|
|
35
|
+
injectedStore = store
|
|
36
|
+
injectedExtractable = !!extractable
|
|
37
|
+
cachedKeypair = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function idb () {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const req = indexedDB.open(DB_NAME, 1)
|
|
43
|
+
req.onupgradeneeded = () => {
|
|
44
|
+
if (!req.result.objectStoreNames.contains(DB_STORE)) req.result.createObjectStore(DB_STORE)
|
|
45
|
+
}
|
|
46
|
+
req.onsuccess = () => resolve(req.result)
|
|
47
|
+
req.onerror = () => reject(req.error)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function idbRequest (db, mode, fn) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const tx = db.transaction(DB_STORE, mode)
|
|
54
|
+
const req = fn(tx.objectStore(DB_STORE))
|
|
55
|
+
req.onsuccess = () => resolve(req.result)
|
|
56
|
+
req.onerror = () => reject(req.error)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const indexedDbStore = {
|
|
61
|
+
async get () {
|
|
62
|
+
const db = await idb()
|
|
63
|
+
try { return await idbRequest(db, 'readonly', s => s.get(STORAGE_KEY)) } finally { db.close() }
|
|
64
|
+
},
|
|
65
|
+
async set (pair) {
|
|
66
|
+
const db = await idb()
|
|
67
|
+
try { await idbRequest(db, 'readwrite', s => s.put(pair, STORAGE_KEY)) } finally { db.close() }
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is there a WORKING localStorage? Not "is it defined" — Node >= 22 exposes one that
|
|
73
|
+
* throws unless started with `--localstorage-file`, so checking for existence alone
|
|
74
|
+
* sends the keypair down a path that fails. Anything headless without a shim would
|
|
75
|
+
* crash instead of quietly falling back.
|
|
76
|
+
*/
|
|
77
|
+
function localStorageWorks () {
|
|
78
|
+
try {
|
|
79
|
+
if (typeof localStorage === 'undefined') return false
|
|
80
|
+
const probe = STORAGE_KEY + '.probe'
|
|
81
|
+
localStorage.setItem(probe, '1')
|
|
82
|
+
localStorage.removeItem(probe)
|
|
83
|
+
return true
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fallbackStore () {
|
|
90
|
+
if (injectedStore) return injectedStore
|
|
91
|
+
if (!localStorageWorks() && typeof indexedDB !== 'undefined') return indexedDbStore
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
10
94
|
|
|
11
95
|
async function loadOrCreate () {
|
|
12
96
|
if (cachedKeypair) return cachedKeypair
|
|
13
97
|
|
|
14
|
-
|
|
98
|
+
const store = fallbackStore()
|
|
99
|
+
if (store) {
|
|
100
|
+
try {
|
|
101
|
+
const saved = await store.get()
|
|
102
|
+
if (saved?.privateKey && saved?.publicKey) {
|
|
103
|
+
cachedKeypair = {
|
|
104
|
+
privateKey: saved.privateKey,
|
|
105
|
+
publicKey: saved.publicKey,
|
|
106
|
+
publicJwk: saved.publicJwk || await crypto.subtle.exportKey('jwk', saved.publicKey),
|
|
107
|
+
}
|
|
108
|
+
return cachedKeypair
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// unreadable entry, regenerate below
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Non-extractable by default: nothing here needs to export the private key, and
|
|
115
|
+
// a CryptoKey survives structured clone, so with IndexedDB it never has to leave
|
|
116
|
+
// as a JWK. A store that serializes has to opt in via `setKeypairStore`.
|
|
117
|
+
const extractable = store === indexedDbStore ? false : injectedExtractable
|
|
118
|
+
const pair = await crypto.subtle.generateKey(
|
|
119
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
120
|
+
extractable, ['sign', 'verify']
|
|
121
|
+
)
|
|
122
|
+
const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
|
|
123
|
+
const entry = { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
|
|
124
|
+
try {
|
|
125
|
+
await store.set(entry)
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// Loud on purpose. If this fails the identity is regenerated on every start,
|
|
128
|
+
// and every peer that knows this device by its public key stops recognising
|
|
129
|
+
// it — the exact failure this whole path exists to prevent. A silent catch
|
|
130
|
+
// here means finding out days later, from the other side.
|
|
131
|
+
console.error('[proxy-client] could not persist the keypair: %s', e?.message || e)
|
|
132
|
+
console.error('[proxy-client] identity will NOT survive a restart. If the store serializes, pass { extractable: true } to setKeypairStore.')
|
|
133
|
+
}
|
|
134
|
+
cachedKeypair = entry
|
|
135
|
+
return cachedKeypair
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (localStorageWorks()) {
|
|
15
139
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
16
140
|
if (raw) {
|
|
17
141
|
try {
|
|
@@ -40,7 +164,7 @@ async function loadOrCreate () {
|
|
|
40
164
|
)
|
|
41
165
|
const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
42
166
|
const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
|
|
43
|
-
if (
|
|
167
|
+
if (localStorageWorks()) {
|
|
44
168
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ privateJwk, publicJwk }))
|
|
45
169
|
}
|
|
46
170
|
cachedKeypair = { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/vault@0.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
Re-vendorizar LOS TRES archivos al subir @dotrino/vault.
|
|
1
|
+
Copia vendorizada de @dotrino/vault@0.53.0 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
|
|
2
|
+
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
|
+
index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
|
|
4
|
+
@dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y
|
|
5
|
+
@dotrino/proxy-client (= ../proxy-client/), todos por el import map de index.html.
|
|
@@ -30,7 +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
|
+
import { verifyContinuity, canSeal } from '@dotrino/identity/acta'
|
|
34
34
|
|
|
35
35
|
/** Un token de emparejamiento vale 5 min. */
|
|
36
36
|
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
|
|
|
52
52
|
export const MSG_ERROR = 'vault.error'
|
|
53
53
|
|
|
54
54
|
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
55
|
-
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
|
|
55
|
+
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
|
|
56
56
|
export const scopeToCaps = (scope) =>
|
|
57
57
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
58
58
|
|
|
@@ -277,7 +277,12 @@ export function createEnrollDesk ({
|
|
|
277
277
|
pend.continuity = (okC && d.continuity.member === d.dpub) ? d.continuity : null
|
|
278
278
|
}
|
|
279
279
|
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
280
|
-
|
|
280
|
+
// EL NOMBRE QUE PUSISTE AQUÍ MANDA. El aparato manda el suyo al enrolarse, y hasta
|
|
281
|
+
// ahora pisaba siempre al de la bóveda — como el aparato usa por defecto el apodo del
|
|
282
|
+
// PERFIL, acababas con varios dispositivos llamados igual que tú y sin forma de saber
|
|
283
|
+
// cuál era cuál. Si en `pair` le diste un nombre, ese es el nombre; el del aparato
|
|
284
|
+
// sigue valiendo como propuesta cuando no dijiste nada.
|
|
285
|
+
if (d.label && !pend.label) pend.label = String(d.label).slice(0, 60)
|
|
281
286
|
// Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
|
|
282
287
|
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
283
288
|
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
@@ -359,7 +364,7 @@ export function createEnrollDesk ({
|
|
|
359
364
|
pend.state = 'DONE'
|
|
360
365
|
pending.delete(pend.token)
|
|
361
366
|
fire(onPendingChange)
|
|
362
|
-
log(
|
|
367
|
+
log(`[vault] device approved: ${pend.deviceId}`)
|
|
363
368
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
364
369
|
}
|
|
365
370
|
|
|
@@ -383,9 +388,12 @@ export function createEnrollDesk ({
|
|
|
383
388
|
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
384
389
|
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
|
|
385
390
|
if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
|
|
386
|
-
|
|
391
|
+
// Ya no hay campo `sealer`: se le pregunta al PERMISO. Es la misma comprobación —«¿me
|
|
392
|
+
// nombra a mí la que me mandan?»— dicha en el idioma nuevo, y con varios selladores la
|
|
393
|
+
// respuesta puede ser que sí para más de uno, que es lo correcto.
|
|
394
|
+
if (!canSeal(record, iss)) {
|
|
387
395
|
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
388
|
-
return reply(from, { type: MSG_ERROR, error: 'that record does not
|
|
396
|
+
return reply(from, { type: MSG_ERROR, error: 'that record does not let this vault seal it' })
|
|
389
397
|
}
|
|
390
398
|
if (record.sealedBy !== pend.dpub) {
|
|
391
399
|
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
* No reimplementa nada del ecosistema.
|
|
27
27
|
*/
|
|
28
28
|
import { verifyChain, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
29
|
+
import { memberCanScope, sealersOf, memberScopes } from '@dotrino/identity/acta'
|
|
29
30
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
30
31
|
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
31
32
|
// local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
|
|
32
33
|
import { MSG, SCOPE } from './protocol.js'
|
|
33
34
|
|
|
34
35
|
const SIGN_SCOPE = SCOPE.SIGN
|
|
35
|
-
const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
|
|
36
36
|
const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
|
|
37
37
|
|
|
38
38
|
/** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
|
|
@@ -57,10 +57,24 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
57
57
|
|
|
58
58
|
// ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
|
|
59
59
|
// de sus propias máquinas: lo firma la propia P y verifyChain lo acepta) -----
|
|
60
|
+
/**
|
|
61
|
+
* Con qué se juzga un papel: el acta que tiene esta bóveda. Sustituye a `trustedIssuer`,
|
|
62
|
+
* que fijaba UNA llave y por eso los papeles de una segunda selladora no valían.
|
|
63
|
+
* Sin acta van nulos y `verifyDelegation` contesta `no-acta`: no hay con qué decidir, así
|
|
64
|
+
* que no se decide que sí.
|
|
65
|
+
*/
|
|
66
|
+
async function contextoActa () {
|
|
67
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
68
|
+
if (!acta) return { actaSeq: null, sealers: null }
|
|
69
|
+
return { actaSeq: acta.seq, sealers: sealersOf(acta) }
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
let _selfCert = null
|
|
61
73
|
const getSelfCert = async () => {
|
|
62
|
-
|
|
63
|
-
const {
|
|
74
|
+
// Se rehace cuando el acta cambia, no cuando pasa el tiempo: el papel ya no caduca.
|
|
75
|
+
const { actaSeq } = await contextoActa()
|
|
76
|
+
if (_selfCert && _selfCert.seq === actaSeq) return _selfCert
|
|
77
|
+
const { cert } = await identity.signDelegation(iss, SIGN_SCOPE)
|
|
64
78
|
_selfCert = cert
|
|
65
79
|
return cert
|
|
66
80
|
}
|
|
@@ -137,13 +151,20 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
137
151
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
138
152
|
return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
139
153
|
}
|
|
140
|
-
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert,
|
|
154
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
|
|
141
155
|
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
142
156
|
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
143
157
|
const { issued } = await identity.listDelegations()
|
|
144
158
|
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
145
|
-
|
|
146
|
-
|
|
159
|
+
// EL SCOPE SALE DEL ACTA, no del papel viejo: el papel dice a qué se comprometió esta
|
|
160
|
+
// bóveda al conectar el aparato; el acta, lo que puede HOY.
|
|
161
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
162
|
+
if (!acta) return send(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
163
|
+
const scope = memberScopes(acta, p.cert.sub)
|
|
164
|
+
if (!scope.length) return send(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
|
|
165
|
+
const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '' })
|
|
166
|
+
// El acta viaja con el papel: sin ella quien lo recibe no puede comprobar quién lo firmó.
|
|
167
|
+
send(from, { type: MSG.RENEWED, cert, acta })
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
@@ -153,12 +174,12 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
153
174
|
const d = p?.data
|
|
154
175
|
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
155
176
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
156
|
-
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert,
|
|
177
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()) })
|
|
157
178
|
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
158
179
|
const { issued, revoked, revokedCerts } = await identity.listDelegations()
|
|
159
180
|
const devices = await Promise.all((issued || []).map(async (x) => ({
|
|
160
181
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
|
|
161
|
-
label: x.label || '', scope: x.scope,
|
|
182
|
+
label: x.label || '', scope: x.scope, seq: x.seq, nonce: x.nonce
|
|
162
183
|
})))
|
|
163
184
|
send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
|
|
164
185
|
// ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
|
|
@@ -188,12 +209,23 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
188
209
|
const chk = await verifyChain({
|
|
189
210
|
data: d, signature: p.signature, cert: p.cert,
|
|
190
211
|
...(expectedScope ? { expectedScope } : {}),
|
|
191
|
-
|
|
212
|
+
...(await contextoActa()), revoked: await revocationSet(),
|
|
192
213
|
})
|
|
193
214
|
if (!chk.ok) {
|
|
194
215
|
send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
195
216
|
return null
|
|
196
217
|
}
|
|
218
|
+
// EL ACTA MANDA, EL PAPEL SOLO ACOMPAÑA. Aquí y no en cada mostrador: el certificado
|
|
219
|
+
// dice a qué se comprometió esta bóveda al conectar el aparato, y el acta lo que puede
|
|
220
|
+
// HOY. Quitarle un permiso sella el acta pero no le retira el papel, que vive hasta 30
|
|
221
|
+
// días, así que sin esto seguiría entrando hasta que lo renovara.
|
|
222
|
+
if (expectedScope) {
|
|
223
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
224
|
+
if (record && !memberCanScope(record, chk.device, expectedScope)) {
|
|
225
|
+
send(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member no longer has that permission' })
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
}
|
|
197
229
|
return chk
|
|
198
230
|
}
|
|
199
231
|
|
|
@@ -302,13 +334,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
302
334
|
*/
|
|
303
335
|
async function listMachines () {
|
|
304
336
|
const { issued } = await identity.listDelegations()
|
|
305
|
-
const now = Date.now()
|
|
306
337
|
const bySub = new Map()
|
|
307
338
|
for (const x of (issued || [])) {
|
|
308
|
-
if (!x.sub || x.revokedAt
|
|
339
|
+
if (!x.sub || x.revokedAt) continue // revocada = fuera de la lista
|
|
309
340
|
if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
|
|
310
341
|
if (!x.label || x.label === 'cli') continue
|
|
311
|
-
|
|
342
|
+
// Se queda el papel del acta MÁS NUEVA de esa llave: ya no hay «el que vence más
|
|
343
|
+
// tarde», porque ninguno vence.
|
|
344
|
+
if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
|
|
312
345
|
}
|
|
313
346
|
return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
|
|
314
347
|
}
|
|
@@ -86,6 +86,10 @@ export const MSG = Object.freeze({
|
|
|
86
86
|
// contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
|
|
87
87
|
// invisible para el resto de tus dispositivos.
|
|
88
88
|
ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
|
|
89
|
+
// RÉPLICAS: la principal empuja lo que hay que servir (el acta y los sobres, que van
|
|
90
|
+
// firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
|
|
91
|
+
REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }
|
|
92
|
+
REPLICA_ACK: 'vault.replica.ack', // réplica → master: { body:{seq,ts}, signature }
|
|
89
93
|
ERROR: 'vault.error' // vault → dispositivo: { error }
|
|
90
94
|
})
|
|
91
95
|
|
|
@@ -98,7 +102,15 @@ export const SCOPE = Object.freeze({
|
|
|
98
102
|
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
99
103
|
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
100
104
|
ADMIN: 'vault:admin',
|
|
101
|
-
APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
105
|
+
APPROVE: 'vault:approve', // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
106
|
+
// El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
|
|
107
|
+
// Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
|
|
108
|
+
// lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
|
|
109
|
+
PASSWORDS: 'vault:passwords',
|
|
110
|
+
// SELLAR EL ACTA: la OTRA bóveda de esta cuenta. Con esto puede admitir aparatos y
|
|
111
|
+
// cambiar permisos si la principal se pierde — que es todo el punto del multivault. Como
|
|
112
|
+
// `admin`, no se empareja: se concede a mano (`caps <ID> +sella`).
|
|
113
|
+
SEALER: 'vault:sealer'
|
|
102
114
|
})
|
|
103
115
|
|
|
104
116
|
/**
|