@dotrino/identity 0.84.0 → 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 +31 -0
- package/package.json +7 -1
- package/src/index.d.ts +42 -0
- package/vault/session.js +176 -0
- package/vault/sessionFlow.js +151 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +35 -0
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/index.js +2 -3
package/README.md
CHANGED
|
@@ -160,6 +160,37 @@ 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
|
+
### 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
|
+
|
|
163
194
|
Diseño y hacia dónde va (inicio de sesión y federación):
|
|
164
195
|
[`dotrino-vault/docs/inicio-de-sesion.md`](../dotrino-vault/docs/inicio-de-sesion.md).
|
|
165
196
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
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
|
@@ -272,3 +272,45 @@ export function assertionBody (args: { sub: string; aud: string; nonce: string;
|
|
|
272
272
|
export function verifyAssertion (assertion: Assertion, opts: { audience: string; nonce: string; expectedProfileId?: string | null; now?: number; maxSkewMs?: number }): Promise<VerifiedAssertion>
|
|
273
273
|
/** ¿Este contenido firmado (un pin, una atestación) va dirigido a mí? `aud` obligatorio; `exp` opcional. */
|
|
274
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/vault/session.js
ADDED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
101
|
-
|
|
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(() => {}))
|