@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/vault/vault.js
CHANGED
|
@@ -119,7 +119,11 @@ import { pubkeyId } from './capabilities.js'
|
|
|
119
119
|
listDelegations: () => handlers.listDelegations({}),
|
|
120
120
|
revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce }),
|
|
121
121
|
admitMember: (m) => handlers.admitMember(m),
|
|
122
|
-
profileActa: () => handlers.profileActa({})
|
|
122
|
+
profileActa: () => handlers.profileActa({}),
|
|
123
|
+
// Camino A (`mode: 'adopt'`): la bóveda se queda con la cuenta que trae el aparato,
|
|
124
|
+
// y para eso tiene que poder ENTRAR en su acta. Sin esto, adoptar fallaba con un
|
|
125
|
+
// «no es una función» en vez de con un error del protocolo.
|
|
126
|
+
joinProfile: (acta) => handlers.joinProfile({ acta })
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
async function startSelfDaemon () {
|
|
@@ -216,7 +220,7 @@ import { pubkeyId } from './capabilities.js'
|
|
|
216
220
|
return { ok: true, enabled: !!enabled }
|
|
217
221
|
},
|
|
218
222
|
selfVaultPairing: async (opts) => {
|
|
219
|
-
if (!daemon) throw new Error('
|
|
223
|
+
if (!daemon) throw new Error('this tab is not the active vault; open it as a visible tab')
|
|
220
224
|
return daemon.startPairing(opts)
|
|
221
225
|
},
|
|
222
226
|
selfVaultPending: async () => (daemon ? daemon.listPending() : []),
|
|
@@ -226,11 +230,11 @@ import { pubkeyId } from './capabilities.js'
|
|
|
226
230
|
return issued || []
|
|
227
231
|
},
|
|
228
232
|
selfVaultApprove: async ({ deviceId, code }) => {
|
|
229
|
-
if (!daemon) throw new Error('
|
|
233
|
+
if (!daemon) throw new Error('this tab is not the active vault')
|
|
230
234
|
return daemon.approve(deviceId, code)
|
|
231
235
|
},
|
|
232
236
|
selfVaultReject: async ({ deviceId }) => {
|
|
233
|
-
if (!daemon) throw new Error('
|
|
237
|
+
if (!daemon) throw new Error('this tab is not the active vault')
|
|
234
238
|
daemon.reject(deviceId)
|
|
235
239
|
return { ok: true }
|
|
236
240
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/vault@0.
|
|
1
|
+
Copia vendorizada de @dotrino/vault@0.18.0 (lib/src/{index,enroll,protocol}.js, sin dependencias).
|
|
2
2
|
El iframe de identity se sirve estatico (vanilla, sin build); asi startDeviceVault
|
|
3
|
-
resuelve en el navegador sin bundler. index.js importa ./enroll.js
|
|
4
|
-
|
|
5
|
-
@dotrino/proxy-client (=../proxy-client/), ambos via el import map de index.html.
|
|
6
|
-
Re-vendorizar
|
|
3
|
+
resuelve en el navegador sin bundler. index.js importa ./enroll.js y ./protocol.js
|
|
4
|
+
(relativos, se vendorizan tambien) y @dotrino/identity/capabilities (=../../capabilities.js)
|
|
5
|
+
y @dotrino/proxy-client (=../proxy-client/), ambos via el import map de index.html.
|
|
6
|
+
Re-vendorizar LOS TRES archivos al subir @dotrino/vault.
|
|
@@ -39,14 +39,20 @@ export const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
|
39
39
|
/** Vida por defecto del cert de un dispositivo (tope duro de `MAX_DELEGATION_MS`). */
|
|
40
40
|
export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
41
41
|
|
|
42
|
+
export const MSG_HELLO = 'vault.hello'
|
|
43
|
+
export const MSG_HELLO_OK = 'vault.hello.ok'
|
|
42
44
|
export const MSG_ENROLL = 'vault.enroll'
|
|
43
45
|
export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
|
|
44
46
|
export const MSG_ENROLLED = 'vault.enrolled'
|
|
47
|
+
// --- camino A: la cuenta del aparato pasa a vivir en la bóveda ---
|
|
48
|
+
export const MSG_ENROLL_ADOPT = 'vault.enroll.adopt'
|
|
49
|
+
export const MSG_ACTA_SEALED = 'vault.acta.sealed'
|
|
50
|
+
export const MSG_ACTA_ADOPTED = 'vault.acta.adopted'
|
|
45
51
|
export const MSG_REVOKED = 'vault.revoked'
|
|
46
52
|
export const MSG_ERROR = 'vault.error'
|
|
47
53
|
|
|
48
54
|
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
49
|
-
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
|
|
55
|
+
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
|
|
50
56
|
export const scopeToCaps = (scope) =>
|
|
51
57
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
52
58
|
|
|
@@ -63,12 +69,22 @@ export function scopeToCn (scope) {
|
|
|
63
69
|
return null
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Token aleatorio en hex (16 bytes = 128 bits por defecto).
|
|
74
|
+
*
|
|
75
|
+
* El emparejamiento pide 12 (96 bits): son de un solo uso, valen 5 minutos y hay
|
|
76
|
+
* UNA sesión viva a la vez, así que adivinarlo es 2^95 intentos contra una bóveda
|
|
77
|
+
* que además exige el código de 6 dígitos. A cambio, cada byte de menos son ~1,4
|
|
78
|
+
* caracteres menos en el QR — y el QR se mide en filas de terminal.
|
|
79
|
+
*/
|
|
80
|
+
export function randToken (bytes = 16) {
|
|
81
|
+
const b = crypto.getRandomValues(new Uint8Array(bytes))
|
|
69
82
|
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
/** Tamaño del token/nonce de una sesión de emparejamiento (ver `randToken`). */
|
|
86
|
+
const PAIR_TOKEN_BYTES = 12
|
|
87
|
+
|
|
72
88
|
/** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
|
|
73
89
|
export async function deviceIdOf (pub) {
|
|
74
90
|
const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
|
|
@@ -94,27 +110,65 @@ export async function deviceIdOf (pub) {
|
|
|
94
110
|
export function createEnrollDesk ({
|
|
95
111
|
identity, iss, proxy, send, sendByPubkey,
|
|
96
112
|
audit = () => {}, log = () => {},
|
|
97
|
-
onChallenge = () => {}, onPendingChange = () => {},
|
|
98
|
-
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
|
|
113
|
+
onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
|
|
114
|
+
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
|
|
115
|
+
// Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
|
|
116
|
+
// es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
|
|
117
|
+
encPub = null, vaultLabel = '',
|
|
118
|
+
// Token de CONEXIÓN de esta bóveda en el proxy (4 chars): su dirección. Es lo
|
|
119
|
+
// único que necesita el QR corto para que el aparato le hable punto a punto.
|
|
120
|
+
connToken = null
|
|
99
121
|
} = {}) {
|
|
100
|
-
if (!identity) throw new Error('createEnrollDesk:
|
|
101
|
-
if (!iss) throw new Error('createEnrollDesk:
|
|
122
|
+
if (!identity) throw new Error('createEnrollDesk: missing identity')
|
|
123
|
+
if (!iss) throw new Error('createEnrollDesk: missing iss (master pubkey)')
|
|
102
124
|
|
|
103
125
|
// token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
|
|
104
126
|
// state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
|
|
105
127
|
const pending = new Map()
|
|
106
128
|
|
|
107
129
|
const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
|
|
108
|
-
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault]
|
|
130
|
+
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) } }
|
|
109
131
|
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
110
132
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía.
|
|
135
|
+
*
|
|
136
|
+
* `mode` y `account` son LO QUE LA BÓVEDA DECLARA que va a pasar, y viajan en el QR
|
|
137
|
+
* para que el aparato pueda **decirlo antes de hacerlo** en vez de emparejar a
|
|
138
|
+
* ciegas (decisión V9 de `docs/vinculacion-de-cuentas.md`: pregunta el vault, el
|
|
139
|
+
* dispositivo muestra el proceso y sus consecuencias):
|
|
140
|
+
*
|
|
141
|
+
* · `mode: 'join'` → el dispositivo estrena una cuenta suya y entra a la de la
|
|
142
|
+
* bóveda. Es lo único que existe hoy.
|
|
143
|
+
* · `mode: 'adopt'` → la bóveda se quedaría con la cuenta que trae el aparato
|
|
144
|
+
* (camino A). Reservado: todavía no hay protocolo.
|
|
145
|
+
* · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
|
|
146
|
+
* aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
|
|
147
|
+
* identidad de verdad de la cuenta es `iss`.
|
|
148
|
+
*/
|
|
149
|
+
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
113
150
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
151
|
+
const acct = String(account || '').slice(0, 40)
|
|
152
|
+
// INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
|
|
153
|
+
// nonce de la sesión. La llave, el proxy y el nombre de la cuenta los pide el
|
|
154
|
+
// aparato por la red presentando el `sn`. El nonce hace de identificador de
|
|
155
|
+
// sesión: no hace falta un token de emparejamiento aparte.
|
|
156
|
+
//
|
|
157
|
+
// `conn` es una CITA del proxio (6 caracteres, un solo uso, caduca en
|
|
158
|
+
// minutos), no la dirección de la conexión: esa pasó a ser una instancia de
|
|
159
|
+
// 24 caracteres, que ni entra cómoda en un QR ni tiene por qué quedar impresa
|
|
160
|
+
// en algo que circula. Por eso se pide una nueva por emparejamiento, y por
|
|
161
|
+
// eso esto es asíncrono.
|
|
162
|
+
const conn = typeof connToken === 'function' ? await connToken() : connToken
|
|
163
|
+
if (conn) {
|
|
164
|
+
const sn = randToken(8)
|
|
165
|
+
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
166
|
+
return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
|
|
167
|
+
}
|
|
168
|
+
const token = randToken(PAIR_TOKEN_BYTES)
|
|
169
|
+
const sn = randToken(PAIR_TOKEN_BYTES)
|
|
170
|
+
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
171
|
+
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
118
172
|
}
|
|
119
173
|
|
|
120
174
|
function stopPairing (token) { pending.delete(token) }
|
|
@@ -132,6 +186,28 @@ export function createEnrollDesk ({
|
|
|
132
186
|
return null
|
|
133
187
|
}
|
|
134
188
|
|
|
189
|
+
/**
|
|
190
|
+
* «¿Quién eres?» — la respuesta al QR corto. Solo se contesta a quien presente el
|
|
191
|
+
* `sn` de una sesión VIVA: el token de conexión son 4 caracteres y se puede acertar
|
|
192
|
+
* a ciegas, el `sn` no. Fuera de un emparejamiento no hay ninguna sesión y por lo
|
|
193
|
+
* tanto no hay respuesta: la puerta solo está abierta mientras dura el `pair`.
|
|
194
|
+
*/
|
|
195
|
+
async function handleHello (from, p) {
|
|
196
|
+
const pend = pending.get(String(p?.sn || ''))
|
|
197
|
+
if (!pend || Date.now() > pend.exp) {
|
|
198
|
+
audit('rejected', { what: 'hello', reason: 'sin-sesion' })
|
|
199
|
+
return reply(from, { type: MSG_ERROR, error: 'no pairing session open for that code' })
|
|
200
|
+
}
|
|
201
|
+
// La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
|
|
202
|
+
// la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
|
|
203
|
+
// de otra bóveda. Lo que NO hace es demostrar que sea TU bóveda —cualquiera puede
|
|
204
|
+
// firmar con una llave suya—; eso solo lo demuestra el código de 6 dígitos.
|
|
205
|
+
const body = { op: 'hello', sn: pend.sn, iss, proxy, acct: pend.account || '', m: pend.mode || 'join', ts: Date.now() }
|
|
206
|
+
const { signature } = await identity.signData(body)
|
|
207
|
+
reply(from, { type: MSG_HELLO_OK, body, signature })
|
|
208
|
+
return { ok: true }
|
|
209
|
+
}
|
|
210
|
+
|
|
135
211
|
/**
|
|
136
212
|
* ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
|
|
137
213
|
* COMPROMISO de su código. Todavía NO se firma ningún cert.
|
|
@@ -139,31 +215,42 @@ export function createEnrollDesk ({
|
|
|
139
215
|
async function handleEnroll (from, p) {
|
|
140
216
|
const d = p?.data
|
|
141
217
|
if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
|
|
142
|
-
return reply(from, { type: MSG_ERROR, error: 'enroll
|
|
218
|
+
return reply(from, { type: MSG_ERROR, error: 'invalid enroll' })
|
|
143
219
|
}
|
|
144
220
|
const pend = pending.get(d.token)
|
|
145
221
|
if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
|
|
146
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
222
|
+
return reply(from, { type: MSG_ERROR, error: 'invalid or expired pairing token' })
|
|
223
|
+
}
|
|
224
|
+
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'invalid session' })
|
|
225
|
+
// V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
|
|
226
|
+
// bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
|
|
227
|
+
// humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
|
|
228
|
+
const intent = d.intent || 'join'
|
|
229
|
+
if (intent !== 'join' && intent !== 'adopt') {
|
|
230
|
+
return reply(from, { type: MSG_ERROR, error: 'unknown intent: ' + intent })
|
|
231
|
+
}
|
|
232
|
+
if (intent !== (pend.mode || 'join')) {
|
|
233
|
+
audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
|
|
234
|
+
return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
|
|
147
235
|
}
|
|
148
|
-
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
|
|
149
236
|
if (!isFresh(d)) {
|
|
150
237
|
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
151
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
238
|
+
return reply(from, { type: MSG_ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
152
239
|
}
|
|
153
240
|
// PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
|
|
154
241
|
if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
|
|
155
242
|
audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
|
|
156
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
243
|
+
return reply(from, { type: MSG_ERROR, error: 'invalid device signature' })
|
|
157
244
|
}
|
|
158
245
|
// El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
|
|
159
246
|
// y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
|
|
160
247
|
if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
|
|
161
248
|
audit('rejected', { what: 'enroll', reason: 'no-commit' })
|
|
162
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
249
|
+
return reply(from, { type: MSG_ERROR, error: 'this device speaks an old pairing version (no code commitment). Update it and try again.' })
|
|
163
250
|
}
|
|
164
251
|
// Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
|
|
165
252
|
if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
|
|
166
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
253
|
+
return reply(from, { type: MSG_ERROR, error: 'another device is already using this pairing session' })
|
|
167
254
|
}
|
|
168
255
|
|
|
169
256
|
const deviceId = await deviceIdOf(d.dpub)
|
|
@@ -182,9 +269,12 @@ export function createEnrollDesk ({
|
|
|
182
269
|
}
|
|
183
270
|
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
184
271
|
if (d.label) pend.label = String(d.label).slice(0, 60)
|
|
272
|
+
// Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
|
|
273
|
+
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
274
|
+
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
185
275
|
|
|
186
276
|
reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
|
|
187
|
-
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
|
|
277
|
+
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
|
|
188
278
|
fire(onPendingChange)
|
|
189
279
|
return { deviceId }
|
|
190
280
|
}
|
|
@@ -199,16 +289,16 @@ export function createEnrollDesk ({
|
|
|
199
289
|
*/
|
|
200
290
|
async function approve (code, { deviceId } = {}) {
|
|
201
291
|
code = String(code || '').trim()
|
|
202
|
-
if (!code) throw new Error('
|
|
292
|
+
if (!code) throw new Error('missing code (the digits shown by the device)')
|
|
203
293
|
|
|
204
294
|
let pend
|
|
205
295
|
if (deviceId) {
|
|
206
296
|
pend = findPending(deviceId)
|
|
207
|
-
if (!pend) throw new Error('no
|
|
297
|
+
if (!pend) throw new Error('no device awaiting approval with that id')
|
|
208
298
|
} else {
|
|
209
299
|
const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
|
|
210
|
-
if (waiting.length === 0) throw new Error('no
|
|
211
|
-
if (waiting.length > 1) throw new Error('
|
|
300
|
+
if (waiting.length === 0) throw new Error('no device awaiting approval')
|
|
301
|
+
if (waiting.length > 1) throw new Error('more than one pairing in flight; restart it with dotrino-vault pair')
|
|
212
302
|
pend = waiting[0]
|
|
213
303
|
}
|
|
214
304
|
|
|
@@ -216,8 +306,22 @@ export function createEnrollDesk ({
|
|
|
216
306
|
const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
|
|
217
307
|
if (expected !== pend.commit) {
|
|
218
308
|
audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
|
|
219
|
-
log('[vault]
|
|
220
|
-
throw new Error('
|
|
309
|
+
log('[vault] wrong code for %s: no certificate was issued', pend.deviceId)
|
|
310
|
+
throw new Error('code does not match the one shown by the device: no certificate was issued. Check it and try again.')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
|
|
314
|
+
// aparato la meta en el acta de la cuenta que le está pasando. El código de vuelta es
|
|
315
|
+
// la misma defensa de siempre, en el otro sentido: el aparato solo hace caso a una
|
|
316
|
+
// bóveda que demuestre que un humano la aprobó.
|
|
317
|
+
if ((pend.mode || 'join') === 'adopt') {
|
|
318
|
+
audit('adopt-approve', { device: pend.deviceId, profile: pend.profileId || null })
|
|
319
|
+
pend.state = 'AWAITING_ACTA'
|
|
320
|
+
pend.approvedAt = Date.now()
|
|
321
|
+
reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
|
|
322
|
+
log('[vault] adoption approved for %s: waiting for the sealed record', pend.deviceId)
|
|
323
|
+
fire(onPendingChange)
|
|
324
|
+
return { ok: true, deviceId: pend.deviceId, adopting: true }
|
|
221
325
|
}
|
|
222
326
|
|
|
223
327
|
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
@@ -247,13 +351,67 @@ export function createEnrollDesk ({
|
|
|
247
351
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
248
352
|
}
|
|
249
353
|
|
|
354
|
+
/**
|
|
355
|
+
* CAMINO A · paso 6: llega el acta que el aparato acaba de sellar, con la bóveda dentro
|
|
356
|
+
* como miembro, la clave de contenido envuelta para ella y el mando ya traspasado.
|
|
357
|
+
*
|
|
358
|
+
* Lo que se comprueba antes de guardar nada (y por qué):
|
|
359
|
+
* · que el sellador sea ESTA bóveda — si no, no es un traspaso, es un acta ajena;
|
|
360
|
+
* · que la selle el aparato que estaba en este emparejamiento — cierra que un tercero
|
|
361
|
+
* que vea pasar el mensaje cuele la suya;
|
|
362
|
+
* · que sea la cuenta que ese aparato declaró al enrolarse (`profileId`) — cierra el
|
|
363
|
+
* cambiazo de cuenta entre el anuncio que leyó el humano y lo que llega después.
|
|
364
|
+
*
|
|
365
|
+
* Adoptar la cuenta de otro solo procede sobre un perfil que **nació para eso** (la marca
|
|
366
|
+
* de `prepareForAdoption`). Es la misma regla del navegador: sin la marca, adoptar sería
|
|
367
|
+
* pisar una cuenta con datos, y eso no puede pasar por accidente.
|
|
368
|
+
*/
|
|
369
|
+
async function handleActaSealed (from, p) {
|
|
370
|
+
const acta = p?.acta
|
|
371
|
+
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
372
|
+
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
|
|
373
|
+
if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
|
|
374
|
+
if (acta.sealer !== iss) {
|
|
375
|
+
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
376
|
+
return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
|
|
377
|
+
}
|
|
378
|
+
if (acta.sealedBy !== pend.dpub) {
|
|
379
|
+
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
380
|
+
return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
|
|
381
|
+
}
|
|
382
|
+
if (pend.profileId && acta.profileId !== pend.profileId) {
|
|
383
|
+
audit('rejected', { what: 'adopt', reason: 'other-profile' })
|
|
384
|
+
return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const r = await identity.joinProfile(acta)
|
|
389
|
+
if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
|
|
390
|
+
audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
|
|
391
|
+
// El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
|
|
392
|
+
// dos quedan en la misma versión.
|
|
393
|
+
const mia = (await identity.profileActa?.())?.acta || acta
|
|
394
|
+
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
|
|
395
|
+
pend.state = 'DONE'
|
|
396
|
+
pending.delete(pend.token)
|
|
397
|
+
fire(onPendingChange)
|
|
398
|
+
fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
|
|
399
|
+
log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
|
|
400
|
+
return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
|
|
401
|
+
} catch (e) {
|
|
402
|
+
log('[vault] no se pudo adoptar la cuenta: %s', e.message)
|
|
403
|
+
reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
|
|
404
|
+
return { ok: false, error: e.message }
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
250
408
|
/** Rechaza un enrolamiento pendiente. */
|
|
251
409
|
function reject (deviceId) {
|
|
252
410
|
const pend = deviceId
|
|
253
411
|
? findPending(deviceId)
|
|
254
412
|
: [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
|
|
255
413
|
if (!pend) return { ok: false }
|
|
256
|
-
reply(pend.from, { type: MSG_ERROR, error: '
|
|
414
|
+
reply(pend.from, { type: MSG_ERROR, error: 'pairing rejected' })
|
|
257
415
|
pending.delete(pend.token)
|
|
258
416
|
audit('reject', { device: pend.deviceId })
|
|
259
417
|
fire(onPendingChange)
|
|
@@ -270,7 +428,7 @@ export function createEnrollDesk ({
|
|
|
270
428
|
const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
|
|
271
429
|
const { signature } = await identity.signData(body)
|
|
272
430
|
try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
|
|
273
|
-
catch (e) { log('[vault]
|
|
431
|
+
catch (e) { log('[vault] could not emit revoke:', e.message) }
|
|
274
432
|
}
|
|
275
433
|
|
|
276
434
|
/** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
|
|
@@ -284,7 +442,7 @@ export function createEnrollDesk ({
|
|
|
284
442
|
}
|
|
285
443
|
|
|
286
444
|
return {
|
|
287
|
-
startPairing, stopPairing, handleEnroll, approve, reject,
|
|
445
|
+
startPairing, stopPairing, handleEnroll, handleActaSealed, handleHello, approve, reject,
|
|
288
446
|
listPending, findPending, emitRevoke, revoke,
|
|
289
447
|
get pendingCount () { return pending.size }
|
|
290
448
|
}
|
|
@@ -27,16 +27,13 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
29
29
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
30
|
+
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
31
|
+
// local se queda corta, el dispositivo deja de atender mensajes sin que nadie lo note.
|
|
32
|
+
import { MSG, SCOPE } from './protocol.js'
|
|
30
33
|
|
|
31
|
-
const SIGN_SCOPE =
|
|
34
|
+
const SIGN_SCOPE = SCOPE.SIGN
|
|
32
35
|
const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
|
|
33
|
-
|
|
34
|
-
const MSG = {
|
|
35
|
-
ENROLL: 'vault.enroll',
|
|
36
|
-
DEVICES: 'vault.devices',
|
|
37
|
-
DEVICES_RESULT: 'vault.devices.result',
|
|
38
|
-
ERROR: 'vault.error'
|
|
39
|
-
}
|
|
36
|
+
const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
|
|
40
37
|
|
|
41
38
|
/** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
|
|
42
39
|
export { deviceIdOf }
|
|
@@ -49,10 +46,11 @@ export { deviceIdOf }
|
|
|
49
46
|
* `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
50
47
|
* @param {object} [opts]
|
|
51
48
|
* @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
|
|
52
|
-
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing,
|
|
53
|
-
* listPending, listMachines, revoke, getSelfCert, onPendingChange,
|
|
49
|
+
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
|
|
50
|
+
* approve, reject, listPending, listMachines, revoke, getSelfCert, onPendingChange,
|
|
51
|
+
* onAdopted, close }
|
|
54
52
|
*/
|
|
55
|
-
export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
53
|
+
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
56
54
|
const iss = identity.me?.publickey
|
|
57
55
|
if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
|
|
58
56
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
@@ -67,12 +65,17 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
67
65
|
return cert
|
|
68
66
|
}
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
// `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
|
|
69
|
+
// levanta el del ecosistema — no hay otro transporte.
|
|
70
|
+
const client = injectedClient || await (async () => {
|
|
71
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
72
|
+
const c = new WebSocketProxyClient({
|
|
73
|
+
url: proxy, enableWebRTC: false, autoReconnect: true,
|
|
74
|
+
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
75
|
+
})
|
|
76
|
+
await c.connect()
|
|
77
|
+
return c
|
|
78
|
+
})()
|
|
76
79
|
|
|
77
80
|
const selfCert = await getSelfCert()
|
|
78
81
|
const identify = async () => {
|
|
@@ -87,6 +90,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
87
90
|
const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
|
|
88
91
|
|
|
89
92
|
let _onPendingChange = () => {}
|
|
93
|
+
let _onAdopted = () => {}
|
|
90
94
|
|
|
91
95
|
// ENROLL / aprobación / revocación: núcleo COMPARTIDO con el daemon del PC y con la
|
|
92
96
|
// copia vendorizada del iframe (`lib/src/enroll.js`). Un solo sitio donde vive el
|
|
@@ -99,9 +103,49 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
99
103
|
sendByPubkey: (pub, obj) => { try { client.sendByPubkey(pub, obj) } catch (_) {} },
|
|
100
104
|
defaultScope: [SIGN_SCOPE],
|
|
101
105
|
defaultTtlMs: DEVICE_TTL_MS,
|
|
106
|
+
// Camino A (la cuenta del aparato pasa a vivir aquí): sin la llave de cifrado, esta
|
|
107
|
+
// bóveda entraría mandando una cuenta cuyo contenido no puede abrir.
|
|
108
|
+
encPub: identity.me?.encryptionPubkey || null,
|
|
109
|
+
vaultLabel: 'bóveda',
|
|
110
|
+
// Cita del proxio para la invitación corta (QR). Si el proxio es viejo y no las
|
|
111
|
+
// conoce, el desk se cae solo a la invitación larga.
|
|
112
|
+
connToken: async () => {
|
|
113
|
+
try { return (await client.requestPairingCode())?.code || null }
|
|
114
|
+
catch (_) { return null }
|
|
115
|
+
},
|
|
116
|
+
onAdopted: (info) => { try { _onAdopted(info) } catch (_) {} },
|
|
102
117
|
onPendingChange: () => _onPendingChange()
|
|
103
118
|
})
|
|
104
119
|
|
|
120
|
+
/** Nonces revocados, para que un cert revocado no pase ningún `verifyChain`. */
|
|
121
|
+
async function revocationSet () {
|
|
122
|
+
const { revoked } = await identity.listDelegations()
|
|
123
|
+
return new Set((revoked || []).map((r) => r.nonce || r))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* RENOVACIÓN automática (igual que `dotrino-vault#handleRenew`): un dispositivo con
|
|
128
|
+
* cert VIGENTE y no revocado pide uno fresco —misma sub-clave y scope— sin QR ni
|
|
129
|
+
* aprobación: sigue siendo el mismo dispositivo, solo extiende la ventana. Un cert
|
|
130
|
+
* vencido o revocado NO se renueva (ahí toca re-emparejar con aprobación).
|
|
131
|
+
*
|
|
132
|
+
* Sin esto, toda máquina enrolada contra un dispositivo-bóveda caduca a los 30 días.
|
|
133
|
+
*/
|
|
134
|
+
async function handleRenew (from, p) {
|
|
135
|
+
const d = p?.data
|
|
136
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
|
|
137
|
+
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
138
|
+
return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
139
|
+
}
|
|
140
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss, revoked: await revocationSet() })
|
|
141
|
+
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
142
|
+
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
143
|
+
const { issued } = await identity.listDelegations()
|
|
144
|
+
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
145
|
+
const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
|
|
146
|
+
send(from, { type: MSG.RENEWED, cert })
|
|
147
|
+
}
|
|
148
|
+
|
|
105
149
|
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
106
150
|
// de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
|
|
107
151
|
// QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
|
|
@@ -124,7 +168,12 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
124
168
|
|
|
125
169
|
client.on('message', (_from, p) => {
|
|
126
170
|
if (!p || typeof p !== 'object') return
|
|
127
|
-
|
|
171
|
+
// El QR corto no lleva la llave: el aparato la pide con un HELLO presentando el `sn`.
|
|
172
|
+
if (p.type === MSG.HELLO) Promise.resolve(desk.handleHello(_from, p)).catch(() => {})
|
|
173
|
+
else if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
|
|
174
|
+
// Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
|
|
175
|
+
else if (p.type === MSG.ACTA_SEALED) Promise.resolve(desk.handleActaSealed(_from, p)).catch(() => {})
|
|
176
|
+
else if (p.type === MSG.RENEW) handleRenew(_from, p).catch(() => {})
|
|
128
177
|
else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
|
|
129
178
|
})
|
|
130
179
|
|
|
@@ -148,6 +197,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
148
197
|
return {
|
|
149
198
|
iss, proxy, client,
|
|
150
199
|
startPairing: desk.startPairing,
|
|
200
|
+
stopPairing: desk.stopPairing,
|
|
151
201
|
// Aprueba TIPEANDO el código que muestra la máquina: el núcleo compartido recompone
|
|
152
202
|
// el compromiso `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide.
|
|
153
203
|
approve: (deviceId, code) => desk.approve(code, { deviceId }),
|
|
@@ -159,6 +209,8 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
159
209
|
revoke: (nonce) => desk.revoke(nonce),
|
|
160
210
|
getSelfCert,
|
|
161
211
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
212
|
+
/** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
|
|
213
|
+
onAdopted (fn) { _onAdopted = fn || (() => {}) },
|
|
162
214
|
close () { try { client.close() } catch (_) {} }
|
|
163
215
|
}
|
|
164
216
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocolo de mensajes entre un dispositivo y el vault (viajan por el proxy,
|
|
3
|
+
* direccionados por pubkey con `sendByPubkey`). El cuerpo va JSON-serializado en
|
|
4
|
+
* el campo `message` del sobre del proxy; el cliente lo entrega ya parseado.
|
|
5
|
+
*
|
|
6
|
+
* Emparejamiento ENDURECIDO (ver dotrino-vault/docs/pairing-protocol.md):
|
|
7
|
+
* 1. dispositivo → vault ENROLL { data:{op,dpub,token,sn,label,ts}, signature }
|
|
8
|
+
* (la firma es del dispositivo con su llave D = PRUEBA DE POSESION; un token
|
|
9
|
+
* robado ya NO basta para enrolar).
|
|
10
|
+
* 2. vault → dispositivo ENROLL_CHALLENGE { deviceId, sas } (aun NO firma cert)
|
|
11
|
+
* 3. el dueño compara el SAS (pantalla del dispositivo ↔ del PC) y APRUEBA en el PC
|
|
12
|
+
* 4. vault → dispositivo ENROLLED { cert, iss, sas } (recien aqui firma el cert)
|
|
13
|
+
* 5. el dispositivo VALIDA la cadena: cert.iss === el iss que vio, cert.sub === D.
|
|
14
|
+
*
|
|
15
|
+
* Revocacion (robo): el vault envia REVOKED { body, signature } FIRMADO por la
|
|
16
|
+
* maestra → el dispositivo se autoborra SOLO si la firma valida contra la maestra
|
|
17
|
+
* pineada (cierra el wipe-DoS; un ERROR plano jamas borra).
|
|
18
|
+
*/
|
|
19
|
+
export const MSG = Object.freeze({
|
|
20
|
+
// La invitación corta no lleva la llave: el aparato la pide presentando el `sn` de
|
|
21
|
+
// la sesión. Una pública es pública — esto no la esconde, solo evita abrirle la
|
|
22
|
+
// puerta a quien acertó el token de conexión a ciegas.
|
|
23
|
+
HELLO: 'vault.hello', // dispositivo → vault: { sn }
|
|
24
|
+
HELLO_OK: 'vault.hello.ok', // vault → dispositivo: { iss, acct }
|
|
25
|
+
ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
|
|
26
|
+
ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
|
|
27
|
+
ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
|
|
28
|
+
// Camino A (la cuenta del aparato pasa a vivir en la bóveda): en vez de un cert, la
|
|
29
|
+
// bóveda manda QUIÉN es para que el aparato la admita, le envuelva la clave de
|
|
30
|
+
// contenido y le traspase el mando; el aparato devuelve el acta sellada y la bóveda
|
|
31
|
+
// responde con la definitiva. Ver docs/vinculacion-de-cuentas.md §2.
|
|
32
|
+
ENROLL_ADOPT: 'vault.enroll.adopt', // vault → dispositivo: { code, pub, encPub, label }
|
|
33
|
+
ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
|
|
34
|
+
ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
|
|
35
|
+
REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
|
|
36
|
+
SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
|
|
37
|
+
SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
|
|
38
|
+
GET: 'vault.get', // dispositivo → vault: { data, signature, cert }
|
|
39
|
+
DATA: 'vault.data', // vault → dispositivo: { id, node }
|
|
40
|
+
STORE: 'vault.store', // dispositivo → vault: { data:{method,args,publickey,ts}, signature, cert }
|
|
41
|
+
STORE_RESULT: 'vault.store.result', // vault → dispositivo: { method, result }
|
|
42
|
+
DEVICES: 'vault.devices', // dispositivo → vault: { data:{publickey,ts}, signature, cert }
|
|
43
|
+
DEVICES_RESULT: 'vault.devices.result', // vault → dispositivo: { devices, revoked }
|
|
44
|
+
RENEW: 'vault.renew', // dispositivo → vault: { data:{op,publickey,ts}, signature, cert }
|
|
45
|
+
RENEWED: 'vault.renewed', // vault → dispositivo: { cert } (cert fresco, misma sub-clave/scope)
|
|
46
|
+
SECRETS: 'vault.secrets', // servicio → vault: { data:{op,ns,ek,publickey,ts}, signature, cert }
|
|
47
|
+
SECRETS_RESULT: 'vault.secrets.result', // vault → servicio: { body:{op,ns,enc,ts}, signature } (enc SELLADO a ek; body firmado por la maestra)
|
|
48
|
+
// AVISO DE CAMBIO (no lleva valores): la bóveda dice «la configuración del ns
|
|
49
|
+
// cambió». El agente no la recarga en caliente — SALE limpio y su supervisor lo
|
|
50
|
+
// levanta. Dos razones, y la segunda es la de peso:
|
|
51
|
+
// · Lee todo fresco. Recargar en caliente exige que cada sitio que leyó una
|
|
52
|
+
// variable sepa releerla, y esa lista hay que mantenerla para siempre.
|
|
53
|
+
// · BORRA DE MEMORIA EL VALOR VIEJO. En JavaScript un secreto no se puede
|
|
54
|
+
// borrar: los strings son inmutables, no hay zeroize, y el valor queda en el
|
|
55
|
+
// heap hasta que al recolector le apetezca — más lo que capturó cada closure
|
|
56
|
+
// y cada caché derivada. Una llave se rota casi siempre PORQUE SE FILTRÓ, así
|
|
57
|
+
// que dejarla viva en el proceso anula la razón de rotarla. Un proceso nuevo
|
|
58
|
+
// empieza con el heap limpio.
|
|
59
|
+
// Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
|
|
60
|
+
// aviso de reinicio sin autenticar ES un ataque de denegación.
|
|
61
|
+
SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
|
|
62
|
+
// --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
|
|
63
|
+
// Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
|
|
64
|
+
// Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
|
|
65
|
+
// NO se exponen aquí, y no es un olvido — es el límite (§2 del diseño).
|
|
66
|
+
ADMIN: 'vault.admin', // admin → vault: { data:{op,…,ts,nonce}, signature, cert }
|
|
67
|
+
ADMIN_RESULT: 'vault.admin.result', // vault → admin: { op, result }
|
|
68
|
+
// Aviso a TODOS los miembros de que el perfil cambió (alguien entró o salió). Es la
|
|
69
|
+
// contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
|
|
70
|
+
// invisible para el resto de tus dispositivos.
|
|
71
|
+
ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
|
|
72
|
+
ERROR: 'vault.error' // vault → dispositivo: { error }
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** Capacidades que puede llevar un `cert` (scope). Mínimo por defecto. */
|
|
76
|
+
export const SCOPE = Object.freeze({
|
|
77
|
+
SIGN: 'vault:sign', // pedir a la maestra que firme datos (identidad)
|
|
78
|
+
READ: 'vault:read', // leer nodos del árbol de contenidos
|
|
79
|
+
STORE: 'vault:store', // leer/escribir el store de hilos + aperturas del usuario
|
|
80
|
+
// Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
|
|
81
|
+
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
82
|
+
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
83
|
+
ADMIN: 'vault:admin'
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Scope de SECRETOS por namespace de servicio: un cert con `vault:secrets:proxy`
|
|
88
|
+
* solo puede leer los secretos del ns `proxy` — un VPS comprometido no puede
|
|
89
|
+
* pedir los de otro servicio. ns válido: [a-z0-9-]{1,32}.
|
|
90
|
+
*/
|
|
91
|
+
export const SECRETS_SCOPE_PREFIX = 'vault:secrets:'
|
|
92
|
+
export const secretsScope = (ns) => SECRETS_SCOPE_PREFIX + ns
|
|
93
|
+
export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,32}$/.test(ns)
|