@dotrino/vault 0.9.0 → 0.11.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/package.json +1 -1
- package/src/enroll.js +108 -9
- package/src/invite.js +290 -43
- package/src/protocol.js +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/enroll.js
CHANGED
|
@@ -42,6 +42,10 @@ export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
|
42
42
|
export const MSG_ENROLL = 'vault.enroll'
|
|
43
43
|
export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
|
|
44
44
|
export const MSG_ENROLLED = 'vault.enrolled'
|
|
45
|
+
// --- camino A: la cuenta del aparato pasa a vivir en la bóveda ---
|
|
46
|
+
export const MSG_ENROLL_ADOPT = 'vault.enroll.adopt'
|
|
47
|
+
export const MSG_ACTA_SEALED = 'vault.acta.sealed'
|
|
48
|
+
export const MSG_ACTA_ADOPTED = 'vault.acta.adopted'
|
|
45
49
|
export const MSG_REVOKED = 'vault.revoked'
|
|
46
50
|
export const MSG_ERROR = 'vault.error'
|
|
47
51
|
|
|
@@ -63,12 +67,22 @@ export function scopeToCn (scope) {
|
|
|
63
67
|
return null
|
|
64
68
|
}
|
|
65
69
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Token aleatorio en hex (16 bytes = 128 bits por defecto).
|
|
72
|
+
*
|
|
73
|
+
* El emparejamiento pide 12 (96 bits): son de un solo uso, valen 5 minutos y hay
|
|
74
|
+
* UNA sesión viva a la vez, así que adivinarlo es 2^95 intentos contra una bóveda
|
|
75
|
+
* que además exige el código de 6 dígitos. A cambio, cada byte de menos son ~1,4
|
|
76
|
+
* caracteres menos en el QR — y el QR se mide en filas de terminal.
|
|
77
|
+
*/
|
|
78
|
+
export function randToken (bytes = 16) {
|
|
79
|
+
const b = crypto.getRandomValues(new Uint8Array(bytes))
|
|
69
80
|
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
/** Tamaño del token/nonce de una sesión de emparejamiento (ver `randToken`). */
|
|
84
|
+
const PAIR_TOKEN_BYTES = 12
|
|
85
|
+
|
|
72
86
|
/** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
|
|
73
87
|
export async function deviceIdOf (pub) {
|
|
74
88
|
const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
|
|
@@ -94,8 +108,11 @@ export async function deviceIdOf (pub) {
|
|
|
94
108
|
export function createEnrollDesk ({
|
|
95
109
|
identity, iss, proxy, send, sendByPubkey,
|
|
96
110
|
audit = () => {}, log = () => {},
|
|
97
|
-
onChallenge = () => {}, onPendingChange = () => {},
|
|
98
|
-
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
|
|
111
|
+
onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
|
|
112
|
+
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
|
|
113
|
+
// Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
|
|
114
|
+
// es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
|
|
115
|
+
encPub = null, vaultLabel = ''
|
|
99
116
|
} = {}) {
|
|
100
117
|
if (!identity) throw new Error('createEnrollDesk: falta identity')
|
|
101
118
|
if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
|
|
@@ -126,8 +143,8 @@ export function createEnrollDesk ({
|
|
|
126
143
|
*/
|
|
127
144
|
function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
128
145
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
129
|
-
const token = randToken()
|
|
130
|
-
const sn = randToken()
|
|
146
|
+
const token = randToken(PAIR_TOKEN_BYTES)
|
|
147
|
+
const sn = randToken(PAIR_TOKEN_BYTES)
|
|
131
148
|
const acct = String(account || '').slice(0, 40)
|
|
132
149
|
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
133
150
|
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
@@ -162,6 +179,17 @@ export function createEnrollDesk ({
|
|
|
162
179
|
return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
|
|
163
180
|
}
|
|
164
181
|
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
|
|
182
|
+
// V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
|
|
183
|
+
// bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
|
|
184
|
+
// humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
|
|
185
|
+
const intent = d.intent || 'join'
|
|
186
|
+
if (intent !== 'join' && intent !== 'adopt') {
|
|
187
|
+
return reply(from, { type: MSG_ERROR, error: 'intención desconocida: ' + intent })
|
|
188
|
+
}
|
|
189
|
+
if (intent !== (pend.mode || 'join')) {
|
|
190
|
+
audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
|
|
191
|
+
return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
|
|
192
|
+
}
|
|
165
193
|
if (!isFresh(d)) {
|
|
166
194
|
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
167
195
|
return reply(from, { type: MSG_ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
|
|
@@ -198,9 +226,12 @@ export function createEnrollDesk ({
|
|
|
198
226
|
}
|
|
199
227
|
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
200
228
|
if (d.label) pend.label = String(d.label).slice(0, 60)
|
|
229
|
+
// Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
|
|
230
|
+
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
231
|
+
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
201
232
|
|
|
202
233
|
reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
|
|
203
|
-
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
|
|
234
|
+
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
|
|
204
235
|
fire(onPendingChange)
|
|
205
236
|
return { deviceId }
|
|
206
237
|
}
|
|
@@ -236,6 +267,20 @@ export function createEnrollDesk ({
|
|
|
236
267
|
throw new Error('el código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Vuelve a mirarlo y prueba otra vez.')
|
|
237
268
|
}
|
|
238
269
|
|
|
270
|
+
// CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
|
|
271
|
+
// aparato la meta en el acta de la cuenta que le está pasando. El código de vuelta es
|
|
272
|
+
// la misma defensa de siempre, en el otro sentido: el aparato solo hace caso a una
|
|
273
|
+
// bóveda que demuestre que un humano la aprobó.
|
|
274
|
+
if ((pend.mode || 'join') === 'adopt') {
|
|
275
|
+
audit('adopt-approve', { device: pend.deviceId, profile: pend.profileId || null })
|
|
276
|
+
pend.state = 'AWAITING_ACTA'
|
|
277
|
+
pend.approvedAt = Date.now()
|
|
278
|
+
reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
|
|
279
|
+
log('[vault] adopción aprobada para %s: esperando el acta sellada', pend.deviceId)
|
|
280
|
+
fire(onPendingChange)
|
|
281
|
+
return { ok: true, deviceId: pend.deviceId, adopting: true }
|
|
282
|
+
}
|
|
283
|
+
|
|
239
284
|
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
240
285
|
|
|
241
286
|
// Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
|
|
@@ -263,6 +308,60 @@ export function createEnrollDesk ({
|
|
|
263
308
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
264
309
|
}
|
|
265
310
|
|
|
311
|
+
/**
|
|
312
|
+
* CAMINO A · paso 6: llega el acta que el aparato acaba de sellar, con la bóveda dentro
|
|
313
|
+
* como miembro, la clave de contenido envuelta para ella y el mando ya traspasado.
|
|
314
|
+
*
|
|
315
|
+
* Lo que se comprueba antes de guardar nada (y por qué):
|
|
316
|
+
* · que el sellador sea ESTA bóveda — si no, no es un traspaso, es un acta ajena;
|
|
317
|
+
* · que la selle el aparato que estaba en este emparejamiento — cierra que un tercero
|
|
318
|
+
* que vea pasar el mensaje cuele la suya;
|
|
319
|
+
* · que sea la cuenta que ese aparato declaró al enrolarse (`profileId`) — cierra el
|
|
320
|
+
* cambiazo de cuenta entre el anuncio que leyó el humano y lo que llega después.
|
|
321
|
+
*
|
|
322
|
+
* Adoptar la cuenta de otro solo procede sobre un perfil que **nació para eso** (la marca
|
|
323
|
+
* de `prepareForAdoption`). Es la misma regla del navegador: sin la marca, adoptar sería
|
|
324
|
+
* pisar una cuenta con datos, y eso no puede pasar por accidente.
|
|
325
|
+
*/
|
|
326
|
+
async function handleActaSealed (from, p) {
|
|
327
|
+
const acta = p?.acta
|
|
328
|
+
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
329
|
+
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no hay ninguna adopción esperando un acta' })
|
|
330
|
+
if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'acta ausente o ilegible' })
|
|
331
|
+
if (acta.sealer !== iss) {
|
|
332
|
+
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
333
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta no nombra a esta bóveda como quien manda' })
|
|
334
|
+
}
|
|
335
|
+
if (acta.sealedBy !== pend.dpub) {
|
|
336
|
+
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
337
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta no la selló el dispositivo de este emparejamiento' })
|
|
338
|
+
}
|
|
339
|
+
if (pend.profileId && acta.profileId !== pend.profileId) {
|
|
340
|
+
audit('rejected', { what: 'adopt', reason: 'other-profile' })
|
|
341
|
+
return reply(from, { type: MSG_ERROR, error: 'esa acta es de otra cuenta distinta a la que anunció el dispositivo' })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const r = await identity.joinProfile(acta)
|
|
346
|
+
if (!r?.joined) throw new Error(r?.reason || 'no se pudo adoptar')
|
|
347
|
+
audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
|
|
348
|
+
// El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
|
|
349
|
+
// dos quedan en la misma versión.
|
|
350
|
+
const mia = (await identity.profileActa?.())?.acta || acta
|
|
351
|
+
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
|
|
352
|
+
pend.state = 'DONE'
|
|
353
|
+
pending.delete(pend.token)
|
|
354
|
+
fire(onPendingChange)
|
|
355
|
+
fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
|
|
356
|
+
log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
|
|
357
|
+
return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
|
|
358
|
+
} catch (e) {
|
|
359
|
+
log('[vault] no se pudo adoptar la cuenta: %s', e.message)
|
|
360
|
+
reply(pend.from, { type: MSG_ERROR, error: 'la bóveda no pudo adoptar la cuenta: ' + e.message })
|
|
361
|
+
return { ok: false, error: e.message }
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
266
365
|
/** Rechaza un enrolamiento pendiente. */
|
|
267
366
|
function reject (deviceId) {
|
|
268
367
|
const pend = deviceId
|
|
@@ -300,7 +399,7 @@ export function createEnrollDesk ({
|
|
|
300
399
|
}
|
|
301
400
|
|
|
302
401
|
return {
|
|
303
|
-
startPairing, stopPairing, handleEnroll, approve, reject,
|
|
402
|
+
startPairing, stopPairing, handleEnroll, handleActaSealed, approve, reject,
|
|
304
403
|
listPending, findPending, emitRevoke, revoke,
|
|
305
404
|
get pendingCount () { return pending.size }
|
|
306
405
|
}
|
package/src/invite.js
CHANGED
|
@@ -1,65 +1,310 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* invite.js — la invitación de emparejamiento: cómo se escribe y cómo se lee.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Una invitación viaja de dos maneras —un QR que se escanea y un código que se
|
|
5
|
+
* copia y se pega— y **las dos quieren lo mismo: que sea CORTA**. En un QR cada
|
|
6
|
+
* carácter son módulos, y los módulos son filas y columnas de terminal; en un
|
|
7
|
+
* código pegable, cada carácter es una oportunidad de que alguien lo corte mal.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Por eso el formato vigente es **`c` (compacto)**: los datos van en BINARIO y el
|
|
10
|
+
* binario en base64url. Nada de JSON. Un JSON con la llave maestra dentro pesa
|
|
11
|
+
* ~340 caracteres (la llave es una JWK *serializada como string*, con sus comillas
|
|
12
|
+
* escapadas: 182 de esos 340 son ella sola); el mismo contenido en binario son ~75
|
|
13
|
+
* bytes → **~100 caracteres**. Medido en la práctica: el QR pasó de 69 módulos
|
|
14
|
+
* (77×39 en la terminal) a 41 (49×25), y el código pegable de 458 caracteres a 101.
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* De dónde sale el ahorro, en orden de importancia:
|
|
17
|
+
* · **La llave `iss`** (182 → 44 chars). Una pubkey P-256 son dos coordenadas de
|
|
18
|
+
* 32 bytes; en el QR va el **punto comprimido** de 33 bytes (SEC1: `02`/`03`
|
|
19
|
+
* según la paridad de `y`, seguido de `x`) y el lector recupera `y` resolviendo
|
|
20
|
+
* la curva. La JWK se rearma con una PLANTILLA (el índice va en la cabecera)
|
|
21
|
+
* para que la string vuelva **byte a byte** igual: el proxy direcciona por esa
|
|
22
|
+
* string exacta, así que una coma de más rompe el enrutamiento.
|
|
23
|
+
* · **`token` y `sn`** (34+34 → 16+16 bytes): eran hexadecimal, que gasta dos
|
|
24
|
+
* caracteres por byte.
|
|
25
|
+
* · **`proxy`** (33 → 0): si es el del ecosistema no viaja; se sobreentiende.
|
|
26
|
+
* · **`m` y `v`**: dos campos de JSON → dos grupos de bits de la cabecera.
|
|
18
27
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* que
|
|
23
|
-
*
|
|
28
|
+
* SEGURIDAD DEL AHORRO: `encodeInvite` **comprueba el viaje de vuelta** antes de
|
|
29
|
+
* entregar la forma compacta — decodifica lo que acaba de codificar y exige que sea
|
|
30
|
+
* idéntico al original. Si algo no encaja (una JWK con otra forma, un campo nuevo,
|
|
31
|
+
* un `token` que no es hex), devuelve la forma larga en base64. Así una llave rara
|
|
32
|
+
* no rompe un emparejamiento: como mucho lo hace más grande.
|
|
33
|
+
*
|
|
34
|
+
* Formatos que se leen (el primer carácter dice cuál es):
|
|
35
|
+
* · `c` — compacto binario+base64url. **El que se emite hoy**, para el QR y para
|
|
36
|
+
* el código pegable: es a la vez el más corto y una sola palabra sin
|
|
37
|
+
* comillas ni llaves, que sobrevive a un doble clic y a un chat.
|
|
38
|
+
* · `b` — base64url del JSON. Forma larga, de reserva.
|
|
39
|
+
* · `j` — JSON crudo. Solo para leer enlaces ya emitidos.
|
|
40
|
+
* · sin marca — anterior a la marca de formato (base64url, o JSON).
|
|
41
|
+
*
|
|
42
|
+
* GOTCHA que justifica el `decodeURIComponent`: el JSON crudo lleva `{`, `}` y `"`,
|
|
43
|
+
* que **no son legales en una URI**. Al abrir el enlace, el navegador los
|
|
44
|
+
* percent-codifica (`%22`…), así que lo que llega a `location.hash` NO es lo que se
|
|
45
|
+
* emitió. Medido en un navegador real (2026-07-28). El formato compacto no tiene
|
|
46
|
+
* ese problema —base64url ya es seguro en una URL—, pero `j` sigue por ahí.
|
|
24
47
|
*/
|
|
25
48
|
|
|
26
49
|
export const FMT_JSON = 'j'
|
|
27
50
|
export const FMT_B64 = 'b'
|
|
51
|
+
export const FMT_COMPACT = 'c'
|
|
52
|
+
|
|
53
|
+
/** El proxy del ecosistema: si es este, no viaja en la invitación compacta. */
|
|
54
|
+
export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* La base del enlace del QR. Corta a propósito (`/d#v=` en vez de
|
|
58
|
+
* `/dispositivos#vault=`): son 15 caracteres menos dentro del QR, y ahí los
|
|
59
|
+
* caracteres se pagan en módulos. `vault.dotrino.com/dispositivos` sigue
|
|
60
|
+
* funcionando —es la ruta que la gente ya tiene— y `parseInvite` lee las dos.
|
|
61
|
+
*/
|
|
62
|
+
export const PAIR_URL = 'https://vault.dotrino.com/d#v='
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Conversiones (sin dependencias: esto corre en el navegador, en Node y dentro
|
|
66
|
+
// del binario SEA)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const B64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
28
70
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
let
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
71
|
+
function bytesToB64url (bytes) {
|
|
72
|
+
let out = ''
|
|
73
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
74
|
+
const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
|
|
75
|
+
out += B64_STD[a >> 2]
|
|
76
|
+
out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
|
|
77
|
+
if (b === undefined) break
|
|
78
|
+
out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
|
|
79
|
+
if (c === undefined) break
|
|
80
|
+
out += B64_STD[c & 63]
|
|
81
|
+
}
|
|
82
|
+
return out.replace(/\+/g, '-').replace(/\//g, '_')
|
|
35
83
|
}
|
|
36
84
|
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
85
|
+
function b64urlToBytes (s) {
|
|
86
|
+
const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
|
|
87
|
+
const out = []
|
|
88
|
+
let acc = 0; let bits = 0
|
|
89
|
+
for (const ch of clean) {
|
|
90
|
+
const v = B64_STD.indexOf(ch)
|
|
91
|
+
if (v < 0) return null
|
|
92
|
+
acc = (acc << 6) | v; bits += 6
|
|
93
|
+
if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
|
|
44
94
|
}
|
|
45
|
-
return
|
|
95
|
+
return Uint8Array.from(out)
|
|
46
96
|
}
|
|
47
97
|
|
|
48
|
-
|
|
49
|
-
|
|
98
|
+
const utf8 = (s) => new TextEncoder().encode(s)
|
|
99
|
+
const fromUtf8 = (b) => new TextDecoder().decode(b)
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `token` y `sn` son hexadecimal. Caben en la invitación en dos tamaños —12 o 16
|
|
103
|
+
* bytes— porque el hexadecimal gasta dos caracteres por byte y en un QR eso se
|
|
104
|
+
* paga: 12 bytes (96 bits, lo que emite hoy `startPairing`) son 11 caracteres
|
|
105
|
+
* menos que 16, y esos 11 caracteres son la diferencia entre que quepa un nombre
|
|
106
|
+
* de cuenta normal o que el QR suba una versión entera.
|
|
107
|
+
*/
|
|
108
|
+
const hexLen = (s) => (typeof s === 'string' && /^[0-9a-f]+$/.test(s) && (s.length === 24 || s.length === 32)) ? s.length / 2 : 0
|
|
109
|
+
const hexToBytes = (s) => Uint8Array.from(s.match(/../g).map((h) => parseInt(h, 16)))
|
|
110
|
+
const bytesToHex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
111
|
+
|
|
112
|
+
const b64urlEncodeStr = (s) => bytesToB64url(utf8(s))
|
|
113
|
+
const b64urlDecodeStr = (s) => { const b = b64urlToBytes(s); return b ? fromUtf8(b) : null }
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// La llave maestra: JWK ⇄ punto comprimido de la curva P-256
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Las formas de JWK que existen en el ecosistema. `JSON.stringify` de una JWK
|
|
121
|
+
* conserva el orden en que la exportó cada WebCrypto, y **ese orden es parte de la
|
|
122
|
+
* identidad**: el proxy direcciona por la string exacta. Por eso no se «normaliza»
|
|
123
|
+
* la JWK —eso cambiaría la dirección de la bóveda—, se rearma tal cual con la
|
|
124
|
+
* plantilla que le toca. El índice viaja en la cabecera (2 bits, hasta 4 formas).
|
|
125
|
+
*/
|
|
126
|
+
const JWK_TEMPLATES = [
|
|
127
|
+
// 0 — WebCrypto de Node (el daemon del PC): key_ops, ext, kty, x, y, crv
|
|
128
|
+
(x, y) => `{"key_ops":["verify"],"ext":true,"kty":"EC","x":"${x}","y":"${y}","crv":"P-256"}`,
|
|
129
|
+
// 1 — WebCrypto de navegador (Chrome/Firefox/Safari, alfabético): crv, ext, key_ops, kty, x, y
|
|
130
|
+
(x, y) => `{"crv":"P-256","ext":true,"key_ops":["verify"],"kty":"EC","x":"${x}","y":"${y}"}`,
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
// Parámetros de la curva P-256 (FIPS 186-4). `a` es −3.
|
|
134
|
+
const P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn
|
|
135
|
+
const P256_B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn
|
|
136
|
+
|
|
137
|
+
const modPow = (base, exp, m) => {
|
|
138
|
+
let r = 1n; let b = base % m
|
|
139
|
+
while (exp > 0n) { if (exp & 1n) r = (r * b) % m; b = (b * b) % m; exp >>= 1n }
|
|
140
|
+
return r
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const bytesToBig = (b) => { let n = 0n; for (const x of b) n = (n << 8n) | BigInt(x); return n }
|
|
144
|
+
const bigToBytes32 = (n) => { const out = new Uint8Array(32); for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n } return out }
|
|
145
|
+
|
|
146
|
+
/** Coordenada de una JWK: base64url de 32 bytes exactos, o `null`. */
|
|
147
|
+
function coordBytes (s) {
|
|
148
|
+
if (typeof s !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(s)) return null
|
|
149
|
+
const b = b64urlToBytes(s)
|
|
150
|
+
return b && b.length === 32 ? b : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `iss` (JWK serializada) → `{ tpl, point }` con el punto comprimido de 33 bytes, o
|
|
155
|
+
* `null` si la llave no tiene una forma conocida (entonces la invitación va larga).
|
|
156
|
+
*/
|
|
157
|
+
function packPubkey (iss) {
|
|
158
|
+
if (typeof iss !== 'string') return null
|
|
159
|
+
let jwk
|
|
160
|
+
try { jwk = JSON.parse(iss) } catch { return null }
|
|
161
|
+
if (!jwk || jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null
|
|
162
|
+
const xb = coordBytes(jwk.x); const yb = coordBytes(jwk.y)
|
|
163
|
+
if (!xb || !yb) return null
|
|
164
|
+
|
|
165
|
+
const tpl = JWK_TEMPLATES.findIndex((f) => f(jwk.x, jwk.y) === iss)
|
|
166
|
+
if (tpl < 0) return null // forma desconocida: no se puede rearmar igual → larga
|
|
167
|
+
|
|
168
|
+
const point = new Uint8Array(33)
|
|
169
|
+
point[0] = 2 + (yb[31] & 1) // 02 = y par, 03 = y impar
|
|
170
|
+
point.set(xb, 1)
|
|
171
|
+
return { tpl, point }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Punto comprimido + plantilla → la `iss` original (o `null` si el punto no es de la curva). */
|
|
175
|
+
function unpackPubkey (point, tpl) {
|
|
176
|
+
const template = JWK_TEMPLATES[tpl]
|
|
177
|
+
if (!template || point.length !== 33 || (point[0] !== 2 && point[0] !== 3)) return null
|
|
178
|
+
|
|
179
|
+
const x = bytesToBig(point.subarray(1))
|
|
180
|
+
if (x >= P256_P) return null
|
|
181
|
+
// y² = x³ − 3x + b (mod p). Con p ≡ 3 (mod 4) la raíz es y = (y²)^((p+1)/4).
|
|
182
|
+
const y2 = (((x * x) % P256_P) * x - 3n * x + P256_B) % P256_P
|
|
183
|
+
const rhs = (y2 + P256_P) % P256_P
|
|
184
|
+
let y = modPow(rhs, (P256_P + 1n) / 4n, P256_P)
|
|
185
|
+
if ((y * y) % P256_P !== rhs) return null // x no está en la curva
|
|
186
|
+
if ((y & 1n) !== BigInt(point[0] & 1)) y = P256_P - y
|
|
187
|
+
|
|
188
|
+
return template(bytesToB64url(point.subarray(1)), bytesToB64url(bigToBytes32(y)))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// El formato compacto
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Cabecera (1 byte):
|
|
197
|
+
* bits 0-2 versión del protocolo (`qr.v`, hoy 2)
|
|
198
|
+
* bit 3 modo: 0 = `join` · 1 = `adopt`
|
|
199
|
+
* bits 4-5 plantilla de la JWK (índice en `JWK_TEMPLATES`)
|
|
200
|
+
* bit 6 lleva proxy propio (si no, el del ecosistema)
|
|
201
|
+
* bit 7 `token`/`sn` de 12 bytes (si 0, de 16)
|
|
202
|
+
*
|
|
203
|
+
* Cuerpo: punto comprimido (33) ‖ token (n) ‖ sn (n) ‖ len+`acct` ‖ [len+`proxy`]
|
|
204
|
+
*/
|
|
205
|
+
const MODES = ['join', 'adopt']
|
|
206
|
+
|
|
207
|
+
function compactEncode (qr) {
|
|
208
|
+
if (!qr || typeof qr !== 'object') return null
|
|
209
|
+
const v = qr.v
|
|
210
|
+
if (!Number.isInteger(v) || v < 0 || v > 7) return null
|
|
211
|
+
const mode = MODES.indexOf(qr.m)
|
|
212
|
+
if (mode < 0) return null
|
|
213
|
+
const n = hexLen(qr.token)
|
|
214
|
+
if (!n || hexLen(qr.sn) !== n) return null
|
|
215
|
+
|
|
216
|
+
const pub = packPubkey(qr.iss)
|
|
217
|
+
if (!pub || pub.tpl > 3) return null
|
|
218
|
+
|
|
219
|
+
const acct = utf8(String(qr.acct || ''))
|
|
220
|
+
if (acct.length > 255) return null
|
|
221
|
+
const ownProxy = qr.proxy && qr.proxy !== DEFAULT_PROXY
|
|
222
|
+
const proxy = ownProxy ? utf8(String(qr.proxy)) : null
|
|
223
|
+
if (proxy && proxy.length > 255) return null
|
|
224
|
+
|
|
225
|
+
// Un campo que no se sepa escribir se perdería en silencio: eso NO se hace.
|
|
226
|
+
const known = new Set(['v', 'iss', 'proxy', 'token', 'sn', 'm', 'acct'])
|
|
227
|
+
if (Object.keys(qr).some((k) => !known.has(k))) return null
|
|
228
|
+
|
|
229
|
+
const head = v | (mode << 3) | (pub.tpl << 4) | (ownProxy ? 0x40 : 0) | (n === 12 ? 0x80 : 0)
|
|
230
|
+
const out = [head, ...pub.point, ...hexToBytes(qr.token), ...hexToBytes(qr.sn), acct.length, ...acct]
|
|
231
|
+
if (proxy) out.push(proxy.length, ...proxy)
|
|
232
|
+
return bytesToB64url(Uint8Array.from(out))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function compactDecode (text) {
|
|
236
|
+
const b = b64urlToBytes(text)
|
|
237
|
+
const n = (b && (b[0] & 0x80)) ? 12 : 16
|
|
238
|
+
if (!b || b.length < 35 + n * 2) return null
|
|
239
|
+
const head = b[0]
|
|
240
|
+
|
|
241
|
+
const iss = unpackPubkey(b.subarray(1, 34), (head >> 4) & 3)
|
|
242
|
+
if (!iss) return null
|
|
243
|
+
|
|
244
|
+
let i = 34
|
|
245
|
+
const token = bytesToHex(b.subarray(i, i + n)); i += n
|
|
246
|
+
const sn = bytesToHex(b.subarray(i, i + n)); i += n
|
|
247
|
+
const acctLen = b[i]; i += 1
|
|
248
|
+
if (i + acctLen > b.length) return null
|
|
249
|
+
const acct = acctLen ? fromUtf8(b.subarray(i, i + acctLen)) : ''
|
|
250
|
+
i += acctLen
|
|
251
|
+
|
|
252
|
+
let proxy = DEFAULT_PROXY
|
|
253
|
+
if (head & 0x40) {
|
|
254
|
+
if (i >= b.length) return null
|
|
255
|
+
const n = b[i]; i += 1
|
|
256
|
+
if (i + n > b.length) return null
|
|
257
|
+
proxy = fromUtf8(b.subarray(i, i + n)); i += n
|
|
258
|
+
}
|
|
259
|
+
if (i !== b.length) return null // sobran bytes: no es lo que creemos que es
|
|
260
|
+
|
|
261
|
+
// Mismo orden de campos que `startPairing`, para que las dos formas serialicen igual.
|
|
262
|
+
return { v: head & 7, iss, proxy, token, sn, m: MODES[(head >> 3) & 1], ...(acct ? { acct } : {}) }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// API
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/** Comparación por contenido, sin depender del orden de las claves. */
|
|
270
|
+
const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* El payload marcado, listo para meter en el `#fragment` o para copiar y pegar.
|
|
274
|
+
*
|
|
275
|
+
* Por defecto **compacto**, que es lo más corto y a la vez pegable. Se cae a la
|
|
276
|
+
* forma larga (`b`) sola, sin avisar, si el compacto no reproduce el original
|
|
277
|
+
* exactamente: más vale un QR grande que uno que no empareja.
|
|
278
|
+
*/
|
|
279
|
+
export function encodeInvite (qr, fmt = FMT_COMPACT) {
|
|
50
280
|
const json = JSON.stringify(qr)
|
|
51
|
-
|
|
281
|
+
if (fmt === FMT_JSON) return FMT_JSON + json
|
|
282
|
+
if (fmt === FMT_COMPACT) {
|
|
283
|
+
const c = compactEncode(qr)
|
|
284
|
+
// El viaje de vuelta se comprueba SIEMPRE: es barato (una vez por
|
|
285
|
+
// emparejamiento) y es lo que permite comprimir sin jugarse el enrolamiento.
|
|
286
|
+
if (c) { const back = compactDecode(c); if (back && canon(back) === canon(qr)) return FMT_COMPACT + c }
|
|
287
|
+
}
|
|
288
|
+
return FMT_B64 + b64urlEncodeStr(json)
|
|
52
289
|
}
|
|
53
290
|
|
|
54
|
-
/**
|
|
291
|
+
/** El enlace del QR: `https://vault.dotrino.com/d#v=<invitación>`. */
|
|
292
|
+
export function inviteUrl (qr) { return PAIR_URL + encodeInvite(qr) }
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Corta el `#fragment` de una URL. `#vault=` es la forma larga histórica y `#v=` la
|
|
296
|
+
* corta de hoy; se prueba la larga primero porque `#vault=` empieza por `#v`.
|
|
297
|
+
*/
|
|
55
298
|
const cutFragment = (text) => {
|
|
56
|
-
const
|
|
57
|
-
|
|
299
|
+
const long = text.indexOf('#vault=')
|
|
300
|
+
if (long >= 0) return text.slice(long + 7)
|
|
301
|
+
const short = text.indexOf('#v=')
|
|
302
|
+
return short >= 0 ? text.slice(short + 3) : text
|
|
58
303
|
}
|
|
59
304
|
|
|
60
305
|
/**
|
|
61
|
-
* Lee una invitación venga como venga: URL con `#vault
|
|
62
|
-
* marca de formato o sin ella (formatos viejos). Devuelve el objeto del QR o
|
|
306
|
+
* Lee una invitación venga como venga: URL con `#v=`/`#vault=`, el código suelto,
|
|
307
|
+
* con marca de formato o sin ella (formatos viejos). Devuelve el objeto del QR o
|
|
63
308
|
* `null` — nunca lanza, porque del otro lado hay alguien pegando texto a mano.
|
|
64
309
|
*/
|
|
65
310
|
export function parseInvite (text) {
|
|
@@ -75,13 +320,15 @@ export function parseInvite (text) {
|
|
|
75
320
|
|
|
76
321
|
const marca = payload[0]
|
|
77
322
|
const resto = payload.slice(1)
|
|
78
|
-
if (marca ===
|
|
79
|
-
if (marca ===
|
|
323
|
+
if (marca === FMT_COMPACT) { const o = compactDecode(resto); if (o) return o }
|
|
324
|
+
if (marca === FMT_JSON) { const o = parse(undoUrl(resto)) || parse(resto); if (o) return o }
|
|
325
|
+
if (marca === FMT_B64) { const s = b64urlDecodeStr(resto); const o = s && parse(s); if (o) return o }
|
|
80
326
|
|
|
81
327
|
// --- sin marca: formatos anteriores a la marca de formato (compatibilidad) ---
|
|
82
328
|
const crudo = undoUrl(payload)
|
|
83
329
|
if (crudo.trimStart().startsWith('{')) return parse(crudo)
|
|
84
|
-
|
|
330
|
+
const s = b64urlDecodeStr(payload)
|
|
331
|
+
return s ? parse(s) : null
|
|
85
332
|
}
|
|
86
333
|
|
|
87
|
-
export default { encodeInvite, parseInvite, FMT_JSON, FMT_B64 }
|
|
334
|
+
export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, PAIR_URL, DEFAULT_PROXY }
|
package/src/protocol.js
CHANGED
|
@@ -20,6 +20,13 @@ export const MSG = Object.freeze({
|
|
|
20
20
|
ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
|
|
21
21
|
ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
|
|
22
22
|
ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
|
|
23
|
+
// Camino A (la cuenta del aparato pasa a vivir en la bóveda): en vez de un cert, la
|
|
24
|
+
// bóveda manda QUIÉN es para que el aparato la admita, le envuelva la clave de
|
|
25
|
+
// contenido y le traspase el mando; el aparato devuelve el acta sellada y la bóveda
|
|
26
|
+
// responde con la definitiva. Ver docs/vinculacion-de-cuentas.md §2.
|
|
27
|
+
ENROLL_ADOPT: 'vault.enroll.adopt', // vault → dispositivo: { code, pub, encPub, label }
|
|
28
|
+
ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
|
|
29
|
+
ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
|
|
23
30
|
REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
|
|
24
31
|
SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
|
|
25
32
|
SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
|