@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.114.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
- return this._call('vaultPair', { qr, label, join }, 200000)
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
- if (Array.isArray(v)) return '[' + v.map(canonicalStringify).join(',') + ']'
68
- const ks = Object.keys(v).sort()
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 }
@@ -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