@dotrino/identity 0.82.0 → 0.83.1

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
@@ -109,6 +109,43 @@ Mismo registro de `peers` que arriba, pero filtrado por flag `isContact: true`.
109
109
 
110
110
  - `id.signData(data)` → `{ signature, publickey }` con encoding canonical-JSON. Lo usa el messenger para construir sobres `identify` que el proxy verifica con su `verifySignatureWithJWK`.
111
111
 
112
+ ### Prueba con destinatario (0.83.0+)
113
+
114
+ `signData` dice «esto lo firmé yo», y no dice para quién. Eso significaba que **un sobre
115
+ firmado para el proxio valía ante geo**: los dos comprueban la misma firma de la misma
116
+ identidad y ninguno tenía con qué notar que no le hablaban a él. La ventana de repetición
117
+ evita que el mismo sobre se reenvíe dos veces al mismo sitio; el cruce de destinatario es
118
+ otra cosa.
119
+
120
+ Una *assertion* es una firma normal del perfil sobre un cuerpo que además dice `aud` (para
121
+ quién), `nonce` (para qué petición) e `iat`/`exp` (desde y hasta cuándo).
122
+
123
+ ```js
124
+ // quien PIDE genera el reto y dice quién es él
125
+ const nonce = newAssertionNonce()
126
+ const prueba = await id.requestAssertion({ audience: 'https://proxy.dotrino.com', nonce })
127
+
128
+ // quien RECIBE comprueba las dos cosas que él sabe y la prueba no puede inventar
129
+ import { verifyAssertion } from '@dotrino/identity/assertion'
130
+ const v = await verifyAssertion(prueba, { audience: 'https://proxy.dotrino.com', nonce })
131
+ // { ok, profileId, signer, seq, scopes, claims, aud, exp } · o { ok:false, reason }
132
+ ```
133
+
134
+ - **`audience` y `nonce` son obligatorios en las dos puntas.** Sin ellos no hay nada que
135
+ comparar, así que `verifyAssertion` responde `no-audience` / `no-nonce` en vez de dar por
136
+ buena una prueba que no ha podido juzgar. **No hay modo permisivo.**
137
+ - **Vigencia corta** (2 min por defecto, tope 5). El tope lo comprueba también quien
138
+ recibe: fiarse del `exp` que puso el otro es fiarse de su buena fe.
139
+ - **Alcances**, lista cerrada: `id:whoami` (el mínimo, solo quién eres), `profile:name`,
140
+ `profile:avatar`, `profile:email`, `profile:social`. Un dato sin su alcance no se emite
141
+ ni se acepta. Hoy solo viaja lo que el perfil ya comparte; la pantalla de permiso para
142
+ conceder algo oculto a un destinatario concreto es la fase siguiente.
143
+ - **Verificar no necesita el iframe ni clave alguna**: `@dotrino/identity/assertion` es un
144
+ módulo puro y lo puede importar un servidor.
145
+
146
+ Diseño y hacia dónde va (inicio de sesión y federación):
147
+ [`dotrino-vault/docs/inicio-de-sesion.md`](../dotrino-vault/docs/inicio-de-sesion.md).
148
+
112
149
  ### Backup / migración
113
150
 
114
151
  - `id.exportIdentity()` → blob JSON con `privateJwk` (ECDSA), `encPrivateJwk` (ECDH), `me`, `peers`. **Sensible** — el host app es responsable de guardarlo de manera segura.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.82.0",
3
+ "version": "0.83.1",
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",
@@ -28,6 +28,9 @@
28
28
  "./content": {
29
29
  "import": "./vault/content.js"
30
30
  },
31
+ "./assertion": {
32
+ "import": "./vault/assertion.js"
33
+ },
31
34
  "./keyid": {
32
35
  "import": "./vault/keyid.js"
33
36
  }
package/src/index.d.ts CHANGED
@@ -123,6 +123,7 @@ export class Identity {
123
123
  removeContact (publickey: string): Promise<PeerInfo | null>
124
124
  listContacts (): Promise<PeerInfo[]>
125
125
  signData (data: any): Promise<{ signature: string; publickey: string }>
126
+ requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number }): Promise<Assertion>
126
127
  setMyNickname (nickname: string): Promise<{ me: Me }>
127
128
  getEncryptionPubkey (): Promise<string>
128
129
  encrypt (recipients: EncryptRecipient[], plaintext: string): Promise<EnvelopeV1>
@@ -224,3 +225,48 @@ export function verifyDelegation (args: { cert: CapabilityCert; expectedScope?:
224
225
  /** Verifica la cadena de una acción/pin delegado: D firmó + cert prueba D←P + scope/exp/revocación. */
225
226
  export function verifyChain (args: { data: any; signature: string; cert: CapabilityCert; expectedScope?: string; trustedIssuer?: string; now?: number; revoked?: ((nonce: string) => boolean) | Set<string> | Record<string, any> }): Promise<{ ok: boolean; reason?: string; issuer?: string; device?: string }>
226
227
 
228
+ // ----- Prueba firmada con destinatario y vigencia (`@dotrino/identity/assertion`) -----
229
+
230
+ export type AssertionScope = 'id:whoami' | 'profile:name' | 'profile:avatar' | 'profile:email' | 'profile:social'
231
+
232
+ export interface AssertionClaims { name?: string; avatar?: string; email?: string; links?: ProfileLink[] }
233
+
234
+ export interface Assertion {
235
+ v: 1
236
+ op: 'assertion'
237
+ sub: string // profileId: la identidad a la que se atribuye
238
+ aud: string // PARA QUIÉN vale
239
+ nonce: string // el reto de quien pide, de un solo uso
240
+ iat: number // ms epoch
241
+ exp: number // ms epoch (tope ASSERTION_MAX_TTL_MS desde iat)
242
+ scopes: AssertionScope[]
243
+ claims: AssertionClaims
244
+ signature: string // firma del aparato firmante sobre el cuerpo canónico
245
+ publickey: string // quién firmó (miembro del acta con `sign`)
246
+ chain: any[] // cadena de actas que prueba que ese firmante es del perfil
247
+ }
248
+
249
+ export interface VerifiedAssertion {
250
+ ok: boolean
251
+ reason?: string
252
+ profileId?: string
253
+ signer?: string
254
+ seq?: number
255
+ scopes?: AssertionScope[]
256
+ claims?: AssertionClaims
257
+ aud?: string
258
+ exp?: number
259
+ }
260
+
261
+ export const ASSERTION_MAX_TTL_MS: number
262
+ export const ASSERTION_DEFAULT_TTL_MS: number
263
+ export const ASSERTION_MAX_SKEW_MS: number
264
+ export const SCOPES: readonly AssertionScope[]
265
+ export const SCOPE_CLAIMS: Readonly<Record<AssertionScope, readonly string[]>>
266
+ /** Un reto de un solo uso, para quien pide la prueba. */
267
+ export function newAssertionNonce (): string
268
+ export function cleanScopes (scopes?: string[]): AssertionScope[]
269
+ export function claimsAllowed (scopes?: string[]): Set<string>
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
+ /** ¿Vale esta prueba, PARA MÍ y AHORA? `audience` y `nonce` son obligatorios; sin modo permisivo. */
272
+ export function verifyAssertion (assertion: Assertion, opts: { audience: string; nonce: string; expectedProfileId?: string | null; now?: number; maxSkewMs?: number }): Promise<VerifiedAssertion>
package/src/index.js CHANGED
@@ -243,6 +243,24 @@ export class Identity {
243
243
  return this._call('signData', { data })
244
244
  }
245
245
 
246
+ /**
247
+ * Una prueba firmada PARA ALGUIEN EN CONCRETO y por un rato: `signData` dice «lo firmé
248
+ * yo», y esto dice además para quién, contestando a qué reto y hasta cuándo. Es lo que
249
+ * un servicio (o una aplicación ajena) tiene que pedir en vez de una firma suelta, que
250
+ * le sirve igual a otro servicio.
251
+ *
252
+ * `audience` es quien va a verificarla —su URL— y `nonce` lo pone él, de un solo uso.
253
+ * `scopes` es lo que se le deja ver, del catálogo cerrado de `@dotrino/identity/assertion`;
254
+ * `id:whoami` (solo quién eres) es el mínimo y el valor por defecto.
255
+ *
256
+ * @returns {Promise<object>} la prueba, lista para mandar. Se comprueba con
257
+ * `verifyAssertion(prueba, { audience, nonce })`.
258
+ */
259
+ async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
260
+ const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs })
261
+ return assertion
262
+ }
263
+
246
264
  /**
247
265
  * Firma un CERTIFICADO DE DELEGACIÓN: autoriza a una sub-clave de dispositivo
248
266
  * `sub` (JWK string) a hacer `scope` en tu nombre, hasta `exp`, revocable por
@@ -718,3 +736,8 @@ export class Identity {
718
736
  // Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), reutilizables
719
737
  // por apps/bridges sin cargar el iframe del vault.
720
738
  export { makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, avatarSvg, avatarDataUri } from '../vault/capabilities.js'
739
+
740
+ // PARA QUIÉN vale una firma, y hasta cuándo. Verificar NO necesita el iframe ni la clave
741
+ // de nadie, así que un servicio puede importarlo suelto (`@dotrino/identity/assertion`, que
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'
package/src/node.js CHANGED
@@ -174,6 +174,14 @@ export class Identity {
174
174
  removeContact (publickey) { return this._h('removeContact', { publickey }) }
175
175
  listContacts () { return this._h('listContacts') }
176
176
  signData (data) { return this._h('signData', { data }) }
177
+ /**
178
+ * Prueba firmada con destinatario y vigencia (ver `vault/assertion.js`). Un servicio en
179
+ * Node la pide igual que una app: `{ audience, nonce, scopes?, ttlMs? }`.
180
+ */
181
+ async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
182
+ const { assertion } = await this._h('requestAssertion', { audience, nonce, scopes, ttlMs })
183
+ return assertion
184
+ }
177
185
  // Delegación de capacidad (sub-clave de dispositivo con scope/exp/revocación)
178
186
  signDelegation (sub, scope, opts = {}) { return this._h('signDelegation', { sub, scope, ...opts }) }
179
187
  revokeDelegation (nonce) { return this._h('revokeDelegation', { nonce }) }
package/vault/acta.js CHANGED
@@ -585,7 +585,11 @@ export async function sealActa ({ acta, privateKey, privateJwk }) {
585
585
  * reciente». Si la cadena que te llega es vieja, sigue verificando — para eso está el
586
586
  * registro público, que es otra capa.
587
587
  */
588
- export async function verifySignedBy ({ data, signature, publickey, chain, expectedProfileId = null } = {}) {
588
+ /**
589
+ * @param {{ data?: any, signature?: string, publickey?: string, chain?: any[], expectedProfileId?: string|null }} [args]
590
+ */
591
+ export async function verifySignedBy (args = {}) {
592
+ const { data, signature, publickey, chain, expectedProfileId = null } = args
589
593
  if (!data || typeof signature !== 'string' || typeof publickey !== 'string') {
590
594
  return { ok: false, reason: 'shape' }
591
595
  }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * assertion.js — PARA QUIÉN vale una firma, y HASTA CUÁNDO.
3
+ *
4
+ * EL AGUJERO QUE TAPA. Una firma del ecosistema decía «esta identidad firmó esto» y nada
5
+ * más. No decía a quién va dirigida, así que un sobre firmado para el proxio **valía ante
6
+ * geo**: los dos comprueban la misma firma de la misma identidad y ninguno tenía con qué
7
+ * notar que no le hablaban a él. La ventana de repetición evita que el MISMO sobre se
8
+ * reenvíe dos veces al MISMO sitio; no evita el cruce de destinatario, que es otra cosa.
9
+ *
10
+ * Una prueba (`assertion`) es una firma normal del perfil —misma cripto, misma cadena de
11
+ * actas— sobre un cuerpo que además dice `aud` (para quién), `nonce` (para qué petición) e
12
+ * `iat`/`exp` (desde y hasta cuándo). Verificarla es lo de siempre MÁS comprobar esas
13
+ * cuatro cosas.
14
+ *
15
+ * TRES REGLAS, y las tres son la razón de que esto exista:
16
+ *
17
+ * · **`aud` es obligatorio al emitir Y al verificar.** Una prueba sin destinatario no se
18
+ * emite, y quien verifica tiene que decir quién es él. Sin eso no hay nada que
19
+ * comparar y devolver «vale» sería exactamente el agujero de arriba.
20
+ * · **El `nonce` lo pone quien PIDE.** Ata la prueba a esa petición y a ninguna otra.
21
+ * Aquí no se lleva registro de nonces vistos: el que pide sabe cuál mandó, y un nonce
22
+ * que no vuelve a usar no se puede repetir.
23
+ * · **SIN MODO PERMISIVO.** No hay bandera para saltarse una comprobación, ni valor por
24
+ * defecto que rellene lo que falta. Si falta el destinatario, el reto o la cadena, se
25
+ * devuelve `ok:false` con su motivo. Un verificador laxo es un verificador roto.
26
+ *
27
+ * Módulo PURO: sin red, sin kv, sin iframe. Lo importan las apps, los servicios y el
28
+ * daemon.
29
+ */
30
+ import { verifySignedBy } from './acta.js'
31
+
32
+ export const ASSERTION_V = 1
33
+
34
+ /**
35
+ * VIGENCIA. Corta a propósito: es lo que hace barata la revocación —no hay nada que
36
+ * invalidar, solo se deja de renovar— y lo que limita el daño de una prueba interceptada.
37
+ *
38
+ * El tope lo comprueba también QUIEN RECIBE, no solo quien emite: fiarse de que el otro
39
+ * puso un `exp` sensato es fiarse de la buena fe del que firma, y una prueba con un año de
40
+ * vigencia es una credencial al portador.
41
+ */
42
+ export const ASSERTION_MAX_TTL_MS = 5 * 60 * 1000
43
+ export const ASSERTION_DEFAULT_TTL_MS = 2 * 60 * 1000
44
+
45
+ /**
46
+ * Tolerancia de reloj para `iat`. No es un repliegue: dos máquinas honestas difieren en
47
+ * segundos, y sin margen una prueba recién firmada se rechazaría por venir «del futuro».
48
+ * Solo afecta al arranque de la ventana; el vencimiento no se estira (ver §verify).
49
+ */
50
+ export const ASSERTION_MAX_SKEW_MS = 60 * 1000
51
+
52
+ /** Lo que se puede pedir. Lista CERRADA: lo que no está aquí no existe. */
53
+ export const SCOPES = Object.freeze(['id:whoami', 'profile:name', 'profile:avatar', 'profile:email', 'profile:social'])
54
+
55
+ /**
56
+ * Qué dato deja ver cada alcance. `id:whoami` no deja ver NINGUNO —es el mínimo: dice
57
+ * quién eres y nada más— y por eso es la lista vacía y no una omisión.
58
+ */
59
+ export const SCOPE_CLAIMS = Object.freeze({
60
+ 'id:whoami': Object.freeze([]),
61
+ 'profile:name': Object.freeze(['name']),
62
+ 'profile:avatar': Object.freeze(['avatar']),
63
+ 'profile:email': Object.freeze(['email']),
64
+ 'profile:social': Object.freeze(['links'])
65
+ })
66
+
67
+ /** Un reto de un solo uso, para quien pide. Que sea él quien lo genere es la mitad del mecanismo. */
68
+ export const newAssertionNonce = () => crypto.randomUUID()
69
+
70
+ /**
71
+ * Normaliza los alcances pedidos: solo los del catálogo, sin repetidos y en orden estable
72
+ * (el cuerpo se firma canónicamente, así que el orden importa para no firmar dos cosas
73
+ * distintas que dicen lo mismo).
74
+ *
75
+ * Pedir alcances desconocidos NO es un aviso que se pueda ignorar: se descartan, y si no
76
+ * queda ninguno se emite el mínimo (`id:whoami`), que es lo que significa «solo quiero
77
+ * saber quién eres».
78
+ */
79
+ export function cleanScopes (scopes) {
80
+ const list = [...new Set((Array.isArray(scopes) ? scopes : []).filter((s) => SCOPES.includes(s)))].sort()
81
+ return list.length ? list : ['id:whoami']
82
+ }
83
+
84
+ /** Las claves de datos que esos alcances permiten llevar. */
85
+ export function claimsAllowed (scopes) {
86
+ const out = new Set()
87
+ for (const s of cleanScopes(scopes)) for (const c of SCOPE_CLAIMS[s]) out.add(c)
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * El cuerpo que se firma. Se construye AQUÍ y en un solo sitio, porque quien firma y quien
93
+ * verifica tienen que estar mirando exactamente los mismos campos: si el emisor añadiera
94
+ * uno que el verificador no reconstruye, la firma no cuadraría y el fallo aparecería como
95
+ * «firma inválida», que manda a buscar al sitio equivocado.
96
+ *
97
+ * Lanza si le falta algo: es un error de programación de quien emite, no un dato del otro
98
+ * lado que pueda venir mal.
99
+ */
100
+ export function assertionBody ({ sub, aud, nonce, scopes, claims, iat, exp }) {
101
+ if (typeof sub !== 'string' || !sub) throw new Error('assertion: sub required')
102
+ if (typeof aud !== 'string' || !aud.trim()) throw new Error('assertion: aud required')
103
+ if (typeof nonce !== 'string' || !nonce) throw new Error('assertion: nonce required')
104
+ if (!Number.isFinite(iat) || !Number.isFinite(exp)) throw new Error('assertion: iat/exp required')
105
+ if (exp <= iat) throw new Error('assertion: exp must be after iat')
106
+ if (exp - iat > ASSERTION_MAX_TTL_MS) throw new Error('assertion: lifetime over the cap')
107
+ const granted = cleanScopes(scopes)
108
+ const permitido = claimsAllowed(granted)
109
+ const out = {}
110
+ for (const [k, v] of Object.entries(claims || {})) {
111
+ // NO se recorta en silencio lo que sobra: llevar un dato sin su alcance es entregar
112
+ // algo que nadie concedió, y el emisor tiene que enterarse de que lo intentó.
113
+ if (!permitido.has(k)) throw new Error(`assertion: claim "${k}" has no scope granting it`)
114
+ if (v !== undefined && v !== null && v !== '') out[k] = v
115
+ }
116
+ return { v: ASSERTION_V, op: 'assertion', sub, aud: aud.trim(), nonce, iat, exp, scopes: granted, claims: out }
117
+ }
118
+
119
+ /** Los campos del cuerpo, sacados de una prueba completa (que además lleva firma y cadena). */
120
+ const bodyOf = (a) => ({ v: a.v, op: a.op, sub: a.sub, aud: a.aud, nonce: a.nonce, iat: a.iat, exp: a.exp, scopes: a.scopes, claims: a.claims })
121
+
122
+ /**
123
+ * ¿Vale esta prueba, PARA MÍ y AHORA?
124
+ *
125
+ * `audience` y `nonce` los pone quien verifica, y son obligatorios: son las dos cosas que
126
+ * él sabe y la prueba no puede inventar. Faltando cualquiera de las dos no se puede
127
+ * juzgar, y decir «ok» sin haber comprobado es cómo un sobre de otro seguía entrando.
128
+ *
129
+ * Devuelve `{ ok:true, profileId, signer, seq, scopes, claims, aud, exp }` o
130
+ * `{ ok:false, reason }`.
131
+ */
132
+ /**
133
+ * @param {any} assertion
134
+ * @param {{ audience?: string, nonce?: string, expectedProfileId?: string|null, now?: number, maxSkewMs?: number }} [opts]
135
+ */
136
+ export async function verifyAssertion (assertion, opts = {}) {
137
+ const { audience, nonce, expectedProfileId = null, now = Date.now(), maxSkewMs = ASSERTION_MAX_SKEW_MS } = opts
138
+ if (typeof audience !== 'string' || !audience.trim()) return { ok: false, reason: 'no-audience' }
139
+ if (typeof nonce !== 'string' || !nonce) return { ok: false, reason: 'no-nonce' }
140
+ const a = assertion
141
+ if (!a || typeof a !== 'object') return { ok: false, reason: 'shape' }
142
+ if (a.v !== ASSERTION_V || a.op !== 'assertion') return { ok: false, reason: 'shape' }
143
+ if (typeof a.sub !== 'string' || typeof a.aud !== 'string' || typeof a.nonce !== 'string') return { ok: false, reason: 'shape' }
144
+ if (!Number.isFinite(a.iat) || !Number.isFinite(a.exp)) return { ok: false, reason: 'shape' }
145
+ if (!Array.isArray(a.scopes) || (a.claims != null && typeof a.claims !== 'object')) return { ok: false, reason: 'shape' }
146
+ if (typeof a.signature !== 'string' || typeof a.publickey !== 'string') return { ok: false, reason: 'shape' }
147
+
148
+ if (a.aud !== audience.trim()) return { ok: false, reason: 'otro-destinatario' }
149
+ if (a.nonce !== nonce) return { ok: false, reason: 'otro-reto' }
150
+
151
+ if (a.exp <= a.iat) return { ok: false, reason: 'vigencia-invalida' }
152
+ // El TOPE lo comprueba quien recibe. Si no, el emisor decide solo cuánto dura su
153
+ // credencial y el tope no es un tope.
154
+ if (a.exp - a.iat > ASSERTION_MAX_TTL_MS) return { ok: false, reason: 'vigencia-excesiva' }
155
+ // Y el vencimiento se juzga SIN margen: el margen es para el arranque (relojes que
156
+ // difieren), no para seguir aceptando lo que ya venció.
157
+ if (a.exp <= now) return { ok: false, reason: 'vencida' }
158
+ if (a.iat > now + maxSkewMs) return { ok: false, reason: 'del-futuro' }
159
+
160
+ if (a.scopes.some((s) => !SCOPES.includes(s))) return { ok: false, reason: 'alcance-desconocido' }
161
+ const permitido = claimsAllowed(a.scopes)
162
+ if (Object.keys(a.claims || {}).some((k) => !permitido.has(k))) return { ok: false, reason: 'claim-sin-alcance' }
163
+
164
+ // Y lo de siempre: que la firma sea de alguien a quien el acta de esa cadena autoriza a
165
+ // firmar por esta identidad. Es la misma comprobación que para cualquier contenido
166
+ // firmado; aquí no se inventa una cripto aparte.
167
+ const v = await verifySignedBy({ data: bodyOf(a), signature: a.signature, publickey: a.publickey, chain: a.chain, expectedProfileId })
168
+ if (!v.ok) return { ok: false, reason: 'firma:' + v.reason }
169
+ // A NOMBRE DE QUIÉN dice ir, contra a nombre de quién va de verdad. Sin esto una prueba
170
+ // podría afirmar ser de otro perfil y la firma seguiría cuadrando: diría la verdad sobre
171
+ // quién la firmó y una mentira sobre de quién es.
172
+ if (a.sub !== v.profileId) return { ok: false, reason: 'otro-sujeto' }
173
+
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
+ }
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 }
package/vault/core.js CHANGED
@@ -21,6 +21,7 @@
21
21
  import { signDelegationWith } from './capabilities.js'
22
22
  import * as Acta from './acta.js'
23
23
  import * as Content from './content.js'
24
+ import { assertionBody, cleanScopes, claimsAllowed, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_TTL_MS } from './assertion.js'
24
25
  import { pubkeyId as pubkeyIdOf, signWithDevice } from './capabilities.js'
25
26
  import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin, requestApproval as remoteApproval, requestRenounce as remoteRenounce, checkMembership as remoteCheck } from './remote.js'
26
27
 
@@ -1605,10 +1606,71 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1605
1606
  throw new Error('profile-without-signer: this device no longer signs for you and is not connected to any vault that can')
1606
1607
  }
1607
1608
  maybeRenewVaultCert()
1608
- try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload: data, onRevoked: wipeVaultLink }) }
1609
+ try {
1610
+ const firmado = await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload: data, onRevoked: wipeVaultLink })
1611
+ // A NOMBRE DE QUIÉN, TAMBIÉN CUANDO FIRMA LA BÓVEDA. La respuesta del daemon trae
1612
+ // `signature` y `publickey` y nada más, así que por este camino la firma salía sin
1613
+ // identidad ni cadena: quien la recibía no podía ni verificarla (le falta el acta que
1614
+ // dice que ese firmante es de este perfil) ni atribuirla a nadie. Se completa con lo
1615
+ // que este aparato ya tiene: el `profileId` y la cadena son del PERFIL, no de quien
1616
+ // firma, y los dos lados están en la misma.
1617
+ //
1618
+ // Si el acta de este aparato estuviera atrasada y la bóveda firmara con una llave
1619
+ // admitida después, la cadena no la incluiría y el receptor lo rechazará por
1620
+ // «firmante-no-autorizado» — ruidoso y correcto: lo que falta ahí es sincronizar el
1621
+ // acta, no aflojar la comprobación.
1622
+ return { ...firmado, profileId: acta?.profileId || publickeyJwkStr, chain: sealerChain() }
1623
+ }
1609
1624
  catch (e) { return handleVaultError(e) }
1610
1625
  },
1611
1626
 
1627
+ /**
1628
+ * UNA PRUEBA PARA ALGUIEN EN CONCRETO, Y POR UN RATO (`vault/assertion.js`).
1629
+ *
1630
+ * `signData` firma «esto lo firmé yo» y no dice para quién: el mismo sobre valía ante
1631
+ * el proxio y ante geo. Esto firma «esto lo firmé yo, PARA `aud`, contestando a
1632
+ * `nonce`, y vale hasta `exp`».
1633
+ *
1634
+ * Firma por el camino de siempre —`signData`—, y eso no es un atajo: así hereda el
1635
+ * re-enrutado a la bóveda cuando este aparato ya no firma por ti, y la prueba sale
1636
+ * igual desde el teléfono que desde el PC.
1637
+ *
1638
+ * QUÉ DATOS LLEVA. Solo los que el perfil YA comparte (`publicMe`), y nunca más de lo
1639
+ * que dan los alcances pedidos. La pantalla de permiso —donde se podrá conceder algo
1640
+ * oculto a un destinatario concreto— es la fase siguiente
1641
+ * (`dotrino-vault/docs/inicio-de-sesion.md`); hasta entonces esto no entrega nada que
1642
+ * no se entregue ya, que es la forma de no adelantar una decisión del usuario.
1643
+ */
1644
+ async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
1645
+ if (typeof audience !== 'string' || !audience.trim()) throw new Error('audience required')
1646
+ if (typeof nonce !== 'string' || !nonce) throw new Error('nonce required')
1647
+ const acta = loadActa()
1648
+ // A NOMBRE DE QUIÉN va: la identidad es el `profileId`, no la llave de este aparato.
1649
+ const sub = acta?.profileId || publickeyJwkStr
1650
+ const granted = cleanScopes(scopes)
1651
+ const permitido = claimsAllowed(granted)
1652
+ const claims = {}
1653
+ if (permitido.size) {
1654
+ const pub = await handlers.publicMe()
1655
+ if (permitido.has('name')) {
1656
+ const n = pub.nickname || [pub.nombres, pub.apellidos].filter(Boolean).join(' ').trim()
1657
+ if (n) claims.name = n
1658
+ }
1659
+ if (permitido.has('avatar') && pub.avatar) claims.avatar = pub.avatar
1660
+ if (permitido.has('email') && pub.email) claims.email = pub.email
1661
+ if (permitido.has('links') && Array.isArray(pub.links) && pub.links.length) claims.links = pub.links
1662
+ }
1663
+ const ttl = Math.min(Math.max(Number(ttlMs) || ASSERTION_DEFAULT_TTL_MS, 1000), ASSERTION_MAX_TTL_MS)
1664
+ const iat = Date.now()
1665
+ const body = assertionBody({ sub, aud: audience, nonce, scopes: granted, claims, iat, exp: iat + ttl })
1666
+ const { signature, publickey, profileId, chain } = await handlers.signData({ data: body })
1667
+ // QUIEN FIRMÓ TIENE QUE SER DE ESTE PERFIL. Si `signData` se re-enrutó a una bóveda
1668
+ // de otra cuenta, la prueba diría `sub` de una y firma de otra: no se arregla, se
1669
+ // para. Verificarla fallaría igual, pero mucho más lejos y con otro nombre.
1670
+ if (profileId !== sub) throw new Error('assertion: the signer belongs to another profile')
1671
+ return { assertion: { ...body, signature, publickey, chain } }
1672
+ },
1673
+
1612
1674
  // ----- delegación de capacidad: la maestra firma un cert para una sub-clave -----
1613
1675
  // de dispositivo `sub`, acotado por `scope` y `exp`, revocable por `nonce`.
1614
1676
  // Es la ÚNICA forma en que la autoridad sale de la clave maestra, y va limitada.