@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 +37 -0
- package/package.json +4 -1
- package/src/index.d.ts +46 -0
- package/src/index.js +23 -0
- package/src/node.js +8 -0
- package/vault/acta.js +5 -1
- package/vault/assertion.js +177 -0
- package/vault/core.js +63 -1
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.
|
|
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
|
-
|
|
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 {
|
|
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.
|