@dotrino/identity 0.102.1 → 0.103.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.102.1",
3
+ "version": "0.103.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",
@@ -52,6 +52,9 @@ export const LEGACY_CERTS_UNTIL = Date.parse('2026-10-01T00:00:00Z')
52
52
  const enc = (s) => new TextEncoder().encode(s)
53
53
 
54
54
  async function rawSign (privateKey, bytes) {
55
+ // Una llave EXTERNA (el chip del teléfono, `vault/vault.js`) firma ella misma: aquí no
56
+ // hay CryptoKey que pasarle a WebCrypto. Devuelve la misma firma P1363 en base64.
57
+ if (privateKey?.external) return privateKey.sign(bytes)
55
58
  return bufToBase64(await crypto.subtle.sign(SIGN, privateKey, bytes))
56
59
  }
57
60
  async function rawVerify (publicJwkStr, bytes, sigB64) {
package/vault/content.js CHANGED
@@ -37,8 +37,11 @@ const fromB64 = (str) => {
37
37
  }
38
38
 
39
39
  async function sharedKey (privateKey, peerPubJwkStr) {
40
- const pub = await subtle.importKey('jwk', JSON.parse(peerPubJwkStr), ECDH, false, [])
41
- const bits = await subtle.deriveBits({ name: 'ECDH', public: pub }, privateKey, 256)
40
+ // Llave EXTERNA: el acuerdo ECDH lo hace el chip y devuelve los 32 bytes del secreto
41
+ // (lo mismo que `deriveBits(…, 256)`); la privada no sale de allí.
42
+ const bits = privateKey?.external
43
+ ? await privateKey.deriveBits(peerPubJwkStr)
44
+ : await subtle.deriveBits({ name: 'ECDH', public: await subtle.importKey('jwk', JSON.parse(peerPubJwkStr), ECDH, false, []) }, privateKey, 256)
42
45
  return subtle.importKey('raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
43
46
  }
44
47
 
package/vault/core.js CHANGED
@@ -88,6 +88,13 @@ async function importPeerEncPubkey (jwkStr) {
88
88
  }
89
89
 
90
90
  async function deriveSharedAesKey (myPriv, peerPub) {
91
+ if (myPriv?.external) {
92
+ // Llave EXTERNA: el chip da los 32 bytes del secreto ECDH, que es exactamente lo que
93
+ // `deriveKey` ECDH→AES-256 usa por dentro. Mismo AES, sin que la privada salga.
94
+ const { kty, crv, x, y } = await crypto.subtle.exportKey('jwk', peerPub)
95
+ const bits = await myPriv.deriveBits(JSON.stringify({ kty, crv, x, y }))
96
+ return crypto.subtle.importKey('raw', bits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
97
+ }
91
98
  return crypto.subtle.deriveKey(
92
99
  { name: 'ECDH', public: peerPub },
93
100
  myPriv,
@@ -98,6 +105,7 @@ async function deriveSharedAesKey (myPriv, peerPub) {
98
105
  }
99
106
 
100
107
  async function signBytes (privateKey, bytes) {
108
+ if (privateKey?.external) return privateKey.sign(bytes) // el chip del teléfono (ver rawSign)
101
109
  const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, privateKey, bytes)
102
110
  return bufToBase64(sig)
103
111
  }
@@ -330,6 +338,13 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
330
338
  return { privateKey, publicKey: await importPub(publicJwk), publicJwk }
331
339
  } catch (_) {}
332
340
  }
341
+ // El ANFITRIÓN puede crear la llave fuera (dentro de la app de Android: en el chip del
342
+ // teléfono, `vault/vault.js`). Guarda él su registro y devuelve un asa externa. Sin
343
+ // `create` —el navegador y las ~30 apps— el camino es el de siempre.
344
+ if (keyStore.create) {
345
+ const made = await keyStore.create(kind, name)
346
+ return { privateKey: made.privateKey, publicKey: await importPub(made.publicJwk), publicJwk: made.publicJwk }
347
+ }
333
348
  const pair = await crypto.subtle.generateKey(algo, false, pairUses) // privada NO extractable
334
349
  const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
335
350
  await keyStore.set(name, { privateKey: pair.privateKey, publicJwk })
@@ -1282,6 +1297,18 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1282
1297
  // Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
1283
1298
  const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
1284
1299
  emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
1300
+ // LA LLAVE ES DEL CHIP DEL TELÉFONO (app de Android): la app guarda la cuenta para su
1301
+ // pantalla nativa de Pedidos, con el MISMO papel. Así el teléfono es un solo aparato en
1302
+ // el acta. Si la app no la guarda, se dice: el emparejamiento en sí ya está hecho.
1303
+ if (keypair?.privateKey?.external) {
1304
+ keypair.privateKey.paired({
1305
+ cert: res.cert, vault: res.master, proxy: res.proxy, deviceId: res.deviceId,
1306
+ name: res.account || res.acta?.name || '', profileId: res.acta?.profileId || null
1307
+ }).then(
1308
+ () => emitVault({ phase: 'native-account', ok: true }),
1309
+ (e) => { console.warn('[identity] the phone did not save the account: ' + e.message); emitVault({ phase: 'native-account', ok: false, code: e.code || 'native-error', reason: e.message }) }
1310
+ )
1311
+ }
1285
1312
  pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
1286
1313
  return { ok: true, deviceId: res.deviceId, master: res.master, seq: res.cert.seq, scope: res.cert.scope, join: unido }
1287
1314
  }
@@ -1336,6 +1363,8 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1336
1363
  // Un cert YA vencido o revocado no puede renovarse (ahí sí, re-emparejar).
1337
1364
  // Ya no hay ventana de caducidad: el papel no vence. Lo único que obliga a pedir uno
1338
1365
  // nuevo es que el ACTA diga algo distinto de lo que lleva escrito.
1366
+ /** Rechazos de la bóveda que se arreglan con un papel nuevo (el mismo juego que la app nativa). */
1367
+ const PAPER_REASONS = new Set(['scope', 'acta-vieja', 'untrusted-issuer', 'expired', 'legacy-cert-retirado', 'no-acta'])
1339
1368
  const RENEW_RETRY_MS = 60 * 60 * 1000 // si falla (vault apagado), no insistir >1 vez/hora
1340
1369
  let renewLastTry = 0
1341
1370
  /**
@@ -3007,8 +3036,19 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
3007
3036
  const v = loadVaultCert(); const device = loadVaultDevice()
3008
3037
  if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
3009
3038
  maybeRenewVaultCert()
3039
+ const ask = (cert) => remoteApproval({ master: v.master, proxy: v.proxy, device, cert, op, id, onRevoked: wipeVaultLink })
3010
3040
  try {
3011
- const r = await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op, id, onRevoked: wipeVaultLink })
3041
+ let r
3042
+ try { r = await ask(v.cert) } catch (e) {
3043
+ // PAPEL VIEJO: tras `caps <ID> +aprueba` el papel que tengo no trae `vault:approve`,
3044
+ // y si el aviso del acta nueva no llegó (o llegó y el freno de una hora ya había
3045
+ // gastado su intento) no se renovaba nunca: el aprobador se quedaba en
3046
+ // «unauthorized: scope» para siempre. Se renueva UNA vez y se reintenta; la bóveda
3047
+ // decide qué papel toca según SU acta. Un aparato revocado o sin el permiso no se
3048
+ // reintenta: renovar no lo arregla y esconder el motivo sería peor.
3049
+ if (e?.code !== 'unauthorized' || !PAPER_REASONS.has(e.reason)) throw e
3050
+ r = await ask(await renovarCert())
3051
+ }
3012
3052
  // El comando de cada pedido viene sellado a este aparato: se abre aquí, que es
3013
3053
  // donde está la llave, y sale ya legible.
3014
3054
  if (Array.isArray(r?.items)) return { ...r, items: await abrirContextos(r.items, currentPid) }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * LLAVES QUE NO VIVEN AQUÍ: el chip del teléfono, dentro de la app de Dotrino.
3
+ *
4
+ * En la app de Android el iframe corre en un WebView, y el teléfono ya tiene su llave de
5
+ * cada cuenta en el Keystore (la que aprueba en la pantalla nativa de Pedidos). Si el
6
+ * WebView se hiciera la suya, el teléfono serían DOS aparatos en el acta: uno con perfil y
7
+ * sin aprobar, otro aprobando y sin perfil (dueño, 2026-09-25: «es como si el perfil viviera
8
+ * solo en nativo y no en el webview»). Con esto hay UNA llave: el WebView firma y descifra
9
+ * con la del chip, y la privada nunca entra en esta página.
10
+ *
11
+ * Este módulo envuelve el `keyStore` de IndexedDB. Lo que guarda por llave es un registro
12
+ * sin nada secreto: `{ external: kid, kind, publicJwk }`. Al leerlo lo convierte en un ASA,
13
+ * que es lo que `core.js` trata como privada: `{ external: true, sign, deriveBits }`. Las
14
+ * cuatro funciones que usan una privada (`rawSign`, `signBytes`, `sharedKey`,
15
+ * `deriveSharedAesKey`) saben qué hacer con ella.
16
+ *
17
+ * `bridge.call(method, params)` habla con la app: `create`, `open`, `sign`, `deriveBits`,
18
+ * `save`, `remove`. La app solo le enseña el puente a `https://id.dotrino.com`.
19
+ */
20
+
21
+ const b64 = (buf) => {
22
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
23
+ let s = ''
24
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
25
+ return btoa(s)
26
+ }
27
+ const fromB64 = (str) => {
28
+ const bin = atob(str)
29
+ const out = new Uint8Array(bin.length)
30
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
31
+ return out
32
+ }
33
+
34
+ const SIGN_SUFFIX = '.keypair'
35
+ const ENC_SUFFIX = '.enc-keypair'
36
+
37
+ /** El nombre de la OTRA mitad del mismo perfil: firma y cifrado comparten llave en el chip. */
38
+ function siblingName (name) {
39
+ if (name.endsWith(ENC_SUFFIX)) return name.slice(0, -ENC_SUFFIX.length) + SIGN_SUFFIX
40
+ if (name.endsWith(SIGN_SUFFIX)) return name.slice(0, -SIGN_SUFFIX.length) + ENC_SUFFIX
41
+ return null
42
+ }
43
+
44
+ /** El asa de una llave del chip. Lo que la app no pueda hacer sale con su `code`, sin plan B. */
45
+ export function externalHandle (bridge, kid) {
46
+ return {
47
+ external: true,
48
+ kid,
49
+ async sign (bytes) {
50
+ const { signature } = await bridge.call('sign', { kid, data: b64(bytes) })
51
+ return signature
52
+ },
53
+ async deriveBits (peerPubJwkStr) {
54
+ const { bits } = await bridge.call('deriveBits', { kid, peer: peerPubJwkStr })
55
+ return fromB64(bits).buffer
56
+ },
57
+ /** Tras emparejar: la app guarda la cuenta para su pantalla de Pedidos. */
58
+ async paired (account) { return bridge.call('save', { kid, ...account }) }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Envuelve `keyStore` para que las llaves NUEVAS nazcan en el chip. Las que ya existían
64
+ * (CryptoKey de IndexedDB) siguen igual: una llave no extraíble no se puede mover.
65
+ */
66
+ export function withExternalKeys (keyStore, bridge) {
67
+ const toHandle = (rec) => (rec && rec.external
68
+ ? { privateKey: externalHandle(bridge, rec.external), publicJwk: rec.publicJwk }
69
+ : rec)
70
+ return {
71
+ async get (name) { return toHandle(await keyStore.get(name)) },
72
+ set: (name, pair) => keyStore.set(name, pair),
73
+ async remove (name) {
74
+ const rec = await keyStore.get(name)
75
+ await keyStore.remove(name)
76
+ // Se borra del chip cuando ya no queda ninguna de las dos mitades del perfil.
77
+ const sib = siblingName(name)
78
+ const other = sib ? await keyStore.get(sib) : null
79
+ if (rec?.external && !(other?.external === rec.external)) await bridge.call('remove', { kid: rec.external })
80
+ },
81
+ async create (kind, name) {
82
+ // Las dos mitades del perfil van bajo la MISMA llave del chip: si la otra ya nació
83
+ // allí, se reutiliza; si no, se crea una nueva.
84
+ const sib = siblingName(name)
85
+ const other = sib ? await keyStore.get(sib) : null
86
+ const k = other?.external
87
+ ? await bridge.call('open', { kid: other.external })
88
+ : await bridge.call('create', {})
89
+ const publicJwk = kind === 'enc' ? k.encPub : k.publickey
90
+ if (!k?.kid || !publicJwk) throw Object.assign(new Error('the phone did not return a key'), { code: 'native-no-key' })
91
+ const rec = { external: k.kid, kind, publicJwk: typeof publicJwk === 'string' ? JSON.parse(publicJwk) : publicJwk }
92
+ await keyStore.set(name, rec)
93
+ return { privateKey: externalHandle(bridge, k.kid), publicJwk: rec.publicJwk }
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * El puente de la app visto desde la página: `window.DotrinoIdentityKeys` (un
100
+ * `addWebMessageListener` de Android). Mensajes `{ id, method, params }` → `{ id, result }`
101
+ * o `{ id, error, code }`.
102
+ */
103
+ export function appBridge (port, { timeoutMs = 20000 } = {}) {
104
+ let seq = 0
105
+ const waiting = new Map()
106
+ port.onmessage = (ev) => {
107
+ let m
108
+ try { m = JSON.parse(ev.data) } catch (_) { return }
109
+ const w = waiting.get(m?.id)
110
+ if (!w) return
111
+ waiting.delete(m.id)
112
+ clearTimeout(w.t)
113
+ if (m.error) w.reject(Object.assign(new Error(m.error), { code: m.code || 'native-error' }))
114
+ else w.resolve(m.result)
115
+ }
116
+ return {
117
+ call (method, params = {}) {
118
+ const id = 'k' + (++seq)
119
+ return new Promise((resolve, reject) => {
120
+ const t = setTimeout(() => {
121
+ waiting.delete(id)
122
+ reject(Object.assign(new Error(`the phone did not answer ${method}`), { code: 'native-no-reply' }))
123
+ }, timeoutMs)
124
+ waiting.set(id, { resolve, reject, t })
125
+ port.postMessage(JSON.stringify({ id, method, params }))
126
+ })
127
+ }
128
+ }
129
+ }
package/vault/remote.js CHANGED
@@ -270,6 +270,7 @@ const REVOKE_GRACE_MS = 1500
270
270
  function vaultError (p) {
271
271
  const e = new Error(p?.error || 'vault error')
272
272
  if (p?.code) e.code = p.code
273
+ if (p?.reason) e.reason = p.reason
273
274
  if (p?.tries != null) e.tries = p.tries
274
275
  if (p?.waitSec != null) e.waitSec = p.waitSec
275
276
  return e
package/vault/vault.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from './peerStore.js'
15
15
  import { createIdentityCore } from './core.js'
16
16
  import { pubkeyId } from './capabilities.js'
17
+ import { withExternalKeys, appBridge } from './externalKeys.js'
17
18
 
18
19
  ;(async () => {
19
20
  // kv estilo localStorage (síncrono) para me, nonces, delegaciones, certs.
@@ -27,7 +28,7 @@ import { pubkeyId } from './capabilities.js'
27
28
  // IndexedDB (clonado estructurado). Nadie —ni este código, ni un XSS en este
28
29
  // origen— puede leer sus bytes; solo firmar/derivar con ellas. Las llaves
29
30
  // planas (JWK) viejas de localStorage se migran y se borran (core.js).
30
- const keyStore = await (() => new Promise((resolve) => {
31
+ const baseKeyStore = await (() => new Promise((resolve) => {
31
32
  const req = indexedDB.open('dotrino-identity-keys', 1)
32
33
  req.onupgradeneeded = () => req.result.createObjectStore('keys')
33
34
  req.onsuccess = () => {
@@ -46,6 +47,11 @@ import { pubkeyId } from './capabilities.js'
46
47
  }
47
48
  req.onerror = () => resolve(null) // sin IDB (raro): cae al modo kv legado
48
49
  }))()
50
+ // DENTRO DE LA APP DE ANDROID, las llaves nuevas nacen en el chip del teléfono: una sola
51
+ // llave por cuenta, la misma que aprueba en la pantalla nativa (`./externalKeys.js`). El
52
+ // puente solo existe en este origen; en un navegador no está y nada cambia.
53
+ const idKeys = typeof window !== 'undefined' ? window.DotrinoIdentityKeys : null
54
+ const keyStore = idKeys && baseKeyStore ? withExternalKeys(baseKeyStore, appBridge(idKeys)) : baseKeyStore
49
55
 
50
56
  // sessionKv: la prueba de desbloqueo del candado por contraseña vive en
51
57
  // sessionStorage — POR PESTAÑA: sobrevive al refresco, muere al cerrarla.
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.72.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.73.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
4
4
  @dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y