@dotrino/identity 0.106.0 → 0.107.1

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.106.0",
3
+ "version": "0.107.1",
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",
package/vault/content.js CHANGED
@@ -106,7 +106,26 @@ export async function myContentKey ({ keyring, myPub, myEncPrivateKey }) {
106
106
  return null
107
107
  }
108
108
 
109
- /** Cifra con la CEK. Devuelve un sobre `{ gen, iv, ct }` (el `gen` dice con cuál se cifró). */
109
+ /**
110
+ * La marca de un sobre de la llave de la cuenta. La lleva todo sobre de `encryptWithCek`
111
+ * para que quien guarda datos ajenos —la bóveda con el almacén de las apps— lo RECONOZCA
112
+ * dentro de un JSON cualquiera sin adivinar por la forma, y pueda volver a cerrarlo con la
113
+ * generación vigente cuando la llave rota (`resealStale`).
114
+ */
115
+ export const CEK_ENVELOPE = 'dotrino-cek'
116
+
117
+ /** ¿Es un sobre de la llave de la cuenta? */
118
+ export const isCekEnvelope = (v) => !!v && typeof v === 'object' && v.t === CEK_ENVELOPE &&
119
+ Number.isInteger(v.gen) && typeof v.iv === 'string' && typeof v.ct === 'string'
120
+
121
+ /**
122
+ * Cifra con una clave simétrica. Devuelve `{ gen, iv, ct }` (el `gen` dice con cuál).
123
+ *
124
+ * Es GENÉRICA: la usan la llave de la cuenta y también llaves que no son de la cuenta (los
125
+ * cajones de secretos, cada entrada del gestor de contraseñas, el transporte). Por eso NO
126
+ * lleva la marca: la marca dice «esto es de la cuenta» y solo la pone `sealAccount`. En
127
+ * 0.107.0 la ponía aquí, y marcaba como de la cuenta sobres que la cuenta no abre.
128
+ */
110
129
  export async function encryptWithCek ({ cek, gen, plaintext }) {
111
130
  const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['encrypt'])
112
131
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
@@ -114,6 +133,12 @@ export async function encryptWithCek ({ cek, gen, plaintext }) {
114
133
  return { gen, iv: b64(iv), ct: b64(ct) }
115
134
  }
116
135
 
136
+ /** Cierra con la LLAVE DE LA CUENTA (generación `gen`): el sobre lleva la marca. */
137
+ export async function sealAccount ({ cek, gen, plaintext }) {
138
+ if (!Number.isInteger(gen) || gen < 1) throw new Error('sealAccount: the account key generation starts at 1')
139
+ return { t: CEK_ENVELOPE, ...(await encryptWithCek({ cek, gen, plaintext })) }
140
+ }
141
+
117
142
  /**
118
143
  * Descifra un sobre teniendo YA la CEK. Es el inverso exacto de `encryptWithCek`.
119
144
  *
@@ -142,7 +167,81 @@ export async function decryptWithKeyring ({ envelope, keyring, myPub, myEncPriva
142
167
  return new TextDecoder().decode(pt)
143
168
  }
144
169
 
170
+ /**
171
+ * MIGRACIÓN, con fecha de caducidad: hasta 0.107.0 los sobres de la cuenta salían SIN marca,
172
+ * como `{ gen, iv, ct }` a secas. Hasta esta fecha, `resealStale` también prueba esos: si
173
+ * abren con la llave de la cuenta, los vuelve a cerrar ya marcados; si no abren, no eran de
174
+ * la cuenta y se dejan como están. Pasada la fecha, un sobre sin marca ya no se toca. Se
175
+ * quita el código cuando pase (y su prueba con él).
176
+ */
177
+ export const LEGACY_UNMARKED_UNTIL = Date.parse('2027-06-30T00:00:00Z')
178
+
179
+ /** ¿Tiene la forma EXACTA de un sobre de la cuenta anterior a la marca? */
180
+ export const isLegacyCekEnvelope = (v) => !!v && typeof v === 'object' && !Array.isArray(v) &&
181
+ Object.keys(v).length === 3 && Number.isInteger(v.gen) && v.gen >= 1 &&
182
+ typeof v.iv === 'string' && typeof v.ct === 'string'
183
+
184
+ /**
185
+ * VUELVE A CERRAR con la generación vigente todo sobre de la cuenta de una generación
186
+ * anterior que haya dentro de `value`, a cualquier profundidad. Devuelve una copia (no toca
187
+ * el original) y cuántos sobres cambió.
188
+ *
189
+ * Es lo que hace la bóveda al abrirse y después de una rotación (dueño, 2026-09-30): lo que
190
+ * sirve va SIEMPRE con la llave vigente, así que un aparato que entra después —que solo
191
+ * recibe esa generación— abre todo, y la llave vieja deja de abrir nada porque ya no se le
192
+ * sirve nada cerrado con ella.
193
+ *
194
+ * `reseal(envelope)` abre y vuelve a cerrar un sobre con la llave de la cuenta; lo pone quien
195
+ * la tiene, y debe devolver un sobre MARCADO de la generación `gen`.
196
+ *
197
+ * · Un sobre MARCADO que no abre PARA todo: es de la cuenta y algo va mal; no se deja a
198
+ * medias ni se salta en silencio.
199
+ * · Un sobre SIN MARCA (migración, hasta `LEGACY_UNMARKED_UNTIL`) se prueba aunque ya
200
+ * tenga la generación vigente —hay que ponerle la marca—; si no abre, se deja y se
201
+ * cuenta en `skipped`.
202
+ *
203
+ * @param {any} value
204
+ * @param {{ gen: number, reseal: (env: object) => Promise<object>, now?: number }} o
205
+ * @returns {Promise<{ value: any, changed: number, skipped: number }>}
206
+ */
207
+ export async function resealStale (value, { gen, reseal, now = Date.now() }) {
208
+ if (!Number.isInteger(gen)) throw new Error('resealStale: the current generation is required')
209
+ const migrar = now < LEGACY_UNMARKED_UNTIL
210
+ let changed = 0; let skipped = 0
211
+ const comprobar = (nuevo) => {
212
+ if (!isCekEnvelope(nuevo) || nuevo.gen !== gen) throw new Error(`resealStale: reseal did not return a marked envelope of generation ${gen}`)
213
+ return nuevo
214
+ }
215
+ const walk = async (v) => {
216
+ if (isCekEnvelope(v)) {
217
+ if (v.gen === gen) return v
218
+ const nuevo = comprobar(await reseal(v))
219
+ changed++
220
+ return nuevo
221
+ }
222
+ if (migrar && isLegacyCekEnvelope(v)) {
223
+ let nuevo
224
+ try { nuevo = await reseal(v) } catch (_) { skipped++; return v }
225
+ changed++
226
+ return comprobar(nuevo)
227
+ }
228
+ if (Array.isArray(v)) {
229
+ const out = []
230
+ for (const x of v) out.push(await walk(x))
231
+ return out
232
+ }
233
+ if (v && typeof v === 'object') {
234
+ const out = {}
235
+ for (const [k, x] of Object.entries(v)) out[k] = await walk(x)
236
+ return out
237
+ }
238
+ return v
239
+ }
240
+ const out = await walk(value)
241
+ return { value: out, changed, skipped }
242
+ }
243
+
145
244
  export default {
146
245
  makeContentKey, wrapForMember, openWrap, makeGeneration, myContentKey,
147
- encryptWithCek, decryptWithCek, decryptWithKeyring
246
+ encryptWithCek, sealAccount, decryptWithCek, decryptWithKeyring, CEK_ENVELOPE, isCekEnvelope, isLegacyCekEnvelope, LEGACY_UNMARKED_UNTIL, resealStale
148
247
  }
package/vault/core.js CHANGED
@@ -2695,13 +2695,14 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
2695
2695
  async contentKey () { return myCek() },
2696
2696
 
2697
2697
  /**
2698
- * Cifra algo con la clave de contenido del perfil. Devuelve el sobre `{gen,iv,ct}`.
2699
- * La llave privada de cifrado NUNCA sale de aquí: se cifra y descifra dentro.
2698
+ * Cifra algo con la clave de contenido del perfil. Devuelve el sobre MARCADO
2699
+ * `{ t: 'dotrino-cek', gen, iv, ct }` (la marca deja a la bóveda reconocerlo y volver a
2700
+ * cerrarlo al rotar). La llave privada de cifrado NUNCA sale de aquí.
2700
2701
  */
2701
2702
  async sealContent ({ plaintext } = {}) {
2702
2703
  const mine = await myCek()
2703
2704
  if (!mine) throw new Error('this device does not hold the profile content key yet')
2704
- return Content.encryptWithCek({ cek: mine.cek, gen: mine.gen, plaintext: String(plaintext) })
2705
+ return Content.sealAccount({ cek: mine.cek, gen: mine.gen, plaintext: String(plaintext) })
2705
2706
  },
2706
2707
 
2707
2708
  /**