@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 +49 -0
- package/package.json +7 -1
- package/src/index.d.ts +46 -0
- package/src/index.js +21 -0
- package/vault/core.js +83 -3
- package/vault/session.js +176 -0
- package/vault/sessionFlow.js +151 -0
- package/vault/vault.js +55 -2
- 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,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.
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
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 }
|
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
|
-
|
|
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.
|
|
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(() => {}))
|