@dotrino/identity 0.37.0 → 0.38.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 -2
- package/src/index.js +18 -0
- package/vault/acta.js +35 -21
- package/vault/content.js +2 -2
- package/vault/core.js +48 -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/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +143 -7
- package/vault/vendor/proxy-client/webrtc.js +10 -1
- 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
|
@@ -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)
|