@dotrino/identity 0.29.0 → 0.30.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.29.0",
3
+ "version": "0.30.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",
package/vault/acta.js CHANGED
@@ -32,10 +32,37 @@ import { signWithDevice, verifyDeviceSig, pubkeyId } from './capabilities.js'
32
32
 
33
33
  export const ACTA_V = 1
34
34
 
35
- /** Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master. */
36
- export const CAPS = Object.freeze(['sign', 'store', 'read'])
35
+ /**
36
+ * Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master.
37
+ *
38
+ * `secrets` es distinta de las otras tres: solo la pueden tener los miembros con **CN**
39
+ * (los servicios), y lo que abre es únicamente el cajón de SU nombre. Ver `cn` abajo.
40
+ */
41
+ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets'])
42
+
43
+ /** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
44
+ export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read'])
45
+
46
+ /** Capacidades de un SERVICIO (con CN): solo su propio cajón de secretos. */
47
+ export const SERVICE_CAPS = Object.freeze(['secrets'])
48
+
49
+ /** Un CN válido: minúsculas, números y guiones (igual que el namespace de secretos). */
50
+ export const isValidCn = (cn) => typeof cn === 'string' && /^[a-z0-9-]{1,32}$/.test(cn)
51
+
52
+ /**
53
+ * Scope del cert que corresponde a cada capacidad. `secrets` necesita el CN para
54
+ * completarse: el miembro `proxy` obtiene `vault:secrets:proxy` y nada más — no existe un
55
+ * scope de secretos «de todos».
56
+ */
57
+ export function capScope (cap, cn = null) {
58
+ if (cap === 'sign') return 'vault:sign'
59
+ if (cap === 'store') return 'vault:store'
60
+ if (cap === 'read') return 'vault:read'
61
+ if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
62
+ return null
63
+ }
37
64
 
38
- /** Cada capacidad es uno de los scopes que ya existen en los certs (sin scopes nuevos). */
65
+ /** Compat: el mapa directo, para las tres capacidades de dispositivo. */
39
66
  export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read' })
40
67
 
41
68
  const enc = (s) => new TextEncoder().encode(s)
@@ -77,7 +104,7 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
77
104
  sealedBy: pub,
78
105
  seq: 1,
79
106
  prev: null,
80
- members: [{ pub, encPub, label: String(label || '').slice(0, 60), caps: [...CAPS], addedAt: now, cert: null }],
107
+ members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...DEVICE_CAPS], addedAt: now, cert: null }],
81
108
  revoked: [],
82
109
  renounced: [],
83
110
  // Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
@@ -98,6 +125,15 @@ export function checkShape (acta) {
98
125
  for (const m of acta.members) {
99
126
  if (!isPub(m?.pub) || !Array.isArray(m?.caps)) return 'member'
100
127
  if (m.caps.some((c) => !CAPS.includes(c))) return 'cap-desconocida'
128
+ // El CN es la frontera: un SERVICIO solo puede abrir su propio cajón, y un
129
+ // DISPOSITIVO no tiene cajón que abrir. Que no se pueda escribir un acta que
130
+ // mezcle las dos cosas es lo que hace que el límite sea real y no una costumbre.
131
+ if (m.cn != null) {
132
+ if (!isValidCn(m.cn)) return 'cn-invalido'
133
+ if (m.caps.some((c) => !SERVICE_CAPS.includes(c))) return 'servicio-con-capacidades-de-dispositivo'
134
+ } else if (m.caps.includes('secrets')) {
135
+ return 'secretos-sin-cn'
136
+ }
101
137
  }
102
138
  if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'miembro-duplicado'
103
139
  if (!acta.members.some((m) => m.pub === acta.sealer)) return 'sealer-no-es-miembro'
@@ -166,11 +202,15 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
166
202
  const m = ch.member
167
203
  if (!isPub(m?.pub)) throw new Error('admit: falta la pubkey del miembro')
168
204
  if (find(m.pub)) throw new Error('admit: ese miembro ya está en el acta')
205
+ const cn = m.cn != null ? String(m.cn) : null
206
+ if (cn !== null && !isValidCn(cn)) throw new Error('admit: CN inválido (minúsculas, números y guiones)')
169
207
  next.members.push({
170
208
  pub: m.pub,
171
209
  encPub: m.encPub || null,
172
210
  label: String(m.label || '').slice(0, 60),
173
- caps: cleanCaps(m.caps),
211
+ cn,
212
+ // Un servicio (con CN) solo puede tener `secrets`; un dispositivo, las otras tres.
213
+ caps: cleanCaps(m.caps).filter((c) => (cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c)),
174
214
  addedAt: now,
175
215
  cert: m.cert || null,
176
216
  // Puente con la identidad que este miembro traía de antes (ver makeContinuity).
@@ -181,7 +221,9 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
181
221
  case 'caps': {
182
222
  const m = find(ch.pub)
183
223
  if (!m) throw new Error('caps: ese miembro no está en el acta')
184
- m.caps = cleanCaps(ch.caps)
224
+ // No se puede ascender un servicio a dispositivo cambiándole las capacidades: para
225
+ // eso hay que sacarlo y volver a admitirlo, que es un gesto visible.
226
+ m.caps = cleanCaps(ch.caps).filter((c) => (m.cn ? SERVICE_CAPS : DEVICE_CAPS).includes(c))
185
227
  break
186
228
  }
187
229
  case 'remove': {
@@ -284,6 +326,26 @@ export function effectiveCaps (acta, pub, extraRenounces = []) {
284
326
  return m.caps.filter((c) => !quitadas.has(c))
285
327
  }
286
328
 
329
+ /**
330
+ * ¿Puede este miembro leer el cajón de secretos `ns`? Solo si su CN es exactamente ése.
331
+ * Es la frontera que pediste: la llave del proxy no ve nada más que lo del proxy.
332
+ */
333
+ export function memberCanReadSecrets (acta, pub, ns) {
334
+ const m = (acta?.members || []).find((x) => x.pub === pub)
335
+ if (!m || !m.cn) return false
336
+ return m.cn === ns && effectiveCaps(acta, pub).includes('secrets')
337
+ }
338
+
339
+ /** Los scopes de cert que le corresponden a un miembro según el acta. */
340
+ export function memberScopes (acta, pub, extraRenounces = []) {
341
+ const m = (acta?.members || []).find((x) => x.pub === pub)
342
+ if (!m) return []
343
+ return effectiveCaps(acta, pub, extraRenounces).map((c) => capScope(c, m.cn)).filter(Boolean)
344
+ }
345
+
346
+ /** ¿Es un servicio (tiene CN) o un dispositivo del usuario? */
347
+ export const isService = (acta, pub) => !!(acta?.members || []).find((x) => x.pub === pub)?.cn
348
+
287
349
  /** ¿Puede este miembro hacer `cap` según el acta? (con el cert se cruza aparte: cert ∩ acta). */
288
350
  export function memberCan (acta, pub, cap, extraRenounces = []) {
289
351
  return effectiveCaps(acta, pub, extraRenounces).includes(cap)
@@ -362,5 +424,5 @@ export default {
362
424
  ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, isHandover,
363
425
  sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
364
426
  makeContinuity, verifyContinuity,
365
- effectiveCaps, memberCan, canAdopt
427
+ effectiveCaps, memberCan, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt
366
428
  }
package/vault/core.js CHANGED
@@ -1071,6 +1071,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1071
1071
  pub: m.pub,
1072
1072
  id: await Acta.memberId(m.pub),
1073
1073
  label: m.label || '',
1074
+ cn: m.cn || null,
1075
+ isService: !!m.cn,
1074
1076
  caps: Acta.effectiveCaps(acta, m.pub, pend),
1075
1077
  addedAt: m.addedAt || null,
1076
1078
  isMe: m.pub === publickeyJwkStr,
@@ -1099,8 +1101,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1099
1101
  * Admite un miembro (solo el master). El cert lo emite quien llama, antes o después.
1100
1102
  * `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).
1101
1103
  */
1102
- async admitMember ({ pub, encPub = null, label = '', caps = ['store', 'read'], cert = null, continuity = null } = {}) {
1103
- const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, caps, cert, continuity } }])
1104
+ async admitMember ({ pub, encPub = null, label = '', cn = null, caps = null, cert = null, continuity = null } = {}) {
1105
+ // `cn`: si viene, este miembro es un SERVICIO y su único permiso es abrir el cajón de
1106
+ // secretos de ESE nombre — no ve nada más del usuario. Sin `cn` es un dispositivo.
1107
+ const finales = caps || (cn ? ['secrets'] : ['store', 'read'])
1108
+ const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity } }])
1104
1109
  // Que entre al perfil incluye poder LEER lo que ya hay: se le envuelve la clave
1105
1110
  // vigente (no hace falta rotar; rotar es para cuando alguien SALE).
1106
1111
  let wrapped = false
@@ -50,6 +50,19 @@ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read
50
50
  export const scopeToCaps = (scope) =>
51
51
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
52
52
 
53
+ /**
54
+ * El CN de un servicio sale de su scope: `vault:secrets:proxy` ⇒ CN `proxy`. Con CN, el
55
+ * miembro entra al acta como SERVICIO —solo abre su propio cajón— en vez de como un
56
+ * dispositivo del usuario. Es la frontera, y vive en el acta para que se pueda comprobar.
57
+ */
58
+ export function scopeToCn (scope) {
59
+ for (const s of (Array.isArray(scope) ? scope : [scope])) {
60
+ const m = /^vault:secrets:([a-z0-9-]{1,32})$/.exec(String(s || ''))
61
+ if (m) return m[1]
62
+ }
63
+ return null
64
+ }
65
+
53
66
  /** Token aleatorio de 128 bits en hex. */
54
67
  export function randToken () {
55
68
  const b = crypto.getRandomValues(new Uint8Array(16))
@@ -215,8 +228,9 @@ export function createEnrollDesk ({
215
228
  let acta = null
216
229
  try {
217
230
  if (typeof identity.admitMember === 'function') {
218
- const caps = scopeToCaps(pend.scope)
219
- if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', caps, cert, continuity: pend.continuity || null })
231
+ const cn = scopeToCn(pend.scope)
232
+ const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
233
+ if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
220
234
  }
221
235
  acta = (await identity.profileActa?.())?.acta || null
222
236
  } catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }