@dotrino/identity 0.114.0 → 0.115.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 +4 -1
- package/src/index.js +9 -1
- package/vault/acta.js +5 -1
- package/vault/core.js +35 -10
- package/vault/machine.js +95 -0
- package/vault/remote.js +9 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.115.0",
|
|
4
4
|
"description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
},
|
|
23
23
|
"./vault/core.js": "./vault/core.js",
|
|
24
24
|
"./vault/remote.js": "./vault/remote.js",
|
|
25
|
+
"./machine": {
|
|
26
|
+
"import": "./vault/machine.js"
|
|
27
|
+
},
|
|
25
28
|
"./acta": {
|
|
26
29
|
"import": "./vault/acta.js"
|
|
27
30
|
},
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* use this library share identity even across different origins.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { browserMachine } from '../vault/machine.js'
|
|
11
|
+
|
|
10
12
|
const DEFAULT_VAULT_URL = 'https://id.dotrino.com/'
|
|
11
13
|
|
|
12
14
|
let singleton = null
|
|
@@ -125,6 +127,9 @@ export class Identity {
|
|
|
125
127
|
this._me = msg.me || null
|
|
126
128
|
this._locked = !!msg.locked
|
|
127
129
|
this._readyResolve(this)
|
|
130
|
+
// Dónde corre esta página (`vault/machine.js`), para los pedidos a la bóveda. Un
|
|
131
|
+
// iframe viejo no conoce el método: no pasa nada, solo no se manda.
|
|
132
|
+
this._call('setMachine', { machine: browserMachine() }).catch(() => {})
|
|
128
133
|
return
|
|
129
134
|
}
|
|
130
135
|
|
|
@@ -427,7 +432,9 @@ export class Identity {
|
|
|
427
432
|
* @returns {Promise<{ ok:boolean, deviceId:string, master:string, exp:number, scope:string[] }>}
|
|
428
433
|
*/
|
|
429
434
|
async enrollDevice (qr, { label = '', join = 'current' } = {}) {
|
|
430
|
-
|
|
435
|
+
// DÓNDE CORRE se mira AQUÍ, en la página, y no dentro del iframe: el iframe no ve si la
|
|
436
|
+
// página es una extensión o la app nativa (`vault/machine.js`).
|
|
437
|
+
return this._call('vaultPair', { qr, label, join, machine: browserMachine() }, 200000)
|
|
431
438
|
}
|
|
432
439
|
|
|
433
440
|
/** Estado de emparejamiento: { paired, deviceId?, master?, scope?, exp?, pairedAt? }. */
|
|
@@ -853,4 +860,5 @@ export { makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, ve
|
|
|
853
860
|
// PARA QUIÉN vale una firma, y hasta cuándo. Verificar NO necesita el iframe ni la clave
|
|
854
861
|
// de nadie, así que un servicio puede importarlo suelto (`@dotrino/identity/assertion`, que
|
|
855
862
|
// no arrastra el cliente del vault); aquí se reexporta para quien ya tiene esto cargado.
|
|
863
|
+
export { cleanMachine, machineLine, browserMachine, MACHINE_KINDS } from '../vault/machine.js'
|
|
856
864
|
export { verifyAssertion, verifySignedFor, newAssertionNonce, cleanScopes, claimsAllowed, assertionBody, SCOPES, SCOPE_CLAIMS, ASSERTION_MAX_TTL_MS, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_SKEW_MS } from '../vault/assertion.js'
|
package/vault/acta.js
CHANGED
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { canonicalStringify } from './core.js'
|
|
38
38
|
import { signWithDevice, verifyDeviceSig, verifyDelegation, pubkeyId } from './capabilities.js'
|
|
39
|
+
import { cleanMachine } from './machine.js'
|
|
40
|
+
export { cleanMachine }
|
|
39
41
|
|
|
40
42
|
export const ACTA_V = 5
|
|
41
43
|
|
|
@@ -816,7 +818,9 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
|
|
|
816
818
|
addedAt: now,
|
|
817
819
|
cert: m.cert || null,
|
|
818
820
|
// Puente con la identidad que este miembro traía de antes (ver makeContinuity).
|
|
819
|
-
...(m.continuity ? { continuity: m.continuity } : {})
|
|
821
|
+
...(m.continuity ? { continuity: m.continuity } : {}),
|
|
822
|
+
// DÓNDE CORRE (`machine.js`): lo que dijo al emparejar, para reconocerlo en la lista.
|
|
823
|
+
...(cleanMachine(m.machine) ? { machine: cleanMachine(m.machine) } : {})
|
|
820
824
|
})
|
|
821
825
|
break
|
|
822
826
|
}
|
package/vault/core.js
CHANGED
|
@@ -64,8 +64,12 @@ const PILAR_VAULT = '@dotrino/vault'
|
|
|
64
64
|
|
|
65
65
|
export function canonicalStringify (v) {
|
|
66
66
|
if (v === null || typeof v !== 'object') return JSON.stringify(v)
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Un array con `undefined` va como `null`, igual que en JSON.stringify.
|
|
68
|
+
if (Array.isArray(v)) return '[' + v.map((x) => x === undefined ? 'null' : canonicalStringify(x)).join(',') + ']'
|
|
69
|
+
// Una clave con `undefined` NO existe, igual que en JSON.stringify: antes salía como
|
|
70
|
+
// `"k":undefined` (ni siquiera es JSON) y lo firmado en memoria no cuadraba con lo mismo
|
|
71
|
+
// leído del disco o de la red (el directorio de nombres, 2026-10-15).
|
|
72
|
+
const ks = Object.keys(v).filter((k) => v[k] !== undefined).sort()
|
|
69
73
|
return '{' + ks.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k])).join(',') + '}'
|
|
70
74
|
}
|
|
71
75
|
|
|
@@ -787,7 +791,16 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
787
791
|
const encKeyId = async (encPub) => (await pubkeyIdOf(encPub)).slice(0, 16)
|
|
788
792
|
|
|
789
793
|
const loadVaultCert = () => { try { return JSON.parse(kv.getItem(VAULT_CERT_STORAGE) || 'null') } catch (_) { return null } }
|
|
794
|
+
// DÓNDE CORRE ESTE APARATO (`machine.js`). Lo dice la PÁGINA al conectar (`setMachine`),
|
|
795
|
+
// porque el iframe no ve si está dentro de una extensión o de la app nativa, y viaja con
|
|
796
|
+
// cada pedido firmado a la bóveda para que su lista lo enseñe al día. Sin página que lo
|
|
797
|
+
// diga (Node, un cliente viejo) no se manda nada: mejor ningún dato que uno equivocado.
|
|
798
|
+
let pageMachine = null
|
|
790
799
|
const loadVaultDevice = () => {
|
|
800
|
+
const d = loadVaultDeviceKey()
|
|
801
|
+
return d && pageMachine ? { ...d, machine: pageMachine } : d
|
|
802
|
+
}
|
|
803
|
+
const loadVaultDeviceKey = () => {
|
|
791
804
|
try {
|
|
792
805
|
const d = JSON.parse(kv.getItem(VAULT_DEVICE_STORAGE) || 'null')
|
|
793
806
|
if (!d) return null
|
|
@@ -1289,7 +1302,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1289
1302
|
* cert contra la bóveda y entra a su cuenta. Aparte para que la decisión de CUÁL cuenta y
|
|
1290
1303
|
* el deshacerla si esto falla vivan juntos arriba, y aquí solo quede el trámite.
|
|
1291
1304
|
*/
|
|
1292
|
-
async function pairWithVault ({ qr, label = '' }) {
|
|
1305
|
+
async function pairWithVault ({ qr, label = '', machine = undefined }) {
|
|
1293
1306
|
// Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
|
|
1294
1307
|
// TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
|
|
1295
1308
|
// La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
|
|
@@ -1303,7 +1316,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1303
1316
|
const continuity = (mine && mine.members.length === 1 && !isPendingJoin())
|
|
1304
1317
|
? await Acta.makeContinuity({ member: publickeyJwkStr, from: mine.profileId, privateKey: masterKey() })
|
|
1305
1318
|
: null
|
|
1306
|
-
const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
1319
|
+
const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', machine: machine === undefined && pageMachine ? pageMachine : machine, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
1307
1320
|
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
1308
1321
|
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
1309
1322
|
// Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
|
|
@@ -2556,6 +2569,8 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2556
2569
|
isService: !!m.cn,
|
|
2557
2570
|
caps: Acta.effectiveCaps(acta, m.pub, pend),
|
|
2558
2571
|
addedAt: m.addedAt || null,
|
|
2572
|
+
// Dónde corre, según dijo al emparejarse (`machine.js`). `null` si entró antes.
|
|
2573
|
+
machine: m.machine || null,
|
|
2559
2574
|
isMe: m.pub === publickeyJwkStr,
|
|
2560
2575
|
isMaster: Acta.canSeal(acta, m.pub),
|
|
2561
2576
|
// La llave de CIFRADO del miembro y, derivado, si se le puede envolver algo.
|
|
@@ -2601,7 +2616,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2601
2616
|
* Admite un miembro (solo el master). El cert lo emite quien llama, antes o después.
|
|
2602
2617
|
* `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).
|
|
2603
2618
|
*/
|
|
2604
|
-
async admitMember ({ pub, encPub = null, label = '', cn = null, caps = null, cert = null, continuity = null } = {}) {
|
|
2619
|
+
async admitMember ({ pub, encPub = null, label = '', cn = null, caps = null, cert = null, continuity = null, machine = null } = {}) {
|
|
2605
2620
|
// `cn`: si viene, este miembro es un SERVICIO: abre el cajón de secretos de ESE nombre
|
|
2606
2621
|
// por llevarlo, sin permiso aparte, y no ve nada más del usuario. Sin `cn` es un dispositivo.
|
|
2607
2622
|
const finales = caps || (cn ? [] : ['store', 'read'])
|
|
@@ -2618,7 +2633,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2618
2633
|
const mine = encPub ? await myCek() : null
|
|
2619
2634
|
if (mine) wrap = { op: 'wrap', gen: mine.gen, pub, wrap: await Content.wrapForMember({ cek: mine.cek, memberEncPub: encPub }) }
|
|
2620
2635
|
} catch (_) { wrap = null }
|
|
2621
|
-
const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity } }, ...(wrap ? [wrap] : [])])
|
|
2636
|
+
const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity, machine } }, ...(wrap ? [wrap] : [])])
|
|
2622
2637
|
return { ok: true, seq: acta.seq, wrapped: !!wrap }
|
|
2623
2638
|
},
|
|
2624
2639
|
|
|
@@ -2894,8 +2909,8 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2894
2909
|
* master: si esta cuenta ya la manda otra bóveda, no hay nada que
|
|
2895
2910
|
* regalar y falla en voz alta.
|
|
2896
2911
|
*/
|
|
2897
|
-
async vaultPair ({ qr, label = '', join = 'current' }) {
|
|
2898
|
-
if (join === 'adopt') return handlers.vaultAdopt({ qr, label })
|
|
2912
|
+
async vaultPair ({ qr, label = '', join = 'current', machine = undefined }) {
|
|
2913
|
+
if (join === 'adopt') return handlers.vaultAdopt({ qr, label, machine })
|
|
2899
2914
|
// La cuenta abierta AQUÍ y la que se cree para este intento. `born` es la que hay que
|
|
2900
2915
|
// tirar si el emparejamiento no llega a término: nació para él y no tiene nada dentro.
|
|
2901
2916
|
const from = currentPid
|
|
@@ -2925,7 +2940,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2925
2940
|
}
|
|
2926
2941
|
}
|
|
2927
2942
|
try {
|
|
2928
|
-
return await pairWithVault({ qr, label })
|
|
2943
|
+
return await pairWithVault({ qr, label, machine })
|
|
2929
2944
|
} catch (e) {
|
|
2930
2945
|
// El intento falló (código vencido, la bóveda dijo que no, se agotó la espera): la
|
|
2931
2946
|
// cuenta que nació para él se va con él. Si no, cada reintento dejaba una cuenta
|
|
@@ -2949,7 +2964,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2949
2964
|
* bóveda, este aparato no puede regalar lo que no tiene; el traspaso se hace desde la
|
|
2950
2965
|
* que manda hoy.
|
|
2951
2966
|
*/
|
|
2952
|
-
async vaultAdopt ({ qr, label = '' } = {}) {
|
|
2967
|
+
async vaultAdopt ({ qr, label = '', machine = undefined } = {}) {
|
|
2953
2968
|
const mine = loadActa()
|
|
2954
2969
|
if (!mine) throw new Error('this device has no account to hand over yet')
|
|
2955
2970
|
if (!amMaster()) throw new Error('not-the-master: another device or vault is in charge of this account; the handover is done from there')
|
|
@@ -2959,6 +2974,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2959
2974
|
qr,
|
|
2960
2975
|
device,
|
|
2961
2976
|
intent: 'adopt',
|
|
2977
|
+
machine,
|
|
2962
2978
|
profileId: mine.profileId,
|
|
2963
2979
|
encPub: encPublickeyJwkStr,
|
|
2964
2980
|
label: label || me?.nickname || '',
|
|
@@ -3005,6 +3021,15 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
3005
3021
|
return { ok: true, pending: true }
|
|
3006
3022
|
},
|
|
3007
3023
|
|
|
3024
|
+
/**
|
|
3025
|
+
* La página dice dónde corre (`machine.js`); se manda con los pedidos a la bóveda.
|
|
3026
|
+
* @param {any} [args]
|
|
3027
|
+
*/
|
|
3028
|
+
async setMachine ({ machine } = {}) {
|
|
3029
|
+
pageMachine = Acta.cleanMachine(machine)
|
|
3030
|
+
return { ok: true }
|
|
3031
|
+
},
|
|
3032
|
+
|
|
3008
3033
|
async vaultStatus () {
|
|
3009
3034
|
const v = loadVaultCert()
|
|
3010
3035
|
if (!v?.cert) return { paired: false }
|
package/vault/machine.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUÉ MÁQUINA ES CADA APARATO: dónde corre, y nada más.
|
|
3
|
+
*
|
|
4
|
+
* En la lista de aparatos de la bóveda solo había un ID (`AB12-CD34`), una etiqueta que pone
|
|
5
|
+
* el propio aparato —muchas veces el apodo del usuario, igual en todos— y la fecha de alta.
|
|
6
|
+
* Con tres navegadores y dos agentes no había forma de saber cuál era cuál (dueño,
|
|
7
|
+
* 2026-10-15). Esto es lo que lo distingue a simple vista:
|
|
8
|
+
*
|
|
9
|
+
* · `kind` — qué es: `node` (un agente o un comando), `browser`, `extension`, `ios`, `android`.
|
|
10
|
+
* · `os` — el sistema donde corre (`Linux 7.0.0`, `Windows`, `macOS`, `Android 14`).
|
|
11
|
+
* · `name` — en Node, el hostname; en un navegador, el navegador (`Chrome`). Un navegador no
|
|
12
|
+
* sabe el nombre de la máquina, y lo que no se sabe no se inventa.
|
|
13
|
+
*
|
|
14
|
+
* Lo DICE el aparato y va dentro de lo que firma, así que nadie más puede escribirlo por él.
|
|
15
|
+
* Tampoco es una prueba de nada: un aparato puede llamarse como quiera. Sirve para reconocerlo,
|
|
16
|
+
* no para decidir — ninguna puerta mira esto.
|
|
17
|
+
*
|
|
18
|
+
* Una sola pieza para las dos puntas: lo limpia igual quien lo manda y quien lo guarda (el acta,
|
|
19
|
+
* la bóveda), para que un campo de más o una cadena de un megabyte no entren por ningún lado.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const MACHINE_KINDS = ['node', 'browser', 'extension', 'ios', 'android']
|
|
23
|
+
|
|
24
|
+
const clip = (s, n) => (typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, n) : '')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lo deja en la forma de siempre, o `null` si no dice nada que valga. Nunca lanza: un aparato
|
|
28
|
+
* viejo o raro no puede tumbar un emparejamiento por cómo describe su máquina.
|
|
29
|
+
* @param {any} raw
|
|
30
|
+
* @returns {{ kind: string, os: string, name: string } | null}
|
|
31
|
+
*/
|
|
32
|
+
export function cleanMachine (raw) {
|
|
33
|
+
if (!raw || typeof raw !== 'object') return null
|
|
34
|
+
const kind = MACHINE_KINDS.includes(raw.kind) ? raw.kind : ''
|
|
35
|
+
const os = clip(raw.os, 40)
|
|
36
|
+
const name = clip(raw.name, 64)
|
|
37
|
+
if (!kind && !os && !name) return null
|
|
38
|
+
return { kind, os, name }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Una línea para enseñarlo: `node · Linux 7.0.0 · mi-pc`. Vacía si no hay nada. */
|
|
42
|
+
export function machineLine (m) {
|
|
43
|
+
const c = cleanMachine(m)
|
|
44
|
+
return c ? [c.kind, c.os, c.name].filter(Boolean).join(' · ') : ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** ¿Dicen lo mismo? Para no reescribir un archivo cada vez que un aparato repite lo de siempre. */
|
|
48
|
+
export const sameMachine = (a, b) => machineLine(a) === machineLine(b)
|
|
49
|
+
|
|
50
|
+
/** @type {Array<[RegExp, (v: string) => string]>} */
|
|
51
|
+
const UA_OS = [
|
|
52
|
+
[/Android ([\d.]+)/, (v) => 'Android ' + v.split('.')[0]],
|
|
53
|
+
[/(?:iPhone|iPad|iPod).*? OS (\d+)/, (v) => 'iOS ' + v],
|
|
54
|
+
[/Windows NT/, () => 'Windows'],
|
|
55
|
+
[/CrOS/, () => 'ChromeOS'],
|
|
56
|
+
[/Mac OS X/, () => 'macOS'],
|
|
57
|
+
[/Linux/, () => 'Linux']
|
|
58
|
+
]
|
|
59
|
+
/** @type {Array<[RegExp, string]>} */
|
|
60
|
+
const UA_BROWSER = [
|
|
61
|
+
[/Edg\//, 'Edge'], [/OPR\//, 'Opera'], [/Firefox\//, 'Firefox'], [/SamsungBrowser\//, 'Samsung Internet'],
|
|
62
|
+
[/Chrome\//, 'Chrome'], [/Safari\//, 'Safari']
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* La máquina de ESTE navegador. `env` se inyecta en las pruebas; en la página es `globalThis`.
|
|
67
|
+
* Lo nativo se reconoce por el puente de `dotrino-native` (`DotrinoNative.platform()`), la
|
|
68
|
+
* extensión por tener `chrome.runtime.id`, y el resto es un navegador.
|
|
69
|
+
* @param {any} [env]
|
|
70
|
+
*/
|
|
71
|
+
export function browserMachine (env = globalThis) {
|
|
72
|
+
/** @type {any} */
|
|
73
|
+
const nav = env?.navigator || {}
|
|
74
|
+
const ua = String(nav.userAgent || '')
|
|
75
|
+
// Node también tiene `navigator` (`Node.js/22`): no es un navegador, y su máquina la dice
|
|
76
|
+
// quien sabe mirarla (`selfMachine` de `@dotrino/vault`). Aquí no se inventa.
|
|
77
|
+
if (env?.process?.versions?.node || !ua || ua.startsWith('Node.js')) return null
|
|
78
|
+
let kind = 'browser'
|
|
79
|
+
try {
|
|
80
|
+
const p = env?.DotrinoNative?.platform?.()
|
|
81
|
+
if (p === 'ios' || p === 'android') kind = p
|
|
82
|
+
else if (env?.chrome?.runtime?.id) kind = 'extension'
|
|
83
|
+
} catch (_) { /* un puente roto no cambia qué navegador es: se queda en `browser` */ }
|
|
84
|
+
let os = ''
|
|
85
|
+
for (const [re, fmt] of UA_OS) { const m = ua.match(re); if (m) { os = fmt(m[1] || ''); break } }
|
|
86
|
+
// iPadOS se presenta como un Mac de escritorio: lo delata la pantalla táctil.
|
|
87
|
+
if (os === 'macOS' && nav.maxTouchPoints > 1) os = 'iPadOS'
|
|
88
|
+
let name = ''
|
|
89
|
+
if (kind === 'browser' || kind === 'extension') {
|
|
90
|
+
for (const [re, n] of UA_BROWSER) if (re.test(ua)) { name = n; break }
|
|
91
|
+
}
|
|
92
|
+
return cleanMachine({ kind, os, name })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default { MACHINE_KINDS, cleanMachine, machineLine, sameMachine, browserMachine }
|
package/vault/remote.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { makeDeviceKey, signWithDevice, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
|
|
17
17
|
import { checkVaultReply } from './acta.js'
|
|
18
|
+
import { cleanMachine, browserMachine } from './machine.js'
|
|
18
19
|
|
|
19
20
|
const MSG = {
|
|
20
21
|
HELLO: 'vault.hello',
|
|
@@ -130,7 +131,7 @@ async function askVault (client, qr) {
|
|
|
130
131
|
* @param {number} [opts.approveTimeoutMs] Espera de la aprobación humana (def 3 min).
|
|
131
132
|
* @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string, account:string}>}
|
|
132
133
|
*/
|
|
133
|
-
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null } = {}) {
|
|
134
|
+
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null, machine = undefined } = {}) {
|
|
134
135
|
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing vault or nonce')
|
|
135
136
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
136
137
|
const client = new WebSocketProxyClient({ url: qr.proxy || 'wss://proxy.dotrino.com', enableWebRTC: false, autoReconnect: false })
|
|
@@ -162,12 +163,16 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
162
163
|
// rechaza el que no coincida con el modo con el que ella abrió el emparejamiento. Así
|
|
163
164
|
// ninguno de los dos puede hacer, a mitad de camino, algo distinto de lo que el humano
|
|
164
165
|
// vio anunciado en las dos pantallas.
|
|
166
|
+
// DÓNDE CORRE (`machine.js`), para que la bóveda lo reconozca en su lista. Quien llama
|
|
167
|
+
// puede pasarlo (lo sabe mejor); si no, se mira el navegador. `null` es no decir nada.
|
|
168
|
+
const maquina = cleanMachine(machine === undefined ? browserMachine() : machine)
|
|
165
169
|
const adopting = intent === 'adopt'
|
|
166
170
|
if (adopting && typeof onAdopt !== 'function') throw new Error('enrollDevice(adopt): missing onAdopt')
|
|
167
171
|
const data = {
|
|
168
172
|
op: 'enroll', dpub: dev.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now(), intent,
|
|
169
173
|
...(adopting && profileId ? { profileId } : {}),
|
|
170
|
-
...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {})
|
|
174
|
+
...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {}),
|
|
175
|
+
...(maquina ? { machine: maquina } : {})
|
|
171
176
|
}
|
|
172
177
|
// `dev.sign`: la llave vive fuera (el chip del teléfono) y solo se le pide la firma.
|
|
173
178
|
const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, sign: dev.sign, data })
|
|
@@ -283,7 +288,7 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
|
|
|
283
288
|
await client.connect()
|
|
284
289
|
try {
|
|
285
290
|
try { await identifyAsDevice(client, device, { cert, acta }) } catch (_) { /* sin identify seguimos: solo perdemos la cola */ }
|
|
286
|
-
const signed = { ...data, publickey: device.publickey, ts: Date.now() }
|
|
291
|
+
const signed = { ...data, publickey: device.publickey, ts: Date.now(), ...(cleanMachine(device.machine) ? { machine: cleanMachine(device.machine) } : {}) }
|
|
287
292
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data: signed })
|
|
288
293
|
const pending = new Promise((resolve, reject) => {
|
|
289
294
|
let graceTimer = null
|
|
@@ -338,7 +343,7 @@ export async function checkMembership ({ master, proxy, device, onRevoked, timeo
|
|
|
338
343
|
// Identificarse hace además que el proxy entregue lo ENCOLADO (un aviso de cuando
|
|
339
344
|
// estaba apagado, si todavía está dentro de las 24 h).
|
|
340
345
|
try { await identifyAsDevice(client, device) } catch (_) {}
|
|
341
|
-
const data = { op: 'check', publickey: device.publickey, ts: Date.now() }
|
|
346
|
+
const data = { op: 'check', publickey: device.publickey, ts: Date.now(), ...(cleanMachine(device.machine) ? { machine: cleanMachine(device.machine) } : {}) }
|
|
342
347
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data })
|
|
343
348
|
const res = await new Promise((resolve) => {
|
|
344
349
|
let settled = false
|