@dotrino/identity 0.78.0 → 0.80.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/LICENSE +0 -0
- package/README.md +0 -0
- package/package.json +2 -1
- package/src/index.d.ts +0 -0
- package/src/index.js +8 -0
- package/src/node.js +2 -0
- package/vault/CNAME +0 -0
- package/vault/acta.js +17 -11
- package/vault/avatar.js +0 -0
- package/vault/capabilities.js +0 -0
- package/vault/content.js +0 -0
- package/vault/core.js +192 -15
- package/vault/index.html +2 -1
- package/vault/keyid.js +0 -0
- package/vault/peerStore.js +0 -0
- package/vault/remote.js +0 -0
- package/vault/sync.js +0 -0
- package/vault/vault.js +0 -0
- package/vault/vendor/proxy-client/VERSION.txt +4 -1
- package/vault/vendor/proxy-client/canonical.js +0 -0
- package/vault/vendor/proxy-client/client.js +183 -6
- 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/proxy-client/webrtc.js +69 -1
- package/vault/vendor/vault/VERSION.txt +5 -6
- package/vault/vendor/vault/enroll.js +25 -6
- package/vault/vendor/vault/index.js +49 -13
- package/vault/vendor/vault/protocol.js +13 -1
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.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.d.ts
CHANGED
|
File without changes
|
package/src/index.js
CHANGED
|
@@ -571,6 +571,14 @@ export class Identity {
|
|
|
571
571
|
async getMe () { return this._call('getMe') }
|
|
572
572
|
/** Subconjunto PÚBLICO de tu perfil (solo lo visible) — para compartir/publicar. */
|
|
573
573
|
async publicMe () { return this._call('publicMe') }
|
|
574
|
+
/**
|
|
575
|
+
* CÓMO FUE EL ÚLTIMO EMPUJÓN del perfil a la bóveda: `{ ok, at, error }`.
|
|
576
|
+
*
|
|
577
|
+
* El núcleo lo lleva desde la fase 3 y aquí no estaba, así que una app no podía
|
|
578
|
+
* preguntarlo — que es justo para lo que se guardó: poder decir «esto no se guardó» en
|
|
579
|
+
* vez de enseñar tan tranquila un perfil que solo vive en este aparato.
|
|
580
|
+
*/
|
|
581
|
+
async profilePushState () { return this._call('profilePushState') }
|
|
574
582
|
|
|
575
583
|
/** Pubkey ECDH (JWK string) propio para encripción. */
|
|
576
584
|
async getEncryptionPubkey () {
|
package/src/node.js
CHANGED
|
@@ -269,6 +269,8 @@ export class Identity {
|
|
|
269
269
|
async updateMe (patch) { return this._h('updateMe', { patch }) }
|
|
270
270
|
getMe () { return this._h('getMe') }
|
|
271
271
|
publicMe () { return this._h('publicMe') }
|
|
272
|
+
/** Cómo fue el último empujón del perfil a la bóveda: `{ ok, at, error }`. */
|
|
273
|
+
profilePushState () { return this._h('profilePushState') }
|
|
272
274
|
getEncryptionPubkey () { return this._h('getEncryptionPubkey') }
|
|
273
275
|
encrypt (recipients, plaintext) { return this._h('encrypt', { recipients, plaintext }) }
|
|
274
276
|
decrypt (senderEncryptionPubkey, myToken, envelope) {
|
package/vault/CNAME
CHANGED
|
File without changes
|
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/avatar.js
CHANGED
|
File without changes
|
package/vault/capabilities.js
CHANGED
|
File without changes
|
package/vault/content.js
CHANGED
|
File without changes
|
package/vault/core.js
CHANGED
|
@@ -124,6 +124,30 @@ const STD_FIELD_CAPS = [
|
|
|
124
124
|
// (los demás campos estándar se comparten salvo que su flag sea false).
|
|
125
125
|
const STD_FIELDS_SENSITIVE = new Set(['telefono', 'direccion'])
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* QUÉ CLASE ES CADA DATO DEL PERFIL: `public` o `private`
|
|
129
|
+
* (`dotrino-vault/docs/datos-del-perfil.md` §2).
|
|
130
|
+
*
|
|
131
|
+
* La regla no es nueva —es la que ya decidía `publicMe()`: lo que marcaste visible es lo
|
|
132
|
+
* que ve quien pregunta desde fuera—. Lo que cambia es que ahora decide **cómo se guarda**:
|
|
133
|
+
* en claro o en sobre. Por eso vive aquí, exportada y a nivel de módulo, en vez de escondida
|
|
134
|
+
* dentro de una función: es política y hay que poder mirarla y probarla.
|
|
135
|
+
*
|
|
136
|
+
* Sensibles (teléfono, dirección) OCULTOS salvo que su marca diga que sí, explícitamente.
|
|
137
|
+
* El resto se comparte salvo que digas que no.
|
|
138
|
+
*/
|
|
139
|
+
export function profileFieldClasses (m = {}) {
|
|
140
|
+
const out = {}
|
|
141
|
+
const clase = (esPublico) => (esPublico ? 'public' : 'private')
|
|
142
|
+
if (m.nickname) out.nickname = clase(true)
|
|
143
|
+
if (m.avatar) out.avatar = clase(m.avatarVisible !== false)
|
|
144
|
+
for (const [k] of STD_FIELD_CAPS) {
|
|
145
|
+
if (!m[k]) continue
|
|
146
|
+
out[k] = clase(STD_FIELDS_SENSITIVE.has(k) ? (m[k + 'Visible'] === true) : (m[k + 'Visible'] !== false))
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
127
151
|
// Sanea un patch de perfil (avatar/links/fields/nickname + campos estándar). Cada link/field
|
|
128
152
|
// lleva `visible` (oculto = no se comparte). Caps de tamaño para no inflar el `me`. Los ids los pone la UI.
|
|
129
153
|
function sanitizeProfilePatch (patch = {}) {
|
|
@@ -1099,15 +1123,118 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1099
1123
|
// store). Al editar aquí se EMPUJA; al arrancar se JALA y gana el más nuevo
|
|
1100
1124
|
// (updatedAt). Las llaves (publickey/encryptionPubkey) son POR dispositivo y
|
|
1101
1125
|
// nunca se sincronizan. Todo best-effort: sin vault encendido no molesta.
|
|
1126
|
+
/**
|
|
1127
|
+
* QUÉ ES PÚBLICO Y QUÉ ES PRIVADO, en un solo sitio.
|
|
1128
|
+
*
|
|
1129
|
+
* Es la misma regla que ya decidía `publicMe()`: lo que marcaste visible es lo que ve
|
|
1130
|
+
* quien pregunta desde fuera. Lo que cambia es que ahora esa marca decide **cómo se
|
|
1131
|
+
* guarda** —en claro o en sobre— y no solo qué se enseña (`docs/datos-del-perfil.md` §2).
|
|
1132
|
+
*
|
|
1133
|
+
* @returns {Array<{key:string, value:string, cls:'public'|'private'}>}
|
|
1134
|
+
*/
|
|
1135
|
+
function profileFields (m) {
|
|
1136
|
+
const out = []
|
|
1137
|
+
const add = (key, value, esPublico) => {
|
|
1138
|
+
if (typeof value !== 'string' || !value) return
|
|
1139
|
+
out.push({ key, value, cls: esPublico ? 'public' : 'private' })
|
|
1140
|
+
}
|
|
1141
|
+
// La clase la decide `profileFieldClasses`, que está exportada y probada: tener la
|
|
1142
|
+
// regla en dos sitios es como acaban divergiendo lo que se enseña y lo que se guarda.
|
|
1143
|
+
const clases = profileFieldClasses(m)
|
|
1144
|
+
for (const [k, cls] of Object.entries(clases)) add(k, m[k], cls === 'public')
|
|
1145
|
+
// Enlaces y campos libres viajan como UN dato cada lista: son arrays y partirlos por
|
|
1146
|
+
// elemento haría que reordenarlos pareciera media docena de cambios.
|
|
1147
|
+
const visibles = (arr) => (arr || []).filter((x) => x.visible !== false).map(({ visible, ...r }) => r)
|
|
1148
|
+
const ocultos = (arr) => (arr || []).filter((x) => x.visible === false)
|
|
1149
|
+
if (Array.isArray(m.links)) {
|
|
1150
|
+
const v = visibles(m.links); if (v.length) add('links', JSON.stringify(v), true)
|
|
1151
|
+
const o = ocultos(m.links); if (o.length) add('links_private', JSON.stringify(o), false)
|
|
1152
|
+
}
|
|
1153
|
+
if (Array.isArray(m.fields)) {
|
|
1154
|
+
const v = visibles(m.fields); if (v.length) add('fields', JSON.stringify(v), true)
|
|
1155
|
+
const o = ocultos(m.fields); if (o.length) add('fields_private', JSON.stringify(o), false)
|
|
1156
|
+
}
|
|
1157
|
+
return out
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* EMPUJAR EL PERFIL, DATO A DATO Y EN SOBRES (`docs/datos-del-perfil.md`).
|
|
1162
|
+
*
|
|
1163
|
+
* Antes se mandaba el `me` entero por `profileSet`, y eso exigía la bóveda ABIERTA —era
|
|
1164
|
+
* ella quien decidía guardarlo, porque lo veía en claro—. Ahora cada dato viaja como un
|
|
1165
|
+
* sobre que la bóveda no puede leer ni fabricar, así que aceptarlo no es decisión suya y
|
|
1166
|
+
* el candado deja de estorbar. Los públicos van en claro porque no hay a quién sellarlos.
|
|
1167
|
+
*
|
|
1168
|
+
* Solo se manda LO QUE CAMBIÓ: cada escritura estrena generación, y reescribir un dato
|
|
1169
|
+
* que no cambió llenaría el llavero y el histórico de ruido.
|
|
1170
|
+
*/
|
|
1171
|
+
async function pushProfileFields (v, device) {
|
|
1172
|
+
const campos = profileFields(me || {})
|
|
1173
|
+
const pendientes = campos.filter((c) => lastPushedFields[c.key] !== c.cls + '\u0000' + c.value)
|
|
1174
|
+
if (!pendientes.length) return
|
|
1175
|
+
|
|
1176
|
+
const privados = pendientes.filter((c) => c.cls === 'private')
|
|
1177
|
+
let destinatarios = null
|
|
1178
|
+
if (privados.length) {
|
|
1179
|
+
destinatarios = await remoteStore({
|
|
1180
|
+
master: v.master, proxy: v.proxy, device, cert: v.cert,
|
|
1181
|
+
method: 'profileRecipients', args: {}, onRevoked: wipeVaultLink
|
|
1182
|
+
})
|
|
1183
|
+
if (!destinatarios?.recoveryPub) {
|
|
1184
|
+
throw new Error('the vault did not say who to seal the profile for')
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
for (const c of pendientes) {
|
|
1189
|
+
const args = { key: c.key, cls: c.cls }
|
|
1190
|
+
if (c.cls === 'public') {
|
|
1191
|
+
args.value = c.value
|
|
1192
|
+
} else {
|
|
1193
|
+
// Envolver solo necesita PÚBLICAS: por eso esto se puede hacer aquí, en el
|
|
1194
|
+
// navegador, sin que ninguna privada ande suelta.
|
|
1195
|
+
const cek = await Content.makeContentKey()
|
|
1196
|
+
const e = await Content.encryptWithCek({ cek, gen: 0, plaintext: c.value })
|
|
1197
|
+
const wraps = { '#recovery': await Content.wrapForMember({ cek, memberEncPub: destinatarios.recoveryPub }) }
|
|
1198
|
+
for (const m of destinatarios.members || []) {
|
|
1199
|
+
if (m.encPub) wraps[m.pub] = await Content.wrapForMember({ cek, memberEncPub: m.encPub })
|
|
1200
|
+
}
|
|
1201
|
+
args.sobre = { e, wraps }
|
|
1202
|
+
}
|
|
1203
|
+
await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profilePut', args, onRevoked: wipeVaultLink })
|
|
1204
|
+
lastPushedFields[c.key] = c.cls + '\u0000' + c.value
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** Lo último que se consiguió empujar de cada dato, para no reescribir lo que no cambió. */
|
|
1209
|
+
const lastPushedFields = Object.create(null)
|
|
1210
|
+
|
|
1102
1211
|
let profilePushTimer = null
|
|
1212
|
+
/**
|
|
1213
|
+
* CÓMO FUE EL ÚLTIMO EMPUJÓN. Se guarda para que la UI pueda decir «esto no se guardó»
|
|
1214
|
+
* en vez de enseñar un perfil que solo existe en este aparato. Sin esto, el usuario ve
|
|
1215
|
+
* su cambio en pantalla y cree que está hecho.
|
|
1216
|
+
*/
|
|
1217
|
+
// `ok: null` = TODAVÍA NO SE HA EMPUJADO NADA. Nacía en `true`, así que «nunca se
|
|
1218
|
+
// intentó» y «salió bien» se veían igual — un valor por defecto que dice que sí, en la
|
|
1219
|
+
// función que existe justamente para no tragarse el fallo. Quien pregunte tiene que
|
|
1220
|
+
// poder distinguir las tres cosas, y por eso son tres valores y no dos.
|
|
1221
|
+
let lastProfilePush = { ok: null, at: 0, error: null }
|
|
1103
1222
|
function pushProfileToVault () {
|
|
1104
1223
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1105
1224
|
if (!v?.cert || !device) return
|
|
1106
1225
|
clearTimeout(profilePushTimer)
|
|
1107
1226
|
profilePushTimer = setTimeout(() => {
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
.catch(() => {
|
|
1227
|
+
pushProfileFields(v, device)
|
|
1228
|
+
.then(() => { lastProfilePush = { ok: true, at: Date.now(), error: null } })
|
|
1229
|
+
.catch((e) => {
|
|
1230
|
+
// EL FALLO SE VE. Aquí había un `.catch(() => {})` con un comentario que decía
|
|
1231
|
+
// «se reintenta en la próxima edición», y era falso de dos maneras: la próxima
|
|
1232
|
+
// edición se encontraba la bóveda cerrada otra vez, y mientras tanto el aparato
|
|
1233
|
+
// se quedaba con datos que nadie más tenía. Es exactamente lo que produjo el
|
|
1234
|
+
// «edito y no funciona, y cada dispositivo ve algo distinto».
|
|
1235
|
+
lastProfilePush = { ok: false, at: Date.now(), error: e?.message || String(e) }
|
|
1236
|
+
try { console.warn('[identity] the profile change did NOT reach the vault:', lastProfilePush.error) } catch (_) {}
|
|
1237
|
+
})
|
|
1111
1238
|
}, 800) // debounce: ediciones seguidas = un solo push
|
|
1112
1239
|
}
|
|
1113
1240
|
/**
|
|
@@ -1152,6 +1279,41 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1152
1279
|
* quedaba enseñando un perfil del que ya no era, para siempre, sin que nadie pulsara
|
|
1153
1280
|
* nada porque no había nada que pulsar.
|
|
1154
1281
|
*/
|
|
1282
|
+
/**
|
|
1283
|
+
* COMPONE EL `me` CON LO QUE SE PUDO ABRIR. Devuelve si cambió algo, para no avisar de
|
|
1284
|
+
* una sincronización que no movió nada.
|
|
1285
|
+
*
|
|
1286
|
+
* `links`/`fields` viajan como UN dato cada lista (y su gemelo privado), así que aquí se
|
|
1287
|
+
* vuelven a juntar con su marca de visibilidad — que es de dónde salió la separación.
|
|
1288
|
+
*/
|
|
1289
|
+
function applyPulledProfile (content) {
|
|
1290
|
+
const antes = JSON.stringify(me || {})
|
|
1291
|
+
const next = { ...(me || {}) }
|
|
1292
|
+
const lista = (json, visible) => {
|
|
1293
|
+
try { return (JSON.parse(json) || []).map((x) => ({ ...x, visible })) } catch (_) { return [] }
|
|
1294
|
+
}
|
|
1295
|
+
for (const [k, v] of Object.entries(content)) {
|
|
1296
|
+
if (k === 'links' || k === 'fields' || k === 'links_private' || k === 'fields_private') continue
|
|
1297
|
+
next[k] = v
|
|
1298
|
+
}
|
|
1299
|
+
if (content.links != null || content.links_private != null) {
|
|
1300
|
+
next.links = [...lista(content.links, true), ...lista(content.links_private, false)]
|
|
1301
|
+
}
|
|
1302
|
+
if (content.fields != null || content.fields_private != null) {
|
|
1303
|
+
next.fields = [...lista(content.fields, true), ...lista(content.fields_private, false)]
|
|
1304
|
+
}
|
|
1305
|
+
next.publickey = publickeyJwkStr
|
|
1306
|
+
next.encryptionPubkey = encPublickeyJwkStr
|
|
1307
|
+
if (JSON.stringify(next) === antes) return false
|
|
1308
|
+
me = next
|
|
1309
|
+
saveMe(me)
|
|
1310
|
+
if (typeof next.nickname === 'string') {
|
|
1311
|
+
const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
|
|
1312
|
+
if (e && e.name !== next.nickname) { e.name = next.nickname; saveProfiles(list) }
|
|
1313
|
+
}
|
|
1314
|
+
return true
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1155
1317
|
async function pullProfileFromVault () {
|
|
1156
1318
|
try {
|
|
1157
1319
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
@@ -1161,23 +1323,31 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1161
1323
|
// lo habían echado. La bóveda contesta «vencido» y no pasa nada; y si además ya no
|
|
1162
1324
|
// está en el acta, contesta con el aviso firmado y aquí se le borra la cuenta.
|
|
1163
1325
|
if (!v?.cert || !device) return
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1326
|
+
// EL PAQUETE LO ARMA ESTE APARATO (dueño, 2026-09-03). La bóveda entrega los sobres
|
|
1327
|
+
// que tiene; aquí se abren los que nos tocan y se compone el perfil.
|
|
1328
|
+
const b = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileBundle', args: {}, onRevoked: wipeVaultLink })
|
|
1329
|
+
const entries = b?.entries || {}
|
|
1330
|
+
if (!Object.keys(entries).length) {
|
|
1331
|
+
// La bóveda aún no tiene perfil: sembrar con el local (si tiene contenido).
|
|
1168
1332
|
if (me?.nickname || me?.avatar) pushProfileToVault()
|
|
1169
1333
|
return
|
|
1170
1334
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1335
|
+
const keyring = (b.wraps || []).map((w) => ({ gen: w.gen, wraps: { [publickeyJwkStr]: w.wrap } }))
|
|
1336
|
+
const content = {}
|
|
1337
|
+
for (const [key, e] of Object.entries(entries)) {
|
|
1338
|
+
try {
|
|
1339
|
+
if (e.cls === 'public') { content[key] = e.pubv; continue }
|
|
1340
|
+
content[key] = await Content.decryptWithKeyring({
|
|
1341
|
+
envelope: e.e, keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
|
|
1342
|
+
})
|
|
1343
|
+
} catch (_) {
|
|
1344
|
+
// SIN ENVOLTURA NO SE INVENTA NADA. Un aparato que entró después de escribirse un
|
|
1345
|
+
// dato no tiene su llave hasta que el dueño abra la bóveda; dejarlo fuera es
|
|
1346
|
+
// correcto, y poner un valor por defecto sería fabricar un perfil falso.
|
|
1178
1347
|
}
|
|
1179
|
-
emitVault({ phase: 'profile-sync', updatedAt: remoteMe.updatedAt })
|
|
1180
1348
|
}
|
|
1349
|
+
if (!applyPulledProfile(content)) return
|
|
1350
|
+
emitVault({ phase: 'profile-sync' })
|
|
1181
1351
|
} catch (_) { /* vault apagado: el perfil local sigue mandando */ }
|
|
1182
1352
|
}
|
|
1183
1353
|
|
|
@@ -2275,6 +2445,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
2275
2445
|
return { me: applyMeUpdate(patch || {}) }
|
|
2276
2446
|
},
|
|
2277
2447
|
async getMe () { return me },
|
|
2448
|
+
/**
|
|
2449
|
+
* CÓMO FUE EL ÚLTIMO EMPUJÓN del perfil a la bóveda. Existe para que la interfaz pueda
|
|
2450
|
+
* decir «esto no se guardó» en vez de enseñar tan tranquila un perfil que solo vive en
|
|
2451
|
+
* este aparato — que es lo que pasaba cuando el fallo se tragaba.
|
|
2452
|
+
* `{ ok, at, error }`.
|
|
2453
|
+
*/
|
|
2454
|
+
async profilePushState () { return lastProfilePush },
|
|
2278
2455
|
// Subconjunto PÚBLICO del perfil (solo lo marcado visible) — para compartir/publicar.
|
|
2279
2456
|
async publicMe () {
|
|
2280
2457
|
const m = me || {}
|
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>
|
package/vault/keyid.js
CHANGED
|
File without changes
|
package/vault/peerStore.js
CHANGED
|
File without changes
|
package/vault/remote.js
CHANGED
|
File without changes
|
package/vault/sync.js
CHANGED
|
File without changes
|
package/vault/vault.js
CHANGED
|
File without changes
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
0.
|
|
1
|
+
Copia vendorizada de @dotrino/proxy-client@0.17.0 (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.
|
|
File without changes
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
|
|
2
|
-
import {
|
|
2
|
+
import { seal, open, isSealed } from './sealing.js'
|
|
3
|
+
import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection } from './webrtc.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Error con un `code` estable.
|
|
@@ -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,16 +95,40 @@ 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.
|
|
71
|
-
emit: (event, ...args) =>
|
|
98
|
+
deliverMessage: (from, parsed, meta) => this._deliver(from, parsed, meta),
|
|
99
|
+
emit: (event, ...args) => {
|
|
100
|
+
// UN CANAL QUE SE CAE SE VUELVE A INTENTAR. Sin esto, el «un intento y no más» de
|
|
101
|
+
// `_upgradeDirect` sería para siempre: una desconexión momentánea condenaría a ese
|
|
102
|
+
// destinatario a ir por el proxio el resto de la vida del proceso.
|
|
103
|
+
if (event === 'webrtc_close') this._rtcTried?.delete(args[0])
|
|
104
|
+
this._emit(event, ...args)
|
|
105
|
+
},
|
|
72
106
|
config: this.iceServers ? { iceServers: this.iceServers } : null
|
|
73
107
|
}) : null
|
|
108
|
+
// QUIÉN PUEDE HACERTE NEGOCIAR UN CANAL DIRECTO. Sin política, cualquiera que sepa
|
|
109
|
+
// alcanzarte por el proxio — y negociar arranca DTLS/ICE/SCTP, o sea código que parsea
|
|
110
|
+
// red no confiable. En un navegador se vive con ello; en un proceso que guarda llaves,
|
|
111
|
+
// no. Quien monta el cliente lo acota (`acceptDirectFrom`).
|
|
112
|
+
if (this._rtc && typeof options.acceptDirectFrom === 'function') {
|
|
113
|
+
this._rtc.acceptFrom = options.acceptDirectFrom
|
|
114
|
+
}
|
|
74
115
|
}
|
|
75
116
|
|
|
76
117
|
// ---------- public API ----------
|
|
77
118
|
|
|
78
119
|
get isConnected () { return this._connected }
|
|
79
120
|
|
|
121
|
+
/**
|
|
122
|
+
* WEBRTC EN NODE, si hay con qué. Se busca UNA vez al conectar y no en medio de una
|
|
123
|
+
* negociación: importar un paquete es asíncrono y hacerlo tarde metería una espera justo
|
|
124
|
+
* donde no puede haberla. Si no hay implementación, no pasa nada — se sigue por el
|
|
125
|
+
* proxio, que es el escalón que siempre funciona.
|
|
126
|
+
*/
|
|
127
|
+
async _prepareWebRTC () {
|
|
128
|
+
if (!this._rtc || resolvePeerConnection()) return
|
|
129
|
+
await loadNodePeerConnection()
|
|
130
|
+
}
|
|
131
|
+
|
|
80
132
|
connect () {
|
|
81
133
|
return new Promise((resolve, reject) => {
|
|
82
134
|
if (this._connected) return resolve(this.token)
|
|
@@ -127,6 +179,19 @@ export class WebSocketProxyClient {
|
|
|
127
179
|
if (typeof options.autoReconnect === 'boolean') this.autoReconnect = options.autoReconnect
|
|
128
180
|
if (typeof options.maxReconnectAttempts === 'number') this.maxReconnectAttempts = options.maxReconnectAttempts
|
|
129
181
|
if (typeof options.reconnectDelay === 'number') this.reconnectDelay = options.reconnectDelay
|
|
182
|
+
|
|
183
|
+
// La configuración de sellado TAMBIÉN se aplica aquí. Casi todas las apps piden el
|
|
184
|
+
// cliente con `getWebSocketProxyClient()`, que es un singleton: si el primero en
|
|
185
|
+
// pedirlo no puso `requireSealed`, el que sí lo pide después se quedaba sin él y
|
|
186
|
+
// sin enterarse — la garantía perdida en silencio, que es la peor forma de
|
|
187
|
+
// perderla.
|
|
188
|
+
if (options.sealing) this.sealing = options.sealing
|
|
189
|
+
if (options.myEncPrivateKey) this.myEncPrivateKey = options.myEncPrivateKey
|
|
190
|
+
|
|
191
|
+
// Se puede ENCENDER, no apagar. Bajar la exigencia en caliente dejaría que
|
|
192
|
+
// cualquier otro módulo de la app la desactivara sin querer, y no hay ningún
|
|
193
|
+
// motivo legítimo para hacerlo a mitad de una sesión.
|
|
194
|
+
if (options.requireSealed === true) this.requireSealed = true
|
|
130
195
|
}
|
|
131
196
|
|
|
132
197
|
on (event, handler) {
|
|
@@ -157,6 +222,41 @@ export class WebSocketProxyClient {
|
|
|
157
222
|
}
|
|
158
223
|
if (proxyTokens.length) {
|
|
159
224
|
this._sendRaw({ to: proxyTokens, message: messageStr })
|
|
225
|
+
// Y SE INTENTA IR DIRECTO PARA LA PRÓXIMA, sin esperar a nadie.
|
|
226
|
+
this._upgradeDirect(proxyTokens)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* SIEMPRE EL CAMINO MÁS DIRECTO (dueño, 2026-09-03), pero sin pagar por ello.
|
|
232
|
+
*
|
|
233
|
+
* Hablarle a alguien por primera vez sale por el proxio, que es lo que hay AHORA. En
|
|
234
|
+
* paralelo se abre un canal directo, y desde el segundo mensaje `trySend` ya lo prefiere
|
|
235
|
+
* solo — eso no hay que cambiarlo, ya estaba.
|
|
236
|
+
*
|
|
237
|
+
* **No se espera a la negociación.** Bloquear el primer mensaje hasta tener canal directo
|
|
238
|
+
* haría más lento justo el arranque, que es lo que se quiere arreglar: se manda por donde
|
|
239
|
+
* se pueda y la conexión mejora por debajo.
|
|
240
|
+
*
|
|
241
|
+
* Un intento por destinatario y no más: si no salió, casi siempre es que no se puede
|
|
242
|
+
* (dos NAT que no se dejan, un navegador sin permisos) y reintentar en cada mensaje sería
|
|
243
|
+
* quemar CPU y señalización para nada. Si el canal se cae, `webrtc_close` lo desapunta y
|
|
244
|
+
* el siguiente mensaje vuelve a intentarlo.
|
|
245
|
+
*/
|
|
246
|
+
_upgradeDirect (tokens) {
|
|
247
|
+
if (!this._rtc) return
|
|
248
|
+
this._rtcTried = this._rtcTried || new Set()
|
|
249
|
+
for (const t of tokens) {
|
|
250
|
+
if (this._rtcTried.has(t) || this._rtc.isOpen(t)) continue
|
|
251
|
+
this._rtcTried.add(t)
|
|
252
|
+
// La implementación se busca AQUÍ, justo antes de negociar, y no al conectar: esto ya
|
|
253
|
+
// corre desatendido, así que la espera no se la come nadie. Lanzarlo desde `connect`
|
|
254
|
+
// metía un microtask de más y descuadraba los tiempos de otras cosas — lo cazaron
|
|
255
|
+
// las pruebas del protocolo, que fallaban solo al correr todas juntas.
|
|
256
|
+
Promise.resolve()
|
|
257
|
+
.then(() => this._prepareWebRTC())
|
|
258
|
+
.then(() => this._rtc.connect(t))
|
|
259
|
+
.catch(() => {}) // no poder ir directo no es un fallo: es el caso normal en internet
|
|
160
260
|
}
|
|
161
261
|
}
|
|
162
262
|
|
|
@@ -265,7 +365,67 @@ export class WebSocketProxyClient {
|
|
|
265
365
|
* incorrecto (reinicia negociaciones imposibles y muestra movimientos fuera de
|
|
266
366
|
* contexto). NO lo uses para mensajes de chat, que sí quieren esperar.
|
|
267
367
|
*/
|
|
368
|
+
/**
|
|
369
|
+
* Seal a payload towards a peer's encryption key and send it. This is what an app
|
|
370
|
+
* should use for anything that is not meant for the proxy's eyes.
|
|
371
|
+
*/
|
|
372
|
+
async sendSealed (toPubkeys, payload, { peerEncPub, ...opts } = {}) {
|
|
373
|
+
if (this.sealing) {
|
|
374
|
+
this._sendByPubkeyRaw(toPubkeys, await this.sealing.seal(payload, peerEncPub), opts)
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
if (!peerEncPub) throw Object.assign(new Error('sendSealed: missing peerEncPub'), { code: 'unsealed' })
|
|
378
|
+
this._sendByPubkeyRaw(toPubkeys, await seal(payload, peerEncPub), opts)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_isSealed (msg) {
|
|
382
|
+
return this.sealing ? this.sealing.isSealed(msg) : isSealed(msg)
|
|
383
|
+
}
|
|
384
|
+
|
|
268
385
|
sendByPubkey (toPubkeys, payload, opts = {}) {
|
|
386
|
+
if (this.requireSealed && !this._isSealed(payload)) {
|
|
387
|
+
throw Object.assign(
|
|
388
|
+
new Error('requireSealed: refusing to send a directed message in the clear — use sendSealed()'),
|
|
389
|
+
{ code: 'unsealed' })
|
|
390
|
+
}
|
|
391
|
+
this._sendByPubkeyRaw(toPubkeys, payload, opts)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Hands a message to the app, opening it first when it is sealed.
|
|
396
|
+
*
|
|
397
|
+
* With `requireSealed`, anything that arrives in the clear is DROPPED and reported
|
|
398
|
+
* as `{ type: 'unsealed' }`. Sealing on the way out is not enough on its own: if the
|
|
399
|
+
* receiving end still accepts plaintext, sending it that way bypasses the sealing
|
|
400
|
+
* entirely — and a peer that never read anything could still push a forged payload
|
|
401
|
+
* into the app.
|
|
402
|
+
*/
|
|
403
|
+
async _deliver (from, payload, meta) {
|
|
404
|
+
if (this._isSealed(payload)) {
|
|
405
|
+
if (!this.sealing && !this.myEncPrivateKey) {
|
|
406
|
+
this._emit('error', { type: 'unsealed', reason: 'no_encryption_key', from })
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const opened = this.sealing
|
|
411
|
+
? await this.sealing.open(payload, meta)
|
|
412
|
+
: await open(payload, this.myEncPrivateKey)
|
|
413
|
+
this._emit('message', from, opened, { ...meta, sealed: true })
|
|
414
|
+
} catch (e) {
|
|
415
|
+
// Sealed to somebody else, or tampered with. Staying quiet is the point.
|
|
416
|
+
this._emit('error', { type: 'undecipherable', from, error: e })
|
|
417
|
+
}
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (this.requireSealed) {
|
|
422
|
+
this._emit('error', { type: 'unsealed', reason: 'plaintext_rejected', from })
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
this._emit('message', from, payload, { ...meta, sealed: false })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
_sendByPubkeyRaw (toPubkeys, payload, opts = {}) {
|
|
269
429
|
const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
|
|
270
430
|
const msg = {
|
|
271
431
|
to_publickey: list,
|
|
@@ -314,14 +474,31 @@ export class WebSocketProxyClient {
|
|
|
314
474
|
* (típicamente por el identity vault). Devuelve la respuesta del proxy con
|
|
315
475
|
* `queued_delivered` (mensajes offline despachados al instante).
|
|
316
476
|
*/
|
|
317
|
-
|
|
477
|
+
/**
|
|
478
|
+
* @param {(data:any)=>Promise<any>} [opts.sign] Con qué firmar. Solo se usa para encender
|
|
479
|
+
* TURN, y por eso es opcional: quien no lo pase se queda como estaba.
|
|
480
|
+
*
|
|
481
|
+
* TURN NO ES UN CANAL APARTE: es lo que WebRTC usa cuando no consigue ir directo. Pero
|
|
482
|
+
* un canal por TURN sigue siendo mejor que el proxio, y no por velocidad — **el relevo
|
|
483
|
+
* no puede leer lo que reenvía** (va cifrado extremo a extremo) y el proxio sí. Por eso
|
|
484
|
+
* el orden es: aquí mismo, directo, por TURN, y el proxio el último (dueño, 2026-09-03).
|
|
485
|
+
*
|
|
486
|
+
* Se enciende SOLO y por detrás: encenderlo pide credenciales al proxio, y hacer
|
|
487
|
+
* esperar a `identify` por eso retrasaría todo lo que viene después para ganar algo que
|
|
488
|
+
* solo hace falta cuando se negocie el primer canal.
|
|
489
|
+
*/
|
|
490
|
+
identify ({ data, signature, cert, acta, sign }) {
|
|
318
491
|
if (!data || !signature) throw new Error('identify requires {data, signature}')
|
|
319
492
|
const msg = { type: 'identify', data, signature }
|
|
320
493
|
if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
|
|
321
494
|
// Acta de perfil: el proxy la verifica (va firmada) y bindea también el `profileId`, así
|
|
322
495
|
// escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
|
|
323
496
|
if (acta) msg.acta = acta
|
|
324
|
-
|
|
497
|
+
const done = this._request(msg, 'identified')
|
|
498
|
+
if (this._rtc && typeof sign === 'function' && data.publickey) {
|
|
499
|
+
done.then(() => this.enableTurn({ publicKey: data.publickey, sign })).catch(() => {})
|
|
500
|
+
}
|
|
501
|
+
return done
|
|
325
502
|
}
|
|
326
503
|
|
|
327
504
|
/**
|
|
@@ -713,7 +890,7 @@ export class WebSocketProxyClient {
|
|
|
713
890
|
this._rtc.handleIncoming(from, parsed)
|
|
714
891
|
break
|
|
715
892
|
}
|
|
716
|
-
this.
|
|
893
|
+
this._deliver(from, parsed ?? message, {
|
|
717
894
|
raw: message, timestamp, via: 'proxy',
|
|
718
895
|
fromPubkey: from_publickey || null,
|
|
719
896
|
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 }
|
|
@@ -20,6 +20,60 @@ export const DEFAULT_ICE_SERVERS = [
|
|
|
20
20
|
|
|
21
21
|
const RTC_TAG = '__cc_rtc__'
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* DE DÓNDE SALE `RTCPeerConnection`, y por qué esto existe.
|
|
25
|
+
*
|
|
26
|
+
* En un navegador es nativo. En Node no hay ninguno, y por eso el ecosistema tenía WebRTC
|
|
27
|
+
* apagado a mano en todas partes: dos máquinas Node se hablaban por el proxio aunque
|
|
28
|
+
* estuvieran en la misma red, que es lo contrario de la regla («siempre el camino más
|
|
29
|
+
* directo», CLAUDE.md).
|
|
30
|
+
*
|
|
31
|
+
* Se resuelve en este orden:
|
|
32
|
+
*
|
|
33
|
+
* 1. el del entorno (navegador, o un Node que algún día lo traiga);
|
|
34
|
+
* 2. el que le inyecten (`setPeerConnection`), para no atarse a un paquete concreto;
|
|
35
|
+
* 3. **`@dotrino/webrtc`**, si está instalado — y si no, **`werift`**, del que aquél es
|
|
36
|
+
* una poda. Los dos son WebRTC en JavaScript puro, sin binario nativo, y eso no es una
|
|
37
|
+
* preferencia estética: la bóveda se distribuye como un ejecutable único (SEA) y un
|
|
38
|
+
* `.node` no entra ahí. Se cargan PEREZOSO y ninguno es dependencia de este paquete:
|
|
39
|
+
* quien lo quiera en Node lo instala.
|
|
40
|
+
*
|
|
41
|
+
* La poda va primero porque es la mitad de paquetes y sin la pila de audio y vídeo,
|
|
42
|
+
* que un canal de datos no toca. Se sigue aceptando el de arriba para no obligar a
|
|
43
|
+
* nadie a cambiar, y porque son el mismo código.
|
|
44
|
+
*
|
|
45
|
+
* Si no hay ninguno, WebRTC queda apagado y se sigue por el proxio. Eso no es un fallo:
|
|
46
|
+
* es el escalón 4, que siempre funciona.
|
|
47
|
+
*/
|
|
48
|
+
let _PC = null
|
|
49
|
+
let _PCBuscado = false
|
|
50
|
+
|
|
51
|
+
export function setPeerConnection (impl) { _PC = impl; _PCBuscado = true }
|
|
52
|
+
|
|
53
|
+
export function resolvePeerConnection () {
|
|
54
|
+
if (_PCBuscado) return _PC
|
|
55
|
+
_PCBuscado = true
|
|
56
|
+
if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; return _PC }
|
|
57
|
+
return _PC
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Busca una implementación para Node. Es ASÍNCRONO —importar un paquete lo es— así que se
|
|
62
|
+
* llama una vez al arrancar, no en medio de una negociación.
|
|
63
|
+
*/
|
|
64
|
+
export async function loadNodePeerConnection () {
|
|
65
|
+
if (_PCBuscado && _PC) return _PC
|
|
66
|
+
if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; _PCBuscado = true; return _PC }
|
|
67
|
+
for (const nombre of ['@dotrino/webrtc', 'werift']) {
|
|
68
|
+
try {
|
|
69
|
+
const w = await import(nombre)
|
|
70
|
+
if (typeof w?.RTCPeerConnection === 'function') { _PC = w.RTCPeerConnection; _PCBuscado = true; return _PC }
|
|
71
|
+
} catch (_) { /* no está: se prueba el siguiente, y si no, el proxio (escalón 4) */ }
|
|
72
|
+
}
|
|
73
|
+
_PCBuscado = true
|
|
74
|
+
return _PC
|
|
75
|
+
}
|
|
76
|
+
|
|
23
77
|
export class WebRTCManager {
|
|
24
78
|
/**
|
|
25
79
|
* @param {object} opts
|
|
@@ -31,6 +85,7 @@ export class WebRTCManager {
|
|
|
31
85
|
* @param {{iceServers?: any[]}} [opts.config]
|
|
32
86
|
*/
|
|
33
87
|
constructor (opts) {
|
|
88
|
+
this.acceptFrom = null // ver `handleIncoming`: lo pone quien monta el cliente
|
|
34
89
|
this.getSelfToken = opts.getSelfToken
|
|
35
90
|
this.signalSend = opts.signalSend
|
|
36
91
|
this.deliverMessage = opts.deliverMessage
|
|
@@ -43,8 +98,19 @@ export class WebRTCManager {
|
|
|
43
98
|
* True if this is a control envelope and was consumed.
|
|
44
99
|
* Otherwise the caller should keep delivering it normally.
|
|
45
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* QUIÉN PUEDE HACERTE NEGOCIAR. Antes: cualquiera que supiera alcanzarte por el proxio.
|
|
103
|
+
*
|
|
104
|
+
* Aceptar una señal arranca DTLS, ICE y SCTP — código que parsea red no confiable— así
|
|
105
|
+
* que quien decide si eso corre no puede ser el que llama a la puerta. En un navegador
|
|
106
|
+
* eso ya era así y se vivía con ello; en la bóveda es el proceso que tiene la maestra.
|
|
107
|
+
*
|
|
108
|
+
* `acceptFrom` lo decide quien monta el cliente: la bóveda solo acepta a MIEMBROS DE SU
|
|
109
|
+
* ACTA. Sin política se mantiene lo de antes, para no romper a quien ya dependía de ello.
|
|
110
|
+
*/
|
|
46
111
|
handleIncoming (from, parsed) {
|
|
47
112
|
if (!parsed || typeof parsed !== 'object' || parsed.t !== RTC_TAG) return false
|
|
113
|
+
if (this.acceptFrom && !this.acceptFrom(from)) return false
|
|
48
114
|
const peer = this._ensurePeer(from)
|
|
49
115
|
this._handleSignal(peer, parsed).catch((e) => {
|
|
50
116
|
this.emit('error', { type: 'webrtc_signal', error: e, peer: from })
|
|
@@ -135,7 +201,9 @@ export class WebRTCManager {
|
|
|
135
201
|
}
|
|
136
202
|
|
|
137
203
|
_createPC (peer) {
|
|
138
|
-
const
|
|
204
|
+
const PC = resolvePeerConnection()
|
|
205
|
+
if (!PC) throw new Error('no WebRTC here: install `werift` for Node, or run this in a browser')
|
|
206
|
+
const pc = new PC({ iceServers: this.iceServers })
|
|
139
207
|
peer.pc = pc
|
|
140
208
|
peer.polite = this._isPolite(peer.remote)
|
|
141
209
|
|
|
@@ -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.60.3 (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,18 @@ 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 = {
|
|
55
|
+
const SCOPE_TO_CAP = {
|
|
56
|
+
'vault:sign': 'sign',
|
|
57
|
+
'vault:store': 'store',
|
|
58
|
+
'vault:read': 'read',
|
|
59
|
+
'vault:admin': 'admin',
|
|
60
|
+
'vault:passwords': 'passwords',
|
|
61
|
+
// `replica` SÍ se empareja, al revés que `sealer` y `admin`. La razón es la misma que
|
|
62
|
+
// hace estrecho al permiso: un replicador reparte sobres que no puede abrir y no cambia
|
|
63
|
+
// nada. Y se despliega sin teclado —un contenedor, una máquina ajena—, que es justo
|
|
64
|
+
// donde obligar a un segundo paso a mano es el paso que nadie da.
|
|
65
|
+
'vault:replica': 'replica'
|
|
66
|
+
}
|
|
56
67
|
export const scopeToCaps = (scope) =>
|
|
57
68
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
58
69
|
|
|
@@ -277,7 +288,12 @@ export function createEnrollDesk ({
|
|
|
277
288
|
pend.continuity = (okC && d.continuity.member === d.dpub) ? d.continuity : null
|
|
278
289
|
}
|
|
279
290
|
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
280
|
-
|
|
291
|
+
// EL NOMBRE QUE PUSISTE AQUÍ MANDA. El aparato manda el suyo al enrolarse, y hasta
|
|
292
|
+
// ahora pisaba siempre al de la bóveda — como el aparato usa por defecto el apodo del
|
|
293
|
+
// PERFIL, acababas con varios dispositivos llamados igual que tú y sin forma de saber
|
|
294
|
+
// cuál era cuál. Si en `pair` le diste un nombre, ese es el nombre; el del aparato
|
|
295
|
+
// sigue valiendo como propuesta cuando no dijiste nada.
|
|
296
|
+
if (d.label && !pend.label) pend.label = String(d.label).slice(0, 60)
|
|
281
297
|
// Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
|
|
282
298
|
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
283
299
|
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
@@ -359,7 +375,7 @@ export function createEnrollDesk ({
|
|
|
359
375
|
pend.state = 'DONE'
|
|
360
376
|
pending.delete(pend.token)
|
|
361
377
|
fire(onPendingChange)
|
|
362
|
-
log(
|
|
378
|
+
log(`[vault] device approved: ${pend.deviceId}`)
|
|
363
379
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
364
380
|
}
|
|
365
381
|
|
|
@@ -383,9 +399,12 @@ export function createEnrollDesk ({
|
|
|
383
399
|
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
384
400
|
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
|
|
385
401
|
if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
|
|
386
|
-
|
|
402
|
+
// Ya no hay campo `sealer`: se le pregunta al PERMISO. Es la misma comprobación —«¿me
|
|
403
|
+
// nombra a mí la que me mandan?»— dicha en el idioma nuevo, y con varios selladores la
|
|
404
|
+
// respuesta puede ser que sí para más de uno, que es lo correcto.
|
|
405
|
+
if (!canSeal(record, iss)) {
|
|
387
406
|
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
388
|
-
return reply(from, { type: MSG_ERROR, error: 'that record does not
|
|
407
|
+
return reply(from, { type: MSG_ERROR, error: 'that record does not let this vault seal it' })
|
|
389
408
|
}
|
|
390
409
|
if (record.sealedBy !== pend.dpub) {
|
|
391
410
|
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
|
}
|
|
@@ -70,7 +84,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
70
84
|
const client = injectedClient || await (async () => {
|
|
71
85
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
72
86
|
const c = new WebSocketProxyClient({
|
|
73
|
-
|
|
87
|
+
// WEBRTC SOLO DONDE EXISTE (ver `lib/src/service.js`): en Node no hay
|
|
88
|
+
// `RTCPeerConnection` y encenderlo reventaría al negociar; en un navegador es nativo y es
|
|
89
|
+
// el camino directo que hay que preferir. Se mira, en vez de apagarlo para siempre.
|
|
90
|
+
url: proxy, enableWebRTC: typeof globalThis.RTCPeerConnection === 'function', autoReconnect: true,
|
|
74
91
|
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
75
92
|
})
|
|
76
93
|
await c.connect()
|
|
@@ -137,13 +154,20 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
137
154
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
138
155
|
return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
139
156
|
}
|
|
140
|
-
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert,
|
|
157
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
|
|
141
158
|
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
142
159
|
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
143
160
|
const { issued } = await identity.listDelegations()
|
|
144
161
|
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
145
|
-
|
|
146
|
-
|
|
162
|
+
// EL SCOPE SALE DEL ACTA, no del papel viejo: el papel dice a qué se comprometió esta
|
|
163
|
+
// bóveda al conectar el aparato; el acta, lo que puede HOY.
|
|
164
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
165
|
+
if (!acta) return send(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
166
|
+
const scope = memberScopes(acta, p.cert.sub)
|
|
167
|
+
if (!scope.length) return send(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
|
|
168
|
+
const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '' })
|
|
169
|
+
// El acta viaja con el papel: sin ella quien lo recibe no puede comprobar quién lo firmó.
|
|
170
|
+
send(from, { type: MSG.RENEWED, cert, acta })
|
|
147
171
|
}
|
|
148
172
|
|
|
149
173
|
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
@@ -153,12 +177,12 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
153
177
|
const d = p?.data
|
|
154
178
|
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
155
179
|
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,
|
|
180
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()) })
|
|
157
181
|
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
158
182
|
const { issued, revoked, revokedCerts } = await identity.listDelegations()
|
|
159
183
|
const devices = await Promise.all((issued || []).map(async (x) => ({
|
|
160
184
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
|
|
161
|
-
label: x.label || '', scope: x.scope,
|
|
185
|
+
label: x.label || '', scope: x.scope, seq: x.seq, nonce: x.nonce
|
|
162
186
|
})))
|
|
163
187
|
send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
|
|
164
188
|
// ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
|
|
@@ -188,12 +212,23 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
188
212
|
const chk = await verifyChain({
|
|
189
213
|
data: d, signature: p.signature, cert: p.cert,
|
|
190
214
|
...(expectedScope ? { expectedScope } : {}),
|
|
191
|
-
|
|
215
|
+
...(await contextoActa()), revoked: await revocationSet(),
|
|
192
216
|
})
|
|
193
217
|
if (!chk.ok) {
|
|
194
218
|
send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
195
219
|
return null
|
|
196
220
|
}
|
|
221
|
+
// EL ACTA MANDA, EL PAPEL SOLO ACOMPAÑA. Aquí y no en cada mostrador: el certificado
|
|
222
|
+
// dice a qué se comprometió esta bóveda al conectar el aparato, y el acta lo que puede
|
|
223
|
+
// HOY. Quitarle un permiso sella el acta pero no le retira el papel, que vive hasta 30
|
|
224
|
+
// días, así que sin esto seguiría entrando hasta que lo renovara.
|
|
225
|
+
if (expectedScope) {
|
|
226
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
227
|
+
if (record && !memberCanScope(record, chk.device, expectedScope)) {
|
|
228
|
+
send(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member no longer has that permission' })
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
}
|
|
197
232
|
return chk
|
|
198
233
|
}
|
|
199
234
|
|
|
@@ -302,13 +337,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
302
337
|
*/
|
|
303
338
|
async function listMachines () {
|
|
304
339
|
const { issued } = await identity.listDelegations()
|
|
305
|
-
const now = Date.now()
|
|
306
340
|
const bySub = new Map()
|
|
307
341
|
for (const x of (issued || [])) {
|
|
308
|
-
if (!x.sub || x.revokedAt
|
|
342
|
+
if (!x.sub || x.revokedAt) continue // revocada = fuera de la lista
|
|
309
343
|
if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
|
|
310
344
|
if (!x.label || x.label === 'cli') continue
|
|
311
|
-
|
|
345
|
+
// Se queda el papel del acta MÁS NUEVA de esa llave: ya no hay «el que vence más
|
|
346
|
+
// tarde», porque ninguno vence.
|
|
347
|
+
if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
|
|
312
348
|
}
|
|
313
349
|
return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
|
|
314
350
|
}
|
|
@@ -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
|
/**
|