@dotrino/identity 0.84.0 → 0.86.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/README.md CHANGED
@@ -160,6 +160,55 @@ const v = await verifySignedFor({ data: pin, signature, publickey, chain, audien
160
160
  `aud` es obligatorio igual que en la prueba; `exp` es opcional, porque la caducidad de lo
161
161
  publicado la lleva el servicio (el TTL del pin) y no el cuerpo.
162
162
 
163
+ ### Permiso por origen (0.86.0+)
164
+
165
+ Una prueba dice **quién eres** sin preguntar nada: quien llega al iframe ya pasó el filtro
166
+ de orígenes, y pedir permiso treinta veces al día por aplicaciones del mismo dueño es
167
+ ceremonia, no seguridad. Cualquier **dato tuyo** —nombre, foto, correo, enlaces— es otra
168
+ cosa: hace falta que lo concedas a ESE origen, y lo concedido se guarda y se puede retirar.
169
+
170
+ ```js
171
+ await id.listGrants() // [{ origin, scopes, at }]
172
+ await id.revokeGrant(origin) // y la próxima vez se vuelve a preguntar
173
+ ```
174
+
175
+ **El panel lo pinta la bóveda, no la aplicación.** Vive en otro origen, así que la página
176
+ que pide no puede pulsar ahí dentro ni leerlo; lo único que puede hacer es no mostrarlo, y
177
+ entonces no consigue el permiso — que es el lado correcto en el que fallar. Sin nadie a
178
+ quien preguntar (Node, o un cliente que no muestra nada), la respuesta es **no**: nunca se
179
+ amplía en silencio.
180
+
181
+ ### Entrar sin enrolar: las sesiones (0.85.0+)
182
+
183
+ Enlazar un aparato y entrar en uno no son lo mismo. Enlazar mete una llave en el acta —hay
184
+ que sellarla, o sea despertar a la selladora y tener el perfil abierto— y salir es sellar
185
+ otra vez. Nadie hace eso para abrir una aplicación en un navegador prestado.
186
+
187
+ Una **sesión** es la otra puerta: una llave que vive en ese navegador y un **papel** con
188
+ vencimiento que la respalda, firmado por un aparato tuyo que sí está en el acta.
189
+
190
+ ```js
191
+ import { openSession } from '@dotrino/identity/session-flow' // el que entra
192
+ import { grantSession } from '@dotrino/identity/session-flow' // el que respalda
193
+ import { verifySession } from '@dotrino/identity/session' // quien recibe algo suyo
194
+ ```
195
+
196
+ El QR va **al revés** que en el emparejamiento: lo muestra quien quiere entrar, y lo escanea
197
+ el teléfono — quien entra puede no tener cámara, el teléfono siempre la tiene.
198
+
199
+ Lo que hace que esto no sea una llave maestra de repuesto:
200
+
201
+ - **Nunca amplía**: cada alcance exige que el aparato que firmó lo tenga **hoy**, y se
202
+ comprueba contra el acta al verificar, no solo al emitir.
203
+ - **Lista negra fija**: jamás `secrets`, `admin`, `approve`, `sealer`, `passwords`,
204
+ `unattended` ni `replica`, aunque el aparato los tenga.
205
+ - **Vence por reloj** (8 h por defecto, tope 24). Es la excepción deliberada a *los papeles
206
+ ya no caducan por reloj*: un certificado describe pertenencia, que dura; una sesión **es**
207
+ temporal.
208
+ - **Muere con su aparato**: si quitas del acta al que la respalda, sus sesiones caen solas.
209
+ Sin avisar a nadie ni perseguir papeles.
210
+ - **No se re-delega**: una sesión no abre otra sesión.
211
+
163
212
  Diseño y hacia dónde va (inicio de sesión y federación):
164
213
  [`dotrino-vault/docs/inicio-de-sesion.md`](../dotrino-vault/docs/inicio-de-sesion.md).
165
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.84.0",
3
+ "version": "0.86.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",
@@ -33,6 +33,12 @@
33
33
  },
34
34
  "./keyid": {
35
35
  "import": "./vault/keyid.js"
36
+ },
37
+ "./session": {
38
+ "import": "./vault/session.js"
39
+ },
40
+ "./session-flow": {
41
+ "import": "./vault/sessionFlow.js"
36
42
  }
37
43
  },
38
44
  "files": [
package/src/index.d.ts CHANGED
@@ -124,6 +124,10 @@ export class Identity {
124
124
  listContacts (): Promise<PeerInfo[]>
125
125
  signData (data: any): Promise<{ signature: string; publickey: string }>
126
126
  requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number }): Promise<Assertion>
127
+ /** Qué le has concedido a cada aplicación (permiso por origen). */
128
+ listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number }>>
129
+ /** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
130
+ revokeGrant (origin: string): Promise<{ ok: boolean }>
127
131
  setMyNickname (nickname: string): Promise<{ me: Me }>
128
132
  getEncryptionPubkey (): Promise<string>
129
133
  encrypt (recipients: EncryptRecipient[], plaintext: string): Promise<EnvelopeV1>
@@ -272,3 +276,45 @@ export function assertionBody (args: { sub: string; aud: string; nonce: string;
272
276
  export function verifyAssertion (assertion: Assertion, opts: { audience: string; nonce: string; expectedProfileId?: string | null; now?: number; maxSkewMs?: number }): Promise<VerifiedAssertion>
273
277
  /** ¿Este contenido firmado (un pin, una atestación) va dirigido a mí? `aud` obligatorio; `exp` opcional. */
274
278
  export function verifySignedFor (args: { data: any; signature: string; publickey: string; chain: any[]; audience: string; expectedProfileId?: string | null; now?: number; maxSkewMs?: number }): Promise<{ ok: boolean; reason?: string; profileId?: string; signer?: string; seq?: number; aud?: string }>
279
+
280
+ // ----- Sesiones: entrar sin enrolar (`@dotrino/identity/session`) -----
281
+
282
+ export type SessionScope = 'id:whoami' | 'vault:store'
283
+
284
+ export interface SessionPaper {
285
+ v: 1
286
+ op: 'session'
287
+ sid: string // el identificador que el usuario ve para poder cerrarla
288
+ s: string // pubkey de la sesión (vive en el aparato prestado)
289
+ by: string // pubkey del aparato que la respalda (miembro del acta)
290
+ origin: string // dónde vale
291
+ scopes: SessionScope[]
292
+ iat: number
293
+ exp: number // vence por reloj: una sesión ES temporal
294
+ sig: string // firma del aparato que respalda
295
+ }
296
+
297
+ export interface VerifiedSession {
298
+ ok: boolean
299
+ reason?: string
300
+ profileId?: string
301
+ seq?: number
302
+ sid?: string
303
+ s?: string
304
+ by?: string
305
+ origin?: string
306
+ scopes?: SessionScope[]
307
+ exp?: number
308
+ }
309
+
310
+ export const SESSION_SCOPES: readonly SessionScope[]
311
+ export const SESSION_FORBIDDEN: readonly string[]
312
+ export const SESSION_DEFAULT_TTL_MS: number
313
+ export const SESSION_MAX_TTL_MS: number
314
+ export function newSessionId (): string
315
+ export function cleanSessionScopes (scopes?: string[]): SessionScope[]
316
+ export function signSession (args: { sid: string; s: string; by: string; origin: string; scopes?: string[]; ttlMs?: number; now?: number }, sign: (body: any) => Promise<any>): Promise<SessionPaper>
317
+ /** ¿Vale este papel AHORA? La cadena de actas es obligatoria: sin ella no se puede juzgar. */
318
+ export function verifySession (paper: SessionPaper, opts: { chain: any[]; expectedProfileId?: string | null; origin?: string | null; now?: number; maxSkewMs?: number }): Promise<VerifiedSession>
319
+ /** ¿Firmó esta sesión esto, y su papel lo cubría? */
320
+ export function verifySessionSigned (args: { data: any; signature: string; session: SessionPaper; chain: any[]; scope?: SessionScope | null; origin?: string | null; expectedProfileId?: string | null; now?: number }): Promise<VerifiedSession>
package/src/index.js CHANGED
@@ -146,6 +146,12 @@ export class Identity {
146
146
  }
147
147
 
148
148
  if (msg.type === 'event') {
149
+ // MOSTRAR EL IFRAME PARA QUE PREGUNTE. El panel de permiso lo pinta la bóveda,
150
+ // en su propio origen: esta página no puede pulsar ahí dentro ni leerlo. Lo
151
+ // único que hace aquí es dejar sitio. Si una aplicación decidiera no hacerlo,
152
+ // no obtiene el permiso — que es el lado correcto en el que fallar.
153
+ if (msg.event === 'consent:open') this._showVault(true)
154
+ if (msg.event === 'consent:close') this._showVault(false)
149
155
  this._emit(msg.event, msg.payload)
150
156
  }
151
157
  }
@@ -157,6 +163,16 @@ export class Identity {
157
163
  return this._ready
158
164
  }
159
165
 
166
+ /** Deja ver la bóveda (a pantalla completa) mientras pregunta, y la devuelve a su sitio. */
167
+ _showVault (visible) {
168
+ const f = this._iframe
169
+ if (!f) return
170
+ f.style.cssText = visible
171
+ ? 'position:fixed;inset:0;width:100%;height:100%;border:0;z-index:2147483000'
172
+ : 'display:none'
173
+ f.setAttribute('aria-hidden', visible ? 'false' : 'true')
174
+ }
175
+
160
176
  destroy () {
161
177
  if (this._handler) window.removeEventListener('message', this._handler)
162
178
  if (this._iframe && this._iframe.parentNode) this._iframe.parentNode.removeChild(this._iframe)
@@ -256,6 +272,11 @@ export class Identity {
256
272
  * @returns {Promise<object>} la prueba, lista para mandar. Se comprueba con
257
273
  * `verifyAssertion(prueba, { audience, nonce })`.
258
274
  */
275
+ /** Qué le has concedido a cada aplicación. Sin esto, conceder no significaría nada. */
276
+ async listGrants () { return this._call('listGrants') }
277
+ /** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
278
+ async revokeGrant (origin) { return this._call('revokeGrant', { origin }) }
279
+
259
280
  async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
260
281
  const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs })
261
282
  return assertion
package/vault/core.js CHANGED
@@ -37,6 +37,7 @@ export const ACTA_STORAGE = 'dotrino.identity.acta' // acta de p
37
37
  export const ACTA_HISTORY_STORAGE = 'dotrino.identity.acta.history' // últimas actas selladas (§1.3)
38
38
  export const PENDING_JOIN_STORAGE = 'dotrino.identity.pendingJoin' // «nací para adoptar la cuenta de otro»
39
39
  export const RENOUNCE_STORAGE = 'dotrino.identity.renounced' // renuncias propias aún no absorbidas por el master
40
+ export const GRANTS_STORAGE = 'dotrino.identity.grants' // qué le concediste a cada origen (permiso por origen)
40
41
  // Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
41
42
  // namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
42
43
  export const PROFILES_STORAGE = 'dotrino.identity.profiles' // [{ id, name, pubkey }]
@@ -183,7 +184,15 @@ function sanitizeProfilePatch (patch = {}) {
183
184
  return out
184
185
  }
185
186
 
186
- export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null }) {
187
+ /**
188
+ * `askConsent` es CÓMO SE PREGUNTA, y lo pone quien monta el núcleo (el iframe pinta su
189
+ * propio panel; en Node no hay a quién preguntar). Se inyecta en vez de vivir aquí porque
190
+ * este módulo no toca interfaz — y porque QUIÉN pinta importa: un panel dibujado por la
191
+ * aplicación que pide sería la aplicación aprobándose a sí misma.
192
+ *
193
+ * Si no se inyecta, no se concede nada nuevo: sin forma de preguntar, la respuesta es no.
194
+ */
195
+ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null, askConsent = null }) {
187
196
  const {
188
197
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
189
198
  } = peers
@@ -594,6 +603,16 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
594
603
  // Diseño en `dotrino-vault/docs/acta-de-perfil.md`. Aquí solo se guarda, se lee y se
595
604
  // sella; las reglas (sellador único, seq/prev, no dejar el perfil sin firmante) viven en
596
605
  // `acta.js`, que es puro y está probado aparte.
606
+ // ----- PERMISO POR ORIGEN: qué le concedió el usuario a cada aplicación -----
607
+ //
608
+ // `{ [origin]: { scopes: [...], at } }`. Vive en el kv del PERFIL, así que cambiar de
609
+ // perfil cambia lo concedido: lo que le diste a una aplicación desde tu cuenta de trabajo
610
+ // no vale para la personal.
611
+ const loadGrants = () => { try { return JSON.parse(kv.getItem(GRANTS_STORAGE) || '{}') } catch (_) { return {} } }
612
+ const saveGrants = (g) => { try { kv.setItem(GRANTS_STORAGE, JSON.stringify(g)) } catch (_) {} }
613
+ /** Lo concedido a un origen, hoy. */
614
+ const grantedTo = (origin) => (loadGrants()[String(origin || '')]?.scopes) || []
615
+
597
616
  const loadActa = () => { try { return JSON.parse(kv.getItem(ACTA_STORAGE) || 'null') } catch (_) { return null } }
598
617
  const saveActa = (a) => kv.setItem(ACTA_STORAGE, JSON.stringify(a))
599
618
  // VENTANA DE RETENCIÓN (§1.3): el master conserva las últimas actas para que un miembro
@@ -1382,6 +1401,49 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1382
1401
  'profileActa', 'profileMembers', 'myMembership', 'isMaster', 'sealerChain'
1383
1402
  ])
1384
1403
 
1404
+ /**
1405
+ * ¿QUÉ SE LE DEJA VER A ESTE ORIGEN? El corazón del permiso por origen.
1406
+ *
1407
+ * `id:whoami` —solo quién eres, sin ningún dato— se concede sin preguntar: quien llega
1408
+ * aquí ya pasó el filtro de orígenes del iframe, y preguntarlo treinta veces al día por
1409
+ * aplicaciones del mismo dueño es ceremonia, no seguridad.
1410
+ *
1411
+ * Todo lo DEMÁS (nombre, foto, correo, redes) exige una concesión guardada, y si no la
1412
+ * hay se pregunta. Lo que el usuario diga se guarda por origen y se puede retirar.
1413
+ *
1414
+ * Y si no hay a quién preguntar —Node, o un cliente que no muestra el panel— se devuelve
1415
+ * lo que ya estuviera concedido y nada más. **No se amplía en silencio**: sin respuesta,
1416
+ * la respuesta es no.
1417
+ */
1418
+ async function consentFor (origin, pedidos) {
1419
+ const org = String(origin || '').trim()
1420
+ const base = pedidos.filter((s) => s === 'id:whoami')
1421
+ const extra = pedidos.filter((s) => s !== 'id:whoami')
1422
+ if (!extra.length) return pedidos
1423
+ // Sin origen no se puede llevar la cuenta de a quién se le concedió qué, así que no se
1424
+ // concede nada más que el mínimo. Es el caso de Node y el de una llamada interna.
1425
+ if (!org) return base.length ? base : ['id:whoami']
1426
+
1427
+ const yaTiene = grantedTo(org)
1428
+ const faltan = extra.filter((s) => !yaTiene.includes(s))
1429
+ if (!faltan.length) return pedidos
1430
+
1431
+ if (typeof askConsent !== 'function') {
1432
+ const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
1433
+ return conocidos.length ? conocidos : ['id:whoami']
1434
+ }
1435
+ let ok = false
1436
+ try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene })) } catch (_) { ok = false }
1437
+ if (!ok) {
1438
+ const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
1439
+ return conocidos.length ? conocidos : ['id:whoami']
1440
+ }
1441
+ const g = loadGrants()
1442
+ g[org] = { scopes: [...new Set([...yaTiene, ...faltan])].sort(), at: Date.now() }
1443
+ saveGrants(g)
1444
+ return pedidos
1445
+ }
1446
+
1385
1447
  const handlers = {
1386
1448
  async profileLockStatus () {
1387
1449
  refreshLockState()
@@ -1641,13 +1703,31 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1641
1703
  * (`dotrino-vault/docs/inicio-de-sesion.md`); hasta entonces esto no entrega nada que
1642
1704
  * no se entregue ya, que es la forma de no adelantar una decisión del usuario.
1643
1705
  */
1644
- async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
1706
+ /**
1707
+ * QUÉ LE HAS CONCEDIDO A CADA APLICACIÓN. Es la mitad que hace que el permiso sea del
1708
+ * usuario y no un trámite: si no se puede ver ni retirar, conceder no significa nada.
1709
+ */
1710
+ async listGrants () {
1711
+ const g = loadGrants()
1712
+ return Object.entries(g).map(([origin, v]) => ({ origin, scopes: v?.scopes || [], at: v?.at || 0 }))
1713
+ .sort((a, b) => b.at - a.at)
1714
+ },
1715
+ /** Retirar lo concedido a un origen. La próxima vez que pida, se vuelve a preguntar. */
1716
+ async revokeGrant ({ origin } = {}) {
1717
+ const g = loadGrants()
1718
+ const org = String(origin || '')
1719
+ if (!(org in g)) return { ok: false }
1720
+ delete g[org]; saveGrants(g)
1721
+ return { ok: true }
1722
+ },
1723
+
1724
+ async requestAssertion ({ audience, nonce, scopes, ttlMs, __origin } = {}) {
1645
1725
  if (typeof audience !== 'string' || !audience.trim()) throw new Error('audience required')
1646
1726
  if (typeof nonce !== 'string' || !nonce) throw new Error('nonce required')
1647
1727
  const acta = loadActa()
1648
1728
  // A NOMBRE DE QUIÉN va: la identidad es el `profileId`, no la llave de este aparato.
1649
1729
  const sub = acta?.profileId || publickeyJwkStr
1650
- const granted = cleanScopes(scopes)
1730
+ const granted = await consentFor(__origin, cleanScopes(scopes))
1651
1731
  const permitido = claimsAllowed(granted)
1652
1732
  const claims = {}
1653
1733
  if (permitido.size) {
@@ -0,0 +1,176 @@
1
+ /**
2
+ * session.js — ENTRAR SIN ENROLAR.
3
+ *
4
+ * El ecosistema tenía muchas formas de ENLAZAR un aparato y ninguna de ENTRAR en uno. Y no
5
+ * son lo mismo: enlazar mete una llave en el acta —hay que sellarla, o sea despertar a la
6
+ * selladora y tener el perfil abierto— y salir es sellar otra vez. Nadie hace eso para
7
+ * abrir una aplicación en un navegador prestado.
8
+ *
9
+ * Una SESIÓN es la otra puerta: una llave que vive en ese navegador y un PAPEL con
10
+ * vencimiento que la respalda, firmado por un aparato tuyo que sí está en el acta. No toca
11
+ * el acta, no necesita la selladora, y cerrar es inmediato.
12
+ *
13
+ * llave de sesión ← papel ← aparato (miembro del acta) ← cadena ← perfil
14
+ *
15
+ * ESTO CREA UNA SEGUNDA AUTORIDAD, y por eso va acotada aquí y no en cada app:
16
+ *
17
+ * · **Nunca amplía.** Un papel no concede lo que su firmante no tiene. Se comprueba
18
+ * contra el acta al verificar, no solo al emitir: quien emite podría mentir.
19
+ * · **Lista negra fija.** Una sesión jamás lleva `secrets`, `admin`, `approve`,
20
+ * `sealer`, `passwords`, `unattended` ni `replica` — ni aunque el aparato los tenga.
21
+ * · **Vence por reloj.** Es la excepción deliberada a «los papeles ya no caducan por
22
+ * reloj»: un certificado describe pertenencia, que dura; una sesión ES temporal, y su
23
+ * vencimiento es la mitad del producto.
24
+ * · **No se re-delega.** Una sesión no abre otra sesión. No hay operación para eso.
25
+ * · **Muere con su aparato.** Si revocas al que la respalda, su certificado deja de
26
+ * valer contra el acta y todos sus papeles caen con él. Sale gratis del modelo, y por
27
+ * eso `verifySession` EXIGE el acta: sin ella no se puede juzgar.
28
+ *
29
+ * Módulo PURO: sin red, sin kv, sin iframe.
30
+ */
31
+ import { verifyDeviceSig } from './capabilities.js'
32
+ import { memberCan, verifySealerChain } from './acta.js'
33
+ import { canonicalStringify } from './core.js'
34
+
35
+ export const SESSION_V = 1
36
+
37
+ /** Cuánto dura una sesión. Horas, no meses: es un rato en un aparato que no es tuyo. */
38
+ export const SESSION_DEFAULT_TTL_MS = 8 * 60 * 60 * 1000
39
+ export const SESSION_MAX_TTL_MS = 24 * 60 * 60 * 1000
40
+ /** Tolerancia de reloj para el arranque (no para el vencimiento). */
41
+ export const SESSION_MAX_SKEW_MS = 60 * 1000
42
+
43
+ /**
44
+ * Lo que una sesión puede llegar a hacer. Lista CERRADA y corta a propósito: es lo de bajo
45
+ * riesgo, lo que haría inviable la sesión si hubiera que preguntarle al teléfono cada vez.
46
+ *
47
+ * · `id:whoami` — decir quién eres (identificarse ante el transporte).
48
+ * · `vault:store` — leer y escribir en el almacén del perfil, si el papel lo dice.
49
+ *
50
+ * Firmar POR LA PERSONA no está aquí, y es deliberado: eso se le pide al aparato que
51
+ * respalda, que es quien tiene una llave que el acta reconoce.
52
+ */
53
+ export const SESSION_SCOPES = Object.freeze(['id:whoami', 'vault:store'])
54
+
55
+ /**
56
+ * Lo que una sesión NUNCA lleva, dijera lo que dijera el papel. Está aparte de la lista
57
+ * blanca a propósito: si mañana alguien añade un alcance a `SESSION_SCOPES` sin pensarlo,
58
+ * esto sigue cortando lo que no puede pasar.
59
+ */
60
+ export const SESSION_FORBIDDEN = Object.freeze(['secrets', 'admin', 'approve', 'sealer', 'passwords', 'unattended', 'replica'])
61
+
62
+ /** Qué capacidad del acta hace falta para conceder cada alcance de sesión. */
63
+ const SCOPE_NEEDS = Object.freeze({ 'id:whoami': null, 'vault:store': 'store' })
64
+
65
+ const enc = (s) => new TextEncoder().encode(s)
66
+ const isStr = (v) => typeof v === 'string' && !!v
67
+
68
+ /** Un identificador de sesión: lo que se enseña al usuario para que pueda cerrarla. */
69
+ export const newSessionId = () => crypto.randomUUID()
70
+
71
+ /** Normaliza los alcances pedidos: solo los del catálogo, sin repetidos y en orden estable. */
72
+ export function cleanSessionScopes (scopes) {
73
+ const list = [...new Set((Array.isArray(scopes) ? scopes : []).filter((s) => SESSION_SCOPES.includes(s)))].sort()
74
+ return list.length ? list : ['id:whoami']
75
+ }
76
+
77
+ /** El cuerpo que firma el aparato que respalda. Un solo sitio: quien firma y quien verifica miran lo mismo. */
78
+ export function sessionBody ({ sid, s, by, origin, scopes, iat, exp }) {
79
+ if (!isStr(sid)) throw new Error('session: sid required')
80
+ if (!isStr(s)) throw new Error('session: s (session pubkey) required')
81
+ if (!isStr(by)) throw new Error('session: by (backing device pubkey) required')
82
+ if (!isStr(origin)) throw new Error('session: origin required')
83
+ if (!Number.isFinite(iat) || !Number.isFinite(exp)) throw new Error('session: iat/exp required')
84
+ if (exp <= iat) throw new Error('session: exp must be after iat')
85
+ if (exp - iat > SESSION_MAX_TTL_MS) throw new Error('session: lifetime over the cap')
86
+ return { v: SESSION_V, op: 'session', sid, s, by, origin: origin.trim(), scopes: cleanSessionScopes(scopes), iat, exp }
87
+ }
88
+
89
+ /**
90
+ * Firma un papel de sesión. Lo llama el APARATO que respalda, con su propia llave.
91
+ *
92
+ * `sign` recibe el cuerpo y devuelve la firma (o el paquete del vault). No se le pasa la
93
+ * privada: este módulo no toca llaves.
94
+ */
95
+ export async function signSession ({ sid, s, by, origin, scopes, ttlMs, now = Date.now() }, sign) {
96
+ if (typeof sign !== 'function') throw new Error('session: sign(body) required')
97
+ const ttl = Math.min(Math.max(Number(ttlMs) || SESSION_DEFAULT_TTL_MS, 60000), SESSION_MAX_TTL_MS)
98
+ const body = sessionBody({ sid, s, by, origin, scopes, iat: now, exp: now + ttl })
99
+ const firmado = await sign(body)
100
+ const sig = typeof firmado === 'string' ? firmado : firmado?.signature
101
+ if (!isStr(sig)) throw Object.assign(new Error('session: sign() returned no signature'), { code: 'no-signature' })
102
+ return { ...body, sig }
103
+ }
104
+
105
+ /**
106
+ * ¿Vale este papel de sesión, AHORA?
107
+ *
108
+ * `chain` es la cadena de actas del perfil, y es obligatoria: sin ella no se puede saber si
109
+ * el aparato que respalda sigue siendo de la casa —que es lo que hace que quitar un aparato
110
+ * se lleve por delante sus sesiones—. Devuelve `{ ok, profileId, scopes, sid, exp }` o
111
+ * `{ ok:false, reason }`.
112
+ */
113
+ export async function verifySession (paper, { chain, expectedProfileId = null, origin = null, now = Date.now(), maxSkewMs = SESSION_MAX_SKEW_MS } = {}) {
114
+ const p = paper
115
+ if (!p || typeof p !== 'object') return { ok: false, reason: 'shape' }
116
+ if (p.v !== SESSION_V || p.op !== 'session') return { ok: false, reason: 'shape' }
117
+ if (!isStr(p.sid) || !isStr(p.s) || !isStr(p.by) || !isStr(p.origin) || !isStr(p.sig)) return { ok: false, reason: 'shape' }
118
+ if (!Number.isFinite(p.iat) || !Number.isFinite(p.exp) || !Array.isArray(p.scopes)) return { ok: false, reason: 'shape' }
119
+
120
+ if (p.exp <= p.iat) return { ok: false, reason: 'vigencia-invalida' }
121
+ if (p.exp - p.iat > SESSION_MAX_TTL_MS) return { ok: false, reason: 'vigencia-excesiva' }
122
+ if (p.exp <= now) return { ok: false, reason: 'vencida' }
123
+ if (p.iat > now + maxSkewMs) return { ok: false, reason: 'del-futuro' }
124
+
125
+ // DÓNDE vale. Un papel para una aplicación no vale en otra: el origen va firmado dentro.
126
+ if (origin != null && p.origin !== String(origin).trim()) return { ok: false, reason: 'otro-origen' }
127
+
128
+ if (p.scopes.some((s) => !SESSION_SCOPES.includes(s))) return { ok: false, reason: 'alcance-desconocido' }
129
+ if (p.scopes.some((s) => SESSION_FORBIDDEN.includes(s))) return { ok: false, reason: 'alcance-prohibido' }
130
+
131
+ // EL ACTA MANDA, y por eso hace falta: dice si el aparato que respalda sigue siendo del
132
+ // perfil y qué puede. Sin ella no se juzga, en vez de dar por bueno lo que diga el papel.
133
+ const c = await verifySealerChain(chain, { expectedProfileId })
134
+ if (!c.ok) return { ok: false, reason: 'cadena:' + c.reason }
135
+ const acta = chain[chain.length - 1]
136
+
137
+ // PRIMERO, ¿ES DE LA CASA? Y en este orden a propósito: si al aparato lo quitaron, el
138
+ // motivo tiene que decir eso y no «le falta un permiso», que manda a mirar al sitio
139
+ // equivocado. Es además el invariante que hace barata la revocación — quitar un aparato
140
+ // se lleva sus sesiones sin avisar a nadie ni perseguir papeles.
141
+ if (!(acta.members || []).some((m) => m?.pub === p.by)) return { ok: false, reason: 'aparato-no-es-del-perfil' }
142
+
143
+ // Y DESPUÉS, NUNCA AMPLÍA: cada alcance exige que el aparato que firmó lo tenga HOY. Se
144
+ // comprueba aquí y no solo al emitir, porque quien emite es precisamente quien podría
145
+ // mentir — y porque el acta de hoy puede haberle quitado lo que tenía ayer.
146
+ for (const s of p.scopes) {
147
+ const cap = SCOPE_NEEDS[s]
148
+ if (cap && !memberCan(acta, p.by, cap)) return { ok: false, reason: 'aparato-sin-' + cap }
149
+ }
150
+
151
+ const { sig, ...body } = p
152
+ if (!(await verifyDeviceSig({ publickey: p.by, data: body, signature: sig }))) return { ok: false, reason: 'firma-invalida' }
153
+
154
+ return { ok: true, profileId: c.profileId, seq: c.seq, sid: p.sid, s: p.s, by: p.by, origin: p.origin, scopes: [...p.scopes], exp: p.exp }
155
+ }
156
+
157
+ /**
158
+ * ¿Firmó ESTA SESIÓN esto, y podía?
159
+ *
160
+ * Es lo que llama quien recibe algo de una sesión: comprueba la firma de la llave de
161
+ * sesión, el papel que la respalda y —si se pide— que el alcance cubra lo que se pretende.
162
+ */
163
+ export async function verifySessionSigned ({ data, signature, session, chain, scope = null, origin = null, expectedProfileId = null, now = Date.now() } = {}) {
164
+ if (!data || !isStr(signature)) return { ok: false, reason: 'shape' }
165
+ const v = await verifySession(session, { chain, expectedProfileId, origin, now })
166
+ if (!v.ok) return v
167
+ if (scope && !v.scopes.includes(scope)) return { ok: false, reason: 'fuera-de-alcance' }
168
+ if (!(await verifyDeviceSig({ publickey: v.s, data, signature }))) return { ok: false, reason: 'firma-invalida' }
169
+ return { ok: true, profileId: v.profileId, sid: v.sid, scopes: v.scopes, exp: v.exp }
170
+ }
171
+
172
+ export default {
173
+ SESSION_V, SESSION_DEFAULT_TTL_MS, SESSION_MAX_TTL_MS, SESSION_MAX_SKEW_MS,
174
+ SESSION_SCOPES, SESSION_FORBIDDEN, newSessionId, cleanSessionScopes,
175
+ sessionBody, signSession, verifySession, verifySessionSigned
176
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * sessionFlow.js — LAS DOS PUNTAS DE «ENTRAR».
3
+ *
4
+ * El papel de sesión lo define `session.js`; aquí está cómo se consigue: un navegador que
5
+ * no te conoce enseña una invitación, un aparato tuyo la lee y le da el papel.
6
+ *
7
+ * navegador nuevo aparato que respalda (con cámara)
8
+ * ────────────────── ─────────────────────────────────
9
+ * genera su llave S
10
+ * muestra QR + código ──── escanea ────► ve qué aplicación y qué pide
11
+ * [Permitir] → firma el papel
12
+ * ◄──── sellado ──── { paper, chain }
13
+ * comprueba y entra
14
+ *
15
+ * TRES COSAS QUE NO SON DETALLE:
16
+ *
17
+ * · **El QR va al revés que en el emparejamiento.** Allí lo muestra la bóveda; aquí lo
18
+ * muestra QUIEN QUIERE ENTRAR, porque quien entra puede no tener cámara y el teléfono
19
+ * siempre la tiene.
20
+ * · **El código corto es el freno del reenvío**, igual que el SAS del emparejamiento:
21
+ * quien intercepte la invitación no puede enseñar el código correcto en la pantalla que
22
+ * el dueño está mirando.
23
+ * · **Va sellado.** Es un mensaje dirigido y el proxio no cifra (CONVENCIONES §4.1). El
24
+ * papel no es un secreto —lo verifica cualquiera—, pero decir en claro «esta persona
25
+ * acaba de entrar en tal aplicación» sí cuenta algo.
26
+ *
27
+ * El transporte se INYECTA (`@dotrino/proxy-client`), como en geo y en reputación: este
28
+ * pilar no abre conexiones ni sabe de proxios.
29
+ */
30
+ import { signSession, verifySession, newSessionId, cleanSessionScopes, SESSION_DEFAULT_TTL_MS } from './session.js'
31
+
32
+ export const SESSION_OP = Object.freeze({
33
+ GRANT: 'session.grant', // aparato → navegador: aquí tienes tu papel
34
+ DENY: 'session.deny', // aparato → navegador: no
35
+ CLOSE: 'session.close' // aparato → navegador: se acabó, bórrala
36
+ })
37
+
38
+ /** El código corto que el humano compara. Seis dígitos, como el del emparejamiento. */
39
+ export function sessionCode (sid) {
40
+ let h = 0
41
+ for (const c of String(sid)) h = (h * 31 + c.charCodeAt(0)) >>> 0
42
+ return String(h % 1000000).padStart(6, '0')
43
+ }
44
+
45
+ /**
46
+ * Lo que viaja en el QR. Corto a propósito: un QR más denso se lee peor con poca luz, que
47
+ * es justo cuando alguien intenta entrar desde un aparato prestado.
48
+ */
49
+ export function buildInvite ({ sid, s, encPub, origin, scopes, proxy }) {
50
+ return { v: 1, t: 'session', sid, s, encPub, origin, scopes: cleanSessionScopes(scopes), proxy }
51
+ }
52
+
53
+ /** Lee una invitación, venga del QR o pegada a mano. `null` si no es una. */
54
+ export function parseInvite (raw) {
55
+ try {
56
+ const o = typeof raw === 'string' ? JSON.parse(raw) : raw
57
+ if (!o || o.t !== 'session' || o.v !== 1) return null
58
+ if (typeof o.sid !== 'string' || typeof o.s !== 'string' || typeof o.origin !== 'string') return null
59
+ return { ...o, scopes: cleanSessionScopes(o.scopes) }
60
+ } catch (_) { return null }
61
+ }
62
+
63
+ /**
64
+ * LADO DEL QUE ENTRA. Genera la sesión, publica la invitación y espera el papel.
65
+ *
66
+ * `transport` es un cliente ya conectado e identificado bajo la llave de sesión (`s`): así
67
+ * el aparato que responde puede escribirle por pubkey. `onInvite` recibe lo que hay que
68
+ * enseñar —la invitación y el código— para que la app pinte el QR con `@dotrino/qr`.
69
+ *
70
+ * Devuelve `{ paper, chain, profileId }` cuando alguien concede, o lanza si se deniega o
71
+ * se agota la espera. No guarda nada: dónde vive la sesión lo decide quien llama.
72
+ */
73
+ export async function openSession ({ transport, sessionPubkey, encPub, origin, scopes, onInvite, timeoutMs = 5 * 60 * 1000, sid = newSessionId() } = {}) {
74
+ if (!transport || typeof transport.on !== 'function') throw new Error('openSession: transport required')
75
+ if (typeof sessionPubkey !== 'string' || !sessionPubkey) throw new Error('openSession: sessionPubkey required')
76
+ if (typeof origin !== 'string' || !origin.trim()) throw new Error('openSession: origin required')
77
+
78
+ const invite = buildInvite({ sid, s: sessionPubkey, encPub, origin: origin.trim(), scopes, proxy: transport.url })
79
+ onInvite?.({ invite, code: sessionCode(sid), qr: JSON.stringify(invite) })
80
+
81
+ return await new Promise((resolve, reject) => {
82
+ let listo = false
83
+ const fin = (fn, arg) => { if (!listo) { listo = true; clearTimeout(reloj); off?.(); fn(arg) } }
84
+ const reloj = setTimeout(() => fin(reject, Object.assign(new Error('nadie abrió la sesión a tiempo'), { code: 'session-timeout' })), timeoutMs)
85
+ const off = transport.on('message', async (_from, payload, meta) => {
86
+ const p = typeof payload === 'string' ? (() => { try { return JSON.parse(payload) } catch (_) { return null } })() : payload
87
+ if (!p || p.sid !== sid) return
88
+ if (p.op === SESSION_OP.DENY) return fin(reject, Object.assign(new Error('la sesión no se concedió'), { code: 'session-denied' }))
89
+ if (p.op !== SESSION_OP.GRANT) return
90
+ // NO SE ACEPTA LO QUE VENGA EN CLARO. El sellado es del pilar del transporte; aquí
91
+ // solo se comprueba que llegó sellado, que es lo que la app puede saber.
92
+ if (meta && meta.sealed === false) return
93
+ const v = await verifySession(p.paper, { chain: p.chain, origin: origin.trim() })
94
+ if (!v.ok) return fin(reject, Object.assign(new Error('el papel de sesión no vale: ' + v.reason), { code: 'session-invalid' }))
95
+ if (p.paper.s !== sessionPubkey) return fin(reject, Object.assign(new Error('el papel es para otra llave'), { code: 'session-invalid' }))
96
+ fin(resolve, { paper: p.paper, chain: p.chain, profileId: v.profileId, sid, scopes: v.scopes, exp: v.exp })
97
+ })
98
+ })
99
+ }
100
+
101
+ /**
102
+ * LADO DEL QUE RESPALDA. Firma el papel y se lo manda al que espera.
103
+ *
104
+ * `sign` firma con la llave de ESTE aparato (la que el acta nombra) y `chain` es la cadena
105
+ * del perfil: las dos cosas viajan juntas porque por separado no sirven.
106
+ *
107
+ * `scopes` acota lo que se concede: por omisión, lo que pidió la invitación. Quien llama
108
+ * puede recortarlo —nunca ampliarlo, que de eso ya se encarga `verifySession`.
109
+ */
110
+ export async function grantSession ({ transport, invite, by, sign, chain, scopes, ttlMs = SESSION_DEFAULT_TTL_MS, now = Date.now() } = {}) {
111
+ const inv = parseInvite(invite)
112
+ if (!inv) throw new Error('grantSession: invitación ilegible')
113
+ if (typeof by !== 'string' || !by) throw new Error('grantSession: by (this device pubkey) required')
114
+ if (typeof sign !== 'function') throw new Error('grantSession: sign(body) required')
115
+ if (!Array.isArray(chain) || !chain.length) throw new Error('grantSession: chain required')
116
+
117
+ const pedidos = cleanSessionScopes(scopes ?? inv.scopes)
118
+ const paper = await signSession({ sid: inv.sid, s: inv.s, by, origin: inv.origin, scopes: pedidos, ttlMs, now }, sign)
119
+ await enviar(transport, inv, { op: SESSION_OP.GRANT, sid: inv.sid, paper, chain })
120
+ return paper
121
+ }
122
+
123
+ /** Decir que no, en vez de dejar al otro mirando una pantalla que no avanza. */
124
+ export async function denySession ({ transport, invite } = {}) {
125
+ const inv = parseInvite(invite)
126
+ if (!inv) throw new Error('denySession: invitación ilegible')
127
+ await enviar(transport, inv, { op: SESSION_OP.DENY, sid: inv.sid })
128
+ }
129
+
130
+ /**
131
+ * CERRARLA. Se avisa a la sesión para que se borre en el acto; lo que la corta de verdad es
132
+ * que el papel vence y nadie lo renueva —y que quitar el aparato se lleva todos los suyos.
133
+ */
134
+ export async function closeSession ({ transport, sessionPubkey, encPub, sid } = {}) {
135
+ if (typeof sessionPubkey !== 'string' || !sessionPubkey) throw new Error('closeSession: sessionPubkey required')
136
+ await enviar(transport, { s: sessionPubkey, encPub }, { op: SESSION_OP.CLOSE, sid })
137
+ }
138
+
139
+ /** Sellado siempre que se pueda: el proxio no cifra (CONVENCIONES §4.1). */
140
+ async function enviar (transport, inv, payload) {
141
+ if (!transport || typeof transport.sendByPubkey !== 'function') throw new Error('sessionFlow: transport required')
142
+ if (inv.encPub && typeof transport.sendSealed === 'function') {
143
+ return transport.sendSealed([inv.s], payload, { peerEncPub: inv.encPub })
144
+ }
145
+ // Sin llave de cifrado del otro lado no se puede sellar. Se dice en vez de mandarlo en
146
+ // claro por su cuenta: quien llama decide si eso le vale.
147
+ if (!inv.encPub) throw Object.assign(new Error('sessionFlow: la invitación no trae encPub; no se puede sellar'), { code: 'unsealed' })
148
+ return transport.sendByPubkey(inv.s, payload)
149
+ }
150
+
151
+ export default { SESSION_OP, sessionCode, buildInvite, parseInvite, openSession, grantSession, denySession, closeSession }
package/vault/vault.js CHANGED
@@ -55,12 +55,62 @@ import { pubkeyId } from './capabilities.js'
55
55
  removeItem: (k) => sessionStorage.removeItem(k)
56
56
  }
57
57
 
58
+ /**
59
+ * EL PANEL DE PERMISO LO PINTA ESTE IFRAME, no la aplicación que pide.
60
+ *
61
+ * Es la diferencia entre un permiso y un trámite: la aplicación vive en otro origen, así
62
+ * que no puede pulsar aquí dentro ni leer lo que hay. Lo único que puede hacer es NO
63
+ * mostrarnos —y entonces no consigue el permiso, que es el lado correcto en el que
64
+ * fallar—. Por eso se le pide que nos muestre (`consent:open`) y, si no lo hace, la
65
+ * pregunta se queda sin responder y se deniega sola.
66
+ */
67
+ const T_CONSENT = (() => {
68
+ const en = (navigator.language || 'es').startsWith('en')
69
+ return en
70
+ ? { title: 'wants to see', allow: 'Allow', deny: 'No', who: 'Your Dotrino identity', once: 'Only what you allow leaves here.' }
71
+ : { title: 'quiere ver', allow: 'Permitir', deny: 'No', who: 'Tu identidad de Dotrino', once: 'De aquí solo sale lo que permitas.' }
72
+ })()
73
+ const SCOPE_TXT = (() => {
74
+ const en = (navigator.language || 'es').startsWith('en')
75
+ return en
76
+ ? { 'profile:name': 'your name', 'profile:avatar': 'your picture', 'profile:email': 'your email', 'profile:social': 'your links', 'id:whoami': 'who you are' }
77
+ : { 'profile:name': 'tu nombre', 'profile:avatar': 'tu foto', 'profile:email': 'tu correo', 'profile:social': 'tus enlaces', 'id:whoami': 'quién eres' }
78
+ })()
79
+
80
+ let consentAbierto = null
81
+ function askConsent ({ origin, scopes }) {
82
+ if (consentAbierto) return Promise.resolve(false) // una pregunta a la vez
83
+ return new Promise((resolve) => {
84
+ const host = document.createElement('div')
85
+ host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(10,8,20,.86);font-family:system-ui,-apple-system,Segoe UI,sans-serif'
86
+ const lista = scopes.map((x) => `<li>${SCOPE_TXT[x] || x}</li>`).join('')
87
+ host.innerHTML = `<div style="background:#171331;border:1px solid #2a2350;border-radius:16px;padding:22px;min-width:min(320px,90vw);max-width:90vw;color:#e7e3ff">
88
+ <div style="opacity:.7;font-size:13px">${T_CONSENT.who}</div>
89
+ <div style="font-weight:700;margin:8px 0 4px">${String(origin).replace(/^https?:\/\//, '')} ${T_CONSENT.title}:</div>
90
+ <ul style="margin:6px 0 12px 18px;padding:0">${lista}</ul>
91
+ <div style="opacity:.7;font-size:12px;margin-bottom:12px">${T_CONSENT.once}</div>
92
+ <div style="display:flex;gap:8px">
93
+ <button data-yes style="flex:1;padding:10px;border-radius:10px;border:0;background:#7c3aed;color:#fff;font:inherit;font-weight:600;cursor:pointer">${T_CONSENT.allow}</button>
94
+ <button data-no style="flex:1;padding:10px;border-radius:10px;border:1px solid #2a2350;background:transparent;color:inherit;font:inherit;cursor:pointer">${T_CONSENT.deny}</button>
95
+ </div></div>`
96
+ const cerrar = (v) => { try { host.remove() } catch (_) {} consentAbierto = null; broadcast('consent:close', {}); resolve(v) }
97
+ host.querySelector('[data-yes]').addEventListener('click', () => cerrar(true))
98
+ host.querySelector('[data-no]').addEventListener('click', () => cerrar(false))
99
+ consentAbierto = host
100
+ document.body.appendChild(host)
101
+ broadcast('consent:open', { origin })
102
+ // Si nadie contesta —porque nadie nos mostró—, se deniega. Nunca al revés.
103
+ setTimeout(() => { if (consentAbierto === host) cerrar(false) }, 60000)
104
+ })
105
+ }
106
+
58
107
  const core = await createIdentityCore({
59
108
  kv,
60
109
  peers: { initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty },
61
110
  makeSync: createSync,
62
111
  keyStore,
63
- sessionKv
112
+ sessionKv,
113
+ askConsent
64
114
  })
65
115
 
66
116
  const { handlers } = core
@@ -290,7 +340,10 @@ import { pubkeyId } from './capabilities.js'
290
340
  const handler = selfHandlers[method] || handlers[method]
291
341
  if (!handler) return reply({ error: `Unknown method: ${method}` })
292
342
  try {
293
- const result = await handler(params || {})
343
+ // EL ORIGEN LO PONE EL IFRAME, no quien llama: va pisado a propósito. Es el único
344
+ // dato que la aplicación no puede falsificar —el navegador lo garantiza— y de él
345
+ // depende a quién se le concedió qué.
346
+ const result = await handler({ ...(params || {}), __origin: event.origin })
294
347
  reply({ result })
295
348
  } catch (e) {
296
349
  // `code` (y su `detail`) CRUZAN. Sin ellos, al otro lado solo llegaba la frase, y una
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.17.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.18.2 (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.
@@ -487,6 +487,41 @@ export class WebSocketProxyClient {
487
487
  * esperar a `identify` por eso retrasaría todo lo que viene después para ganar algo que
488
488
  * solo hace falta cuando se negocie el primer canal.
489
489
  */
490
+ /**
491
+ * PARA QUIÉN firmamos cuando le hablamos a ESTE proxio. Sale de la URL a la que estamos
492
+ * conectados: quien levanta su propio proxio tiene otro destinatario, y con un valor fijo
493
+ * un sobre firmado para el nuestro valdría ante el suyo.
494
+ */
495
+ get audience () { return String(this.url || '').replace(/\/+$/, '') }
496
+
497
+ /**
498
+ * IDENTIFICARSE, ARMANDO EL SOBRE AQUÍ. Doce repos lo escribían a mano
499
+ * (`{op:'identify', publickey, token, ts}` + firma), o sea el protocolo copiado doce
500
+ * veces: al añadirle el destinatario habría que acertar en los doce, y quien escribiera
501
+ * el trece lo haría sin él.
502
+ *
503
+ * `sign` es lo que firma (normalmente `(d) => identity.signData(d)`); acepta tanto la
504
+ * firma en texto como el paquete del vault.
505
+ */
506
+ async identifyAs ({ publickey, sign, cert, acta } = {}) {
507
+ if (typeof sign !== 'function') throw new Error('identifyAs requires sign(data)')
508
+ if (!publickey) throw new Error('identifyAs requires publickey')
509
+ if (!this.token) throw new Error('identifyAs: not connected yet (no token)')
510
+ // El `token` lo da el proxio al conectar y va firmado aquí dentro: es el reto de esta
511
+ // conexión, y por eso este sobre no necesita otro. Lo que le faltaba era decir a quién
512
+ // se lo estamos dando.
513
+ const data = { op: 'identify', aud: this.audience, publickey, token: this.token, ts: Date.now() }
514
+ const firmado = await sign(data)
515
+ const signature = typeof firmado === 'string' ? firmado : firmado?.signature
516
+ // «No pude firmar» y «se cayó la red» son cosas distintas, y se distinguen por el
517
+ // `code`: la bóveda usa esto para saber si su llave de comunicación firma todavía, y
518
+ // tragarse un fallo de red como si fuera lo primero la mandaría al camino equivocado.
519
+ if (typeof signature !== 'string') {
520
+ throw Object.assign(new Error('identifyAs: sign() returned no signature'), { code: 'no-signature' })
521
+ }
522
+ return this.identify({ data, signature, cert, acta, sign })
523
+ }
524
+
490
525
  identify ({ data, signature, cert, acta, sign }) {
491
526
  if (!data || !signature) throw new Error('identify requires {data, signature}')
492
527
  const msg = { type: 'identify', data, signature }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.61.0 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
1
+ Copia vendorizada de @dotrino/vault@0.62.1 (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
@@ -97,9 +97,8 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
97
97
  const selfCert = await getSelfCert()
98
98
  const identify = async () => {
99
99
  if (!client.token) return
100
- const data = { op: 'identify', publickey: iss, token: client.token, ts: Date.now() }
101
- const { signature } = await identity.signData(data)
102
- await client.identify({ data, signature, cert: selfCert })
100
+ // El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
101
+ await client.identifyAs({ publickey: iss, sign: (d) => identity.signData(d), cert: selfCert })
103
102
  }
104
103
  await identify()
105
104
  client.on('token', () => identify().catch(() => {}))