@dotrino/vault 0.11.0 → 0.13.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 +2 -2
- package/src/enroll.js +47 -4
- package/src/invite.js +76 -1
- package/src/protocol.js +5 -0
- package/src/service.js +57 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
],
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@dotrino/identity": ">=0.30.0",
|
|
46
|
-
"@dotrino/proxy-client": ">=0.
|
|
46
|
+
"@dotrino/proxy-client": ">=0.9.0"
|
|
47
47
|
},
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"repository": {
|
package/src/enroll.js
CHANGED
|
@@ -39,6 +39,8 @@ export const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
|
39
39
|
/** Vida por defecto del cert de un dispositivo (tope duro de `MAX_DELEGATION_MS`). */
|
|
40
40
|
export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
41
41
|
|
|
42
|
+
export const MSG_HELLO = 'vault.hello'
|
|
43
|
+
export const MSG_HELLO_OK = 'vault.hello.ok'
|
|
42
44
|
export const MSG_ENROLL = 'vault.enroll'
|
|
43
45
|
export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
|
|
44
46
|
export const MSG_ENROLLED = 'vault.enrolled'
|
|
@@ -112,7 +114,10 @@ export function createEnrollDesk ({
|
|
|
112
114
|
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
|
|
113
115
|
// Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
|
|
114
116
|
// es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
|
|
115
|
-
encPub = null, vaultLabel = ''
|
|
117
|
+
encPub = null, vaultLabel = '',
|
|
118
|
+
// Token de CONEXIÓN de esta bóveda en el proxy (4 chars): su dirección. Es lo
|
|
119
|
+
// único que necesita el QR corto para que el aparato le hable punto a punto.
|
|
120
|
+
connToken = null
|
|
116
121
|
} = {}) {
|
|
117
122
|
if (!identity) throw new Error('createEnrollDesk: falta identity')
|
|
118
123
|
if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
|
|
@@ -141,11 +146,27 @@ export function createEnrollDesk ({
|
|
|
141
146
|
* aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
|
|
142
147
|
* identidad de verdad de la cuenta es `iss`.
|
|
143
148
|
*/
|
|
144
|
-
function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
149
|
+
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
145
150
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
151
|
+
const acct = String(account || '').slice(0, 40)
|
|
152
|
+
// INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
|
|
153
|
+
// nonce de la sesión. La llave, el proxy y el nombre de la cuenta los pide el
|
|
154
|
+
// aparato por la red presentando el `sn`. El nonce hace de identificador de
|
|
155
|
+
// sesión: no hace falta un token de emparejamiento aparte.
|
|
156
|
+
//
|
|
157
|
+
// `conn` es una CITA del proxio (6 caracteres, un solo uso, caduca en
|
|
158
|
+
// minutos), no la dirección de la conexión: esa pasó a ser una instancia de
|
|
159
|
+
// 24 caracteres, que ni entra cómoda en un QR ni tiene por qué quedar impresa
|
|
160
|
+
// en algo que circula. Por eso se pide una nueva por emparejamiento, y por
|
|
161
|
+
// eso esto es asíncrono.
|
|
162
|
+
const conn = typeof connToken === 'function' ? await connToken() : connToken
|
|
163
|
+
if (conn) {
|
|
164
|
+
const sn = randToken(8)
|
|
165
|
+
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
166
|
+
return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
|
|
167
|
+
}
|
|
146
168
|
const token = randToken(PAIR_TOKEN_BYTES)
|
|
147
169
|
const sn = randToken(PAIR_TOKEN_BYTES)
|
|
148
|
-
const acct = String(account || '').slice(0, 40)
|
|
149
170
|
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
150
171
|
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
151
172
|
}
|
|
@@ -165,6 +186,28 @@ export function createEnrollDesk ({
|
|
|
165
186
|
return null
|
|
166
187
|
}
|
|
167
188
|
|
|
189
|
+
/**
|
|
190
|
+
* «¿Quién eres?» — la respuesta al QR corto. Solo se contesta a quien presente el
|
|
191
|
+
* `sn` de una sesión VIVA: el token de conexión son 4 caracteres y se puede acertar
|
|
192
|
+
* a ciegas, el `sn` no. Fuera de un emparejamiento no hay ninguna sesión y por lo
|
|
193
|
+
* tanto no hay respuesta: la puerta solo está abierta mientras dura el `pair`.
|
|
194
|
+
*/
|
|
195
|
+
async function handleHello (from, p) {
|
|
196
|
+
const pend = pending.get(String(p?.sn || ''))
|
|
197
|
+
if (!pend || Date.now() > pend.exp) {
|
|
198
|
+
audit('rejected', { what: 'hello', reason: 'sin-sesion' })
|
|
199
|
+
return reply(from, { type: MSG_ERROR, error: 'no hay ningún emparejamiento abierto con ese código' })
|
|
200
|
+
}
|
|
201
|
+
// La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
|
|
202
|
+
// la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
|
|
203
|
+
// de otra bóveda. Lo que NO hace es demostrar que sea TU bóveda —cualquiera puede
|
|
204
|
+
// firmar con una llave suya—; eso solo lo demuestra el código de 6 dígitos.
|
|
205
|
+
const body = { op: 'hello', sn: pend.sn, iss, proxy, acct: pend.account || '', m: pend.mode || 'join', ts: Date.now() }
|
|
206
|
+
const { signature } = await identity.signData(body)
|
|
207
|
+
reply(from, { type: MSG_HELLO_OK, body, signature })
|
|
208
|
+
return { ok: true }
|
|
209
|
+
}
|
|
210
|
+
|
|
168
211
|
/**
|
|
169
212
|
* ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
|
|
170
213
|
* COMPROMISO de su código. Todavía NO se firma ningún cert.
|
|
@@ -399,7 +442,7 @@ export function createEnrollDesk ({
|
|
|
399
442
|
}
|
|
400
443
|
|
|
401
444
|
return {
|
|
402
|
-
startPairing, stopPairing, handleEnroll, handleActaSealed, approve, reject,
|
|
445
|
+
startPairing, stopPairing, handleEnroll, handleActaSealed, handleHello, approve, reject,
|
|
403
446
|
listPending, findPending, emitRevoke, revoke,
|
|
404
447
|
get pendingCount () { return pending.size }
|
|
405
448
|
}
|
package/src/invite.js
CHANGED
|
@@ -49,6 +49,21 @@
|
|
|
49
49
|
export const FMT_JSON = 'j'
|
|
50
50
|
export const FMT_B64 = 'b'
|
|
51
51
|
export const FMT_COMPACT = 'c'
|
|
52
|
+
/**
|
|
53
|
+
* `t` — la invitación CORTA: solo la dirección y el nonce de la sesión. La llave
|
|
54
|
+
* maestra ya no viaja; el aparato se la pide a la bóveda por la red presentando el
|
|
55
|
+
* `sn`, y la comprueba con la firma del certificado y con el código de 6 dígitos,
|
|
56
|
+
* que es lo único que de verdad decide. Esconderla no aportaba nada —una pública es
|
|
57
|
+
* pública— y ocupaba 44 de los ~100 caracteres.
|
|
58
|
+
*
|
|
59
|
+
* Lo que lleva NO es la dirección de la bóveda: es una **cita del proxy** —un código
|
|
60
|
+
* de 6 caracteres, de un solo uso y con minutos de vida, cuyos 2 primeros dicen qué
|
|
61
|
+
* proxio la emitió—. El aparato la CANJEA (`redeemPairingCode`) y obtiene la dirección
|
|
62
|
+
* real de la conexión, que hoy son 24 caracteres para poder rutearse entre proxios y no
|
|
63
|
+
* cabría cómoda en un QR. Además, una cita caduca y se quema: no deja una dirección
|
|
64
|
+
* permanente impresa en algo que circula.
|
|
65
|
+
*/
|
|
66
|
+
export const FMT_SHORT = 't'
|
|
52
67
|
|
|
53
68
|
/** El proxy del ecosistema: si es este, no viaja en la invitación compacta. */
|
|
54
69
|
export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
|
|
@@ -266,6 +281,60 @@ function compactDecode (text) {
|
|
|
266
281
|
// API
|
|
267
282
|
// ---------------------------------------------------------------------------
|
|
268
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Blob de la forma CORTA (`t`): cabecera(1) ‖ token de conexión(4 ASCII) ‖ sn(8)
|
|
286
|
+
* ‖ [len+proxy].
|
|
287
|
+
* bits 0-2 versión · bit 3 modo (join/adopt) · bit 4 lleva proxy propio
|
|
288
|
+
*
|
|
289
|
+
* Sin llave y sin nombre de cuenta: eso llega en la respuesta de la bóveda, así que
|
|
290
|
+
* un nombre largo ya no agranda el QR. 13 bytes → 18 caracteres.
|
|
291
|
+
*
|
|
292
|
+
* El PROXY sí viaja cuando no es el del ecosistema, y no es opcional: el token de
|
|
293
|
+
* conexión solo tiene sentido **en el proxy donde se emitió**. Quitarlo hacía que un
|
|
294
|
+
* aparato se conectara al proxy público y le hablara a un token de otro servidor —
|
|
295
|
+
* el mensaje no llegaba a ninguna parte y el emparejamiento se quedaba esperando.
|
|
296
|
+
* Le pasa a cualquiera con proxy propio (y lo cazó el E2E de secretos, que levanta
|
|
297
|
+
* uno local).
|
|
298
|
+
*/
|
|
299
|
+
// La CITA del proxio: 2 caracteres de prefijo de nodo + 4 = 6. Antes eran 4 (la
|
|
300
|
+
// dirección de la conexión). Cambió porque la dirección pasó a ser una instancia
|
|
301
|
+
// de 24 caracteres, y lo que se imprime en un QR ahora es una cita de un solo uso.
|
|
302
|
+
const CONN_TOKEN_LEN = 6
|
|
303
|
+
|
|
304
|
+
function shortEncode (qr) {
|
|
305
|
+
if (!qr || qr.v !== 2) return null
|
|
306
|
+
const mode = MODES.indexOf(qr.m)
|
|
307
|
+
if (mode < 0) return null
|
|
308
|
+
const conn = String(qr.conn || '')
|
|
309
|
+
if (conn.length !== CONN_TOKEN_LEN || !/^[\x21-\x7e]+$/.test(conn)) return null
|
|
310
|
+
if (!/^[0-9a-f]{16}$/.test(qr.sn || '')) return null // 8 bytes
|
|
311
|
+
const known = new Set(['v', 'conn', 'sn', 'm', 'proxy'])
|
|
312
|
+
if (Object.keys(qr).some((k) => !known.has(k))) return null
|
|
313
|
+
const ownProxy = qr.proxy && qr.proxy !== DEFAULT_PROXY
|
|
314
|
+
const proxy = ownProxy ? utf8(String(qr.proxy)) : null
|
|
315
|
+
if (proxy && proxy.length > 255) return null
|
|
316
|
+
const out = [qr.v | (mode << 3) | (ownProxy ? 0x10 : 0), ...[...conn].map((c) => c.charCodeAt(0)), ...hexToBytes(qr.sn)]
|
|
317
|
+
if (proxy) out.push(proxy.length, ...proxy)
|
|
318
|
+
return bytesToB64url(Uint8Array.from(out))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function shortDecode (text) {
|
|
322
|
+
const b = b64urlToBytes(text)
|
|
323
|
+
if (!b || b.length < 1 + CONN_TOKEN_LEN + 8) return null
|
|
324
|
+
const head = b[0]
|
|
325
|
+
if (head & 0xe0) return null
|
|
326
|
+
let i = 1 + CONN_TOKEN_LEN + 8
|
|
327
|
+
let proxy = DEFAULT_PROXY
|
|
328
|
+
if (head & 0x10) {
|
|
329
|
+
if (i >= b.length) return null
|
|
330
|
+
const n = b[i]; i += 1
|
|
331
|
+
if (i + n !== b.length) return null
|
|
332
|
+
proxy = fromUtf8(b.subarray(i, i + n)); i += n
|
|
333
|
+
} else if (i !== b.length) return null
|
|
334
|
+
const conn = String.fromCharCode(...b.subarray(1, 1 + CONN_TOKEN_LEN))
|
|
335
|
+
return { v: head & 7, conn, sn: bytesToHex(b.subarray(1 + CONN_TOKEN_LEN, 1 + CONN_TOKEN_LEN + 8)), m: MODES[(head >> 3) & 1], proxy }
|
|
336
|
+
}
|
|
337
|
+
|
|
269
338
|
/** Comparación por contenido, sin depender del orden de las claves. */
|
|
270
339
|
const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))
|
|
271
340
|
|
|
@@ -278,6 +347,11 @@ const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))
|
|
|
278
347
|
*/
|
|
279
348
|
export function encodeInvite (qr, fmt = FMT_COMPACT) {
|
|
280
349
|
const json = JSON.stringify(qr)
|
|
350
|
+
// La forma corta se emite cuando el QR trae dirección en vez de llave.
|
|
351
|
+
if (qr && qr.conn) {
|
|
352
|
+
const t = shortEncode(qr)
|
|
353
|
+
if (t) { const back = shortDecode(t); if (back && canon(back) === canon(qr)) return FMT_SHORT + t }
|
|
354
|
+
}
|
|
281
355
|
if (fmt === FMT_JSON) return FMT_JSON + json
|
|
282
356
|
if (fmt === FMT_COMPACT) {
|
|
283
357
|
const c = compactEncode(qr)
|
|
@@ -320,6 +394,7 @@ export function parseInvite (text) {
|
|
|
320
394
|
|
|
321
395
|
const marca = payload[0]
|
|
322
396
|
const resto = payload.slice(1)
|
|
397
|
+
if (marca === FMT_SHORT) { const o = shortDecode(resto); if (o) return o }
|
|
323
398
|
if (marca === FMT_COMPACT) { const o = compactDecode(resto); if (o) return o }
|
|
324
399
|
if (marca === FMT_JSON) { const o = parse(undoUrl(resto)) || parse(resto); if (o) return o }
|
|
325
400
|
if (marca === FMT_B64) { const s = b64urlDecodeStr(resto); const o = s && parse(s); if (o) return o }
|
|
@@ -331,4 +406,4 @@ export function parseInvite (text) {
|
|
|
331
406
|
return s ? parse(s) : null
|
|
332
407
|
}
|
|
333
408
|
|
|
334
|
-
export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, PAIR_URL, DEFAULT_PROXY }
|
|
409
|
+
export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, FMT_SHORT, PAIR_URL, DEFAULT_PROXY }
|
package/src/protocol.js
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* pineada (cierra el wipe-DoS; un ERROR plano jamas borra).
|
|
18
18
|
*/
|
|
19
19
|
export const MSG = Object.freeze({
|
|
20
|
+
// La invitación corta no lleva la llave: el aparato la pide presentando el `sn` de
|
|
21
|
+
// la sesión. Una pública es pública — esto no la esconde, solo evita abrirle la
|
|
22
|
+
// puerta a quien acertó el token de conexión a ciegas.
|
|
23
|
+
HELLO: 'vault.hello', // dispositivo → vault: { sn }
|
|
24
|
+
HELLO_OK: 'vault.hello.ok', // vault → dispositivo: { iss, acct }
|
|
20
25
|
ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
|
|
21
26
|
ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
|
|
22
27
|
ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
|
package/src/service.js
CHANGED
|
@@ -60,6 +60,42 @@ function installNodeGlobals () {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* La respuesta al `hello` va firmada y con el `sn` DENTRO de lo firmado. Comprobarlo
|
|
66
|
+
* ata la respuesta a ESTA sesión: no vale la de otro emparejamiento ni la de otra
|
|
67
|
+
* bóveda. Ojo con lo que NO prueba: cualquiera puede firmar con una llave suya, así
|
|
68
|
+
* que esto no dice que sea TU bóveda — eso lo dice el código de 6 dígitos, que solo
|
|
69
|
+
* aprende la bóveda donde tú lo tecleas.
|
|
70
|
+
*/
|
|
71
|
+
async function verificarHola (p, sn) {
|
|
72
|
+
const b = p?.body
|
|
73
|
+
if (!b?.iss || b.sn !== sn) throw new Error('la bóveda contestó a otro emparejamiento')
|
|
74
|
+
if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
|
|
75
|
+
throw new Error('la respuesta de la bóveda no está bien firmada')
|
|
76
|
+
}
|
|
77
|
+
return b
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Canjea la cita del QR y devuelve la instancia a la que apunta.
|
|
82
|
+
*
|
|
83
|
+
* Una cita se quema al usarse y caduca en minutos, así que un error acá casi
|
|
84
|
+
* siempre significa lo mismo para quien lo lee: el código ya se usó o venció, y
|
|
85
|
+
* hay que pedir otro en la bóveda. Se dice así, no con el error crudo.
|
|
86
|
+
*/
|
|
87
|
+
async function resolverCita (client, code) {
|
|
88
|
+
if (!code) throw new Error('la invitación no trae código de emparejamiento')
|
|
89
|
+
if (typeof client.redeemPairingCode !== 'function') {
|
|
90
|
+
throw new Error('el proxio no soporta códigos de emparejamiento (actualizá @dotrino/proxy-client)')
|
|
91
|
+
}
|
|
92
|
+
const r = await client.redeemPairingCode(code)
|
|
93
|
+
if (!r?.ok || !r.instance) {
|
|
94
|
+
throw new Error(`ese código no sirve: ${r?.error || 'no válido'}. Pedí uno nuevo en la bóveda.`)
|
|
95
|
+
}
|
|
96
|
+
return r.instance
|
|
97
|
+
}
|
|
98
|
+
|
|
63
99
|
async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
|
|
64
100
|
installNodeGlobals()
|
|
65
101
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
@@ -132,12 +168,30 @@ function writeServiceIdentity (dir, obj) {
|
|
|
132
168
|
*/
|
|
133
169
|
export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
|
|
134
170
|
if (typeof qr === 'string') { try { qr = JSON.parse(qr) } catch (_) { throw new Error('qr inválido: no es JSON') } }
|
|
135
|
-
if (!qr?.
|
|
171
|
+
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
|
|
136
172
|
if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
|
|
137
173
|
if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
|
|
138
174
|
label = label || 'servicio:' + ns
|
|
139
175
|
|
|
140
|
-
const client = await freshClient(qr.proxy)
|
|
176
|
+
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
177
|
+
// QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
|
|
178
|
+
if (!qr.iss) {
|
|
179
|
+
// `qr.conn` es una CITA (código de 6 caracteres, un solo uso): hay que
|
|
180
|
+
// canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
|
|
181
|
+
// que la emitió —lo dice el prefijo del propio código—, así que funciona
|
|
182
|
+
// aunque la bóveda esté en otro proxio de la malla.
|
|
183
|
+
const destino = await resolverCita(client, qr.conn)
|
|
184
|
+
const hola = await new Promise((resolve, reject) => {
|
|
185
|
+
const off = client.on('message', (_f, p) => {
|
|
186
|
+
if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then(resolve, reject) }
|
|
187
|
+
else if (p?.type === MSG.ERROR) { fin(); reject(new Error(p.error)) }
|
|
188
|
+
})
|
|
189
|
+
const t = setTimeout(() => { fin(); reject(new Error('la bóveda no contestó: ese código pudo caducar')) }, 15000)
|
|
190
|
+
const fin = () => { off(); clearTimeout(t) }
|
|
191
|
+
try { client.send(destino, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
|
|
192
|
+
})
|
|
193
|
+
qr = { ...qr, iss: hola.iss, proxy: hola.proxy || qr.proxy }
|
|
194
|
+
}
|
|
141
195
|
try {
|
|
142
196
|
const device = await makeDeviceKey({ label })
|
|
143
197
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
@@ -147,7 +201,7 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
|
|
|
147
201
|
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
148
202
|
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
149
203
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
150
|
-
const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
|
|
204
|
+
const data = { op: 'enroll', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
151
205
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
152
206
|
|
|
153
207
|
const enrolled = new Promise((resolve, reject) => {
|