@dotrino/identity 0.79.0 → 0.81.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 +1 -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 +8 -8
- 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 +0 -0
- 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 +1 -1
- package/vault/vendor/proxy-client/canonical.js +0 -0
- package/vault/vendor/proxy-client/client.js +80 -4
- package/vault/vendor/proxy-client/index.js +0 -0
- package/vault/vendor/proxy-client/sealing.js +0 -0
- package/vault/vendor/proxy-client/signature.js +0 -0
- package/vault/vendor/proxy-client/webrtc.js +69 -1
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/enroll.js +12 -1
- package/vault/vendor/vault/index.js +4 -1
- package/vault/vendor/vault/protocol.js +15 -0
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
File without changes
|
package/package.json
CHANGED
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
|
@@ -968,8 +968,8 @@ export function effectiveCaps (acta, pub, extraRenounces = []) {
|
|
|
968
968
|
* `ns` es opcional y es para lo que va atado a un cajón: un servicio (miembro con `cn`)
|
|
969
969
|
* solo firma dentro del suyo. Sin `ns`, se pregunta por la capacidad a secas.
|
|
970
970
|
*/
|
|
971
|
-
export function memberCanSign (acta, pub, ns = null) {
|
|
972
|
-
if (!memberCan(acta, pub, 'sign')) return false
|
|
971
|
+
export function memberCanSign (acta, pub, ns = null, extraRenounces = []) {
|
|
972
|
+
if (!memberCan(acta, pub, 'sign', extraRenounces)) return false
|
|
973
973
|
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
974
974
|
// SIN `cn` = un aparato TUYO: firma como tú, y no hay nada que preguntarle a nadie.
|
|
975
975
|
if (!m?.cn) return true
|
|
@@ -996,19 +996,19 @@ export function memberCanSign (acta, pub, ns = null) {
|
|
|
996
996
|
* Devuelve `false` ante un scope que no reconoce: si aparece uno nuevo, lo que toca es
|
|
997
997
|
* añadirlo aquí, no que se cuele por no estar en la lista.
|
|
998
998
|
*/
|
|
999
|
-
export function memberCanScope (acta, pub, scope) {
|
|
999
|
+
export function memberCanScope (acta, pub, scope, extraRenounces = []) {
|
|
1000
1000
|
if (!acta || typeof scope !== 'string') return false
|
|
1001
1001
|
const secretos = /^vault:secrets:([a-z0-9-]{1,32})$/.exec(scope)
|
|
1002
|
-
if (secretos) return memberCanReadSecrets(acta, pub, secretos[1])
|
|
1003
|
-
if (scope === 'vault:sign') return memberCanSign(acta, pub)
|
|
1002
|
+
if (secretos) return memberCanReadSecrets(acta, pub, secretos[1], extraRenounces)
|
|
1003
|
+
if (scope === 'vault:sign') return memberCanSign(acta, pub, null, extraRenounces)
|
|
1004
1004
|
const cap = Object.keys(CAP_SCOPE).find((c) => CAP_SCOPE[c] === scope)
|
|
1005
|
-
return cap ? memberCan(acta, pub, cap) : false
|
|
1005
|
+
return cap ? memberCan(acta, pub, cap, extraRenounces) : false
|
|
1006
1006
|
}
|
|
1007
1007
|
|
|
1008
|
-
export function memberCanReadSecrets (acta, pub, ns) {
|
|
1008
|
+
export function memberCanReadSecrets (acta, pub, ns, extraRenounces = []) {
|
|
1009
1009
|
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
1010
1010
|
if (!m || !m.cn) return false
|
|
1011
|
-
return m.cn === ns && effectiveCaps(acta, pub).includes('secrets')
|
|
1011
|
+
return m.cn === ns && effectiveCaps(acta, pub, extraRenounces).includes('secrets')
|
|
1012
1012
|
}
|
|
1013
1013
|
|
|
1014
1014
|
/** Los scopes de cert que le corresponden a un miembro según el acta. */
|
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
|
File without changes
|
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,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/proxy-client@0.
|
|
1
|
+
Copia vendorizada de @dotrino/proxy-client@0.17.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.js).
|
|
2
2
|
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
3
|
sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
|
|
4
4
|
por el import map): solo se carga si de verdad se sella algo.
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
|
|
2
2
|
import { seal, open, isSealed } from './sealing.js'
|
|
3
|
-
import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS } from './webrtc.js'
|
|
3
|
+
import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection } from './webrtc.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Error con un `code` estable.
|
|
@@ -96,15 +96,39 @@ export class WebSocketProxyClient {
|
|
|
96
96
|
getSelfToken: () => this.token,
|
|
97
97
|
signalSend: (to, payload) => this._proxySendOne(to, payload),
|
|
98
98
|
deliverMessage: (from, parsed, meta) => this._deliver(from, parsed, meta),
|
|
99
|
-
emit: (event, ...args) =>
|
|
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
|
+
},
|
|
100
106
|
config: this.iceServers ? { iceServers: this.iceServers } : null
|
|
101
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
|
+
}
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
// ---------- public API ----------
|
|
105
118
|
|
|
106
119
|
get isConnected () { return this._connected }
|
|
107
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
|
+
|
|
108
132
|
connect () {
|
|
109
133
|
return new Promise((resolve, reject) => {
|
|
110
134
|
if (this._connected) return resolve(this.token)
|
|
@@ -198,6 +222,41 @@ export class WebSocketProxyClient {
|
|
|
198
222
|
}
|
|
199
223
|
if (proxyTokens.length) {
|
|
200
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
|
|
201
260
|
}
|
|
202
261
|
}
|
|
203
262
|
|
|
@@ -415,14 +474,31 @@ export class WebSocketProxyClient {
|
|
|
415
474
|
* (típicamente por el identity vault). Devuelve la respuesta del proxy con
|
|
416
475
|
* `queued_delivered` (mensajes offline despachados al instante).
|
|
417
476
|
*/
|
|
418
|
-
|
|
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 }) {
|
|
419
491
|
if (!data || !signature) throw new Error('identify requires {data, signature}')
|
|
420
492
|
const msg = { type: 'identify', data, signature }
|
|
421
493
|
if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
|
|
422
494
|
// Acta de perfil: el proxy la verifica (va firmada) y bindea también el `profileId`, así
|
|
423
495
|
// escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
|
|
424
496
|
if (acta) msg.acta = acta
|
|
425
|
-
|
|
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
|
|
426
502
|
}
|
|
427
503
|
|
|
428
504
|
/**
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/vault@0.
|
|
1
|
+
Copia vendorizada de @dotrino/vault@0.61.0 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
|
|
2
2
|
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
3
|
index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
|
|
4
4
|
@dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y
|
|
@@ -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
|
|
|
@@ -84,7 +84,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
84
84
|
const client = injectedClient || await (async () => {
|
|
85
85
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
86
86
|
const c = new WebSocketProxyClient({
|
|
87
|
-
|
|
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,
|
|
88
91
|
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
89
92
|
})
|
|
90
93
|
await c.connect()
|
|
@@ -128,3 +128,18 @@ export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,
|
|
|
128
128
|
* remota y el lector de `.env`, y tres copias de una regla son tres reglas.
|
|
129
129
|
*/
|
|
130
130
|
export const isValidVarKey = (key) => typeof key === 'string' && /^[A-Z0-9_]{1,64}$/.test(key)
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* LA VERSIÓN DEL CABLE (CONVENCIONES §14, `@dotrino/compat`).
|
|
134
|
+
*
|
|
135
|
+
* Sube **solo cuando cambia el protocolo**, no con cada release: es lo que decide *si dos
|
|
136
|
+
* piezas pueden hablar*. La `version` del paquete decide otra cosa —si esa build concreta
|
|
137
|
+
* está rota— y por eso hacen falta las dos.
|
|
138
|
+
*
|
|
139
|
+
* Empieza en 1 hoy, con todo lo anterior sin declarar nada: a quien calla se le atiende
|
|
140
|
+
* hasta que cierre la ventana de migración del pilar, y después no.
|
|
141
|
+
*/
|
|
142
|
+
export const VAULT_PROTOCOL = 1
|
|
143
|
+
|
|
144
|
+
/** Los protocolos que esta build entiende. Al añadir el 2, aquí van `[1, 2]`. */
|
|
145
|
+
export const VAULT_SPEAKS = [1]
|