@dotrino/identity 0.83.1 → 0.85.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
@@ -143,6 +143,54 @@ const v = await verifyAssertion(prueba, { audience: 'https://proxy.dotrino.com',
143
143
  - **Verificar no necesita el iframe ni clave alguna**: `@dotrino/identity/assertion` es un
144
144
  módulo puro y lo puede importar un servidor.
145
145
 
146
+ **Y su hermana, para lo que se publica** (0.84.0+). Un pin de geo o una atestación de
147
+ reputación se firman y se sueltan: no hay nadie al otro lado que pueda dar un reto de
148
+ antemano, así que ahí no cabe una prueba entera — lo que falta es el destinatario, y solo
149
+ eso. Sin él, un pin firmado para geo lo acepta igual reputación.
150
+
151
+ ```js
152
+ import { verifySignedFor } from '@dotrino/identity/assertion'
153
+
154
+ const pin = { op: 'pin', aud: 'https://geo.dotrino.com', lat, lon, iat: Date.now() }
155
+ const { signature, publickey, chain } = await id.signData(pin)
156
+ // en el servidor:
157
+ const v = await verifySignedFor({ data: pin, signature, publickey, chain, audience: 'https://geo.dotrino.com' })
158
+ ```
159
+
160
+ `aud` es obligatorio igual que en la prueba; `exp` es opcional, porque la caducidad de lo
161
+ publicado la lleva el servicio (el TTL del pin) y no el cuerpo.
162
+
163
+ ### Entrar sin enrolar: las sesiones (0.85.0+)
164
+
165
+ Enlazar un aparato y entrar en uno no son lo mismo. Enlazar mete una llave en el acta —hay
166
+ que sellarla, o sea despertar a la selladora y tener el perfil abierto— y salir es sellar
167
+ otra vez. Nadie hace eso para abrir una aplicación en un navegador prestado.
168
+
169
+ Una **sesión** es la otra puerta: una llave que vive en ese navegador y un **papel** con
170
+ vencimiento que la respalda, firmado por un aparato tuyo que sí está en el acta.
171
+
172
+ ```js
173
+ import { openSession } from '@dotrino/identity/session-flow' // el que entra
174
+ import { grantSession } from '@dotrino/identity/session-flow' // el que respalda
175
+ import { verifySession } from '@dotrino/identity/session' // quien recibe algo suyo
176
+ ```
177
+
178
+ El QR va **al revés** que en el emparejamiento: lo muestra quien quiere entrar, y lo escanea
179
+ el teléfono — quien entra puede no tener cámara, el teléfono siempre la tiene.
180
+
181
+ Lo que hace que esto no sea una llave maestra de repuesto:
182
+
183
+ - **Nunca amplía**: cada alcance exige que el aparato que firmó lo tenga **hoy**, y se
184
+ comprueba contra el acta al verificar, no solo al emitir.
185
+ - **Lista negra fija**: jamás `secrets`, `admin`, `approve`, `sealer`, `passwords`,
186
+ `unattended` ni `replica`, aunque el aparato los tenga.
187
+ - **Vence por reloj** (8 h por defecto, tope 24). Es la excepción deliberada a *los papeles
188
+ ya no caducan por reloj*: un certificado describe pertenencia, que dura; una sesión **es**
189
+ temporal.
190
+ - **Muere con su aparato**: si quitas del acta al que la respalda, sus sesiones caen solas.
191
+ Sin avisar a nadie ni perseguir papeles.
192
+ - **No se re-delega**: una sesión no abre otra sesión.
193
+
146
194
  Diseño y hacia dónde va (inicio de sesión y federación):
147
195
  [`dotrino-vault/docs/inicio-de-sesion.md`](../dotrino-vault/docs/inicio-de-sesion.md).
148
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.83.1",
3
+ "version": "0.85.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
@@ -270,3 +270,47 @@ export function claimsAllowed (scopes?: string[]): Set<string>
270
270
  export function assertionBody (args: { sub: string; aud: string; nonce: string; scopes?: string[]; claims?: AssertionClaims; iat: number; exp: number }): Omit<Assertion, 'signature' | 'publickey' | 'chain'>
271
271
  /** ¿Vale esta prueba, PARA MÍ y AHORA? `audience` y `nonce` son obligatorios; sin modo permisivo. */
272
272
  export function verifyAssertion (assertion: Assertion, opts: { audience: string; nonce: string; expectedProfileId?: string | null; now?: number; maxSkewMs?: number }): Promise<VerifiedAssertion>
273
+ /** ¿Este contenido firmado (un pin, una atestación) va dirigido a mí? `aud` obligatorio; `exp` opcional. */
274
+ 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 }>
275
+
276
+ // ----- Sesiones: entrar sin enrolar (`@dotrino/identity/session`) -----
277
+
278
+ export type SessionScope = 'id:whoami' | 'vault:store'
279
+
280
+ export interface SessionPaper {
281
+ v: 1
282
+ op: 'session'
283
+ sid: string // el identificador que el usuario ve para poder cerrarla
284
+ s: string // pubkey de la sesión (vive en el aparato prestado)
285
+ by: string // pubkey del aparato que la respalda (miembro del acta)
286
+ origin: string // dónde vale
287
+ scopes: SessionScope[]
288
+ iat: number
289
+ exp: number // vence por reloj: una sesión ES temporal
290
+ sig: string // firma del aparato que respalda
291
+ }
292
+
293
+ export interface VerifiedSession {
294
+ ok: boolean
295
+ reason?: string
296
+ profileId?: string
297
+ seq?: number
298
+ sid?: string
299
+ s?: string
300
+ by?: string
301
+ origin?: string
302
+ scopes?: SessionScope[]
303
+ exp?: number
304
+ }
305
+
306
+ export const SESSION_SCOPES: readonly SessionScope[]
307
+ export const SESSION_FORBIDDEN: readonly string[]
308
+ export const SESSION_DEFAULT_TTL_MS: number
309
+ export const SESSION_MAX_TTL_MS: number
310
+ export function newSessionId (): string
311
+ export function cleanSessionScopes (scopes?: string[]): SessionScope[]
312
+ 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>
313
+ /** ¿Vale este papel AHORA? La cadena de actas es obligatoria: sin ella no se puede juzgar. */
314
+ export function verifySession (paper: SessionPaper, opts: { chain: any[]; expectedProfileId?: string | null; origin?: string | null; now?: number; maxSkewMs?: number }): Promise<VerifiedSession>
315
+ /** ¿Firmó esta sesión esto, y su papel lo cubría? */
316
+ 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
@@ -740,4 +740,4 @@ export { makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, ve
740
740
  // PARA QUIÉN vale una firma, y hasta cuándo. Verificar NO necesita el iframe ni la clave
741
741
  // de nadie, así que un servicio puede importarlo suelto (`@dotrino/identity/assertion`, que
742
742
  // no arrastra el cliente del vault); aquí se reexporta para quien ya tiene esto cargado.
743
- export { verifyAssertion, newAssertionNonce, cleanScopes, claimsAllowed, assertionBody, SCOPES, SCOPE_CLAIMS, ASSERTION_MAX_TTL_MS, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_SKEW_MS } from '../vault/assertion.js'
743
+ export { verifyAssertion, verifySignedFor, newAssertionNonce, cleanScopes, claimsAllowed, assertionBody, SCOPES, SCOPE_CLAIMS, ASSERTION_MAX_TTL_MS, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_SKEW_MS } from '../vault/assertion.js'
@@ -174,4 +174,42 @@ export async function verifyAssertion (assertion, opts = {}) {
174
174
  return { ok: true, profileId: v.profileId, signer: v.signer, seq: v.seq, scopes: [...a.scopes], claims: { ...(a.claims || {}) }, aud: a.aud, exp: a.exp }
175
175
  }
176
176
 
177
- export default { ASSERTION_V, ASSERTION_MAX_TTL_MS, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_SKEW_MS, SCOPES, SCOPE_CLAIMS, newAssertionNonce, cleanScopes, claimsAllowed, assertionBody, verifyAssertion }
177
+ /**
178
+ * ¿ESTE CONTENIDO FIRMADO VA DIRIGIDO A MÍ? La otra mitad del destinatario, y **no es lo
179
+ * mismo que una prueba**: conviene tener claras las dos, porque confundirlas lleva a pedir
180
+ * un reto donde no hay quien lo emita.
181
+ *
182
+ * · **Prueba** (`verifyAssertion`) — autenticación INTERACTIVA: alguien me habla, yo le
183
+ * mando un reto y quiero saber quién es AHORA. Lleva `nonce` porque hay dos partes.
184
+ * · **Contenido dirigido** (esto) — un pin de geo, una atestación de reputación: se
185
+ * firma y se publica, sin nadie al otro lado que pueda dar un reto de antemano. Lo que
186
+ * falta ahí es `aud`, y solo `aud`: sin él, un pin firmado para geo lo acepta igual
187
+ * reputación.
188
+ *
189
+ * `aud` es obligatorio, como en la prueba. `exp` es OPCIONAL, y esa es la diferencia real:
190
+ * lo publicado tiene su propia caducidad (el TTL del pin, la vigencia de la atestación) y
191
+ * quien la lleva es el servicio; si el cuerpo trae `exp`, se respeta.
192
+ */
193
+ /**
194
+ * @param {{ data?: any, signature?: string, publickey?: string, chain?: any[], audience?: string, expectedProfileId?: string|null, now?: number, maxSkewMs?: number }} [args]
195
+ */
196
+ export async function verifySignedFor (args = {}) {
197
+ const { data, signature, publickey, chain, audience, expectedProfileId = null, now = Date.now(), maxSkewMs = ASSERTION_MAX_SKEW_MS } = args
198
+ if (typeof audience !== 'string' || !audience.trim()) return { ok: false, reason: 'no-audience' }
199
+ if (!data || typeof data !== 'object') return { ok: false, reason: 'shape' }
200
+ if (typeof data.aud !== 'string' || !data.aud) return { ok: false, reason: 'sin-destinatario' }
201
+ if (data.aud !== audience.trim()) return { ok: false, reason: 'otro-destinatario' }
202
+ if (data.exp != null) {
203
+ if (!Number.isFinite(data.exp)) return { ok: false, reason: 'shape' }
204
+ if (data.exp <= now) return { ok: false, reason: 'vencida' }
205
+ }
206
+ if (data.iat != null) {
207
+ if (!Number.isFinite(data.iat)) return { ok: false, reason: 'shape' }
208
+ if (data.iat > now + maxSkewMs) return { ok: false, reason: 'del-futuro' }
209
+ }
210
+ const v = await verifySignedBy({ data, signature, publickey, chain, expectedProfileId })
211
+ if (!v.ok) return { ok: false, reason: 'firma:' + v.reason }
212
+ return { ok: true, profileId: v.profileId, signer: v.signer, seq: v.seq, aud: data.aud }
213
+ }
214
+
215
+ export default { ASSERTION_V, ASSERTION_MAX_TTL_MS, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_SKEW_MS, SCOPES, SCOPE_CLAIMS, newAssertionNonce, cleanScopes, claimsAllowed, assertionBody, verifyAssertion, verifySignedFor }
@@ -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 }
@@ -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(() => {}))