@dotrino/identity 0.94.0 → 0.95.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 +2 -1
- package/vault/index.html +2 -0
- package/vault/vault.js +38 -1
- package/vault/vendor/opaque/VERSION.txt +6 -0
- package/vault/vendor/opaque/build/opaque.js +588 -0
- package/vault/vendor/opaque/build/wasm-bytes.js +2 -0
- package/vault/vendor/opaque/src/index.js +123 -0
- package/vault/vendor/vault/VERSION.txt +3 -1
- package/vault/vendor/vault/b64.js +36 -0
- package/vault/vendor/vault/index.js +97 -3
- package/vault/vendor/vault/passwordLogins.js +431 -0
- package/vault/vendor/vault/protocol.js +13 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EL APARATO QUE SE ABRE CON USUARIO Y CONTRASEÑA.
|
|
3
|
+
*
|
|
4
|
+
* Es un miembro del acta como cualquier otro —su llave de firma, su llave de cifrado, sus
|
|
5
|
+
* permisos—, y lo único distinto es dónde vive su llave privada: aquí, **cifrada con algo
|
|
6
|
+
* que solo sale de la contraseña**. Diseño en
|
|
7
|
+
* `dotrino-passmanager/docs/temporary-access.md`.
|
|
8
|
+
*
|
|
9
|
+
* Lo que esta pieza guarda y lo que NO:
|
|
10
|
+
*
|
|
11
|
+
* · el registro OPAQUE del usuario y la preparación del servidor — con ellos NO se puede
|
|
12
|
+
* comprobar una contraseña sin el protocolo, ni sacarla de ahí;
|
|
13
|
+
* · el paquete con las llaves privadas del aparato, **cerrado por quien lo creó** con la
|
|
14
|
+
* llave que sale de la contraseña (`exportKey`). La bóveda no lo abre nunca: lo guarda
|
|
15
|
+
* y lo devuelve cuando alguien demuestra saber la contraseña;
|
|
16
|
+
* · el certificado del aparato y su acta, que son públicos.
|
|
17
|
+
*
|
|
18
|
+
* **La bóveda nunca ve la contraseña.** OPAQUE (`@dotrino/opaque`, RFC 9807) la comprueba
|
|
19
|
+
* sin recibirla y sin entregar nada con qué adivinarla desde fuera: solo se puede probar en
|
|
20
|
+
* línea, contra el freno de abajo.
|
|
21
|
+
*
|
|
22
|
+
* El escritorio (`createLoginDesk`) es puro: recibe el estado, lo devuelve cambiado y no toca
|
|
23
|
+
* el disco ni la red. Quien lo guarda y quien lo sirve es cada bóveda —`src/vault.js` en el
|
|
24
|
+
* binario, `lib/src/index.js` en la pestaña—. Lo único que no es puro es `registerLogin`, al
|
|
25
|
+
* final, porque hace falta la identidad que firma.
|
|
26
|
+
*/
|
|
27
|
+
import { server as opaque, suiteId } from '@dotrino/opaque'
|
|
28
|
+
import { deviceIdOf, scopeToCaps, scopeToCn } from './enroll.js'
|
|
29
|
+
import { SCOPE } from './protocol.js'
|
|
30
|
+
import { bytesToB64url, b64urlToBytes } from './b64.js'
|
|
31
|
+
|
|
32
|
+
/** Un usuario es la parte de antes de la `@` en `nombre@AB12-CD34-EF56`. */
|
|
33
|
+
export const isValidUser = (u) => typeof u === 'string' && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(u)
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* LA DIRECCIÓN: `nombre@AB12-CD34-EF56` (`temporary-access.md` §3.2). Lo de después de la
|
|
37
|
+
* `@` son los primeros **48 bits de la huella de la cuenta** — el mismo `pubkeyId` que ya
|
|
38
|
+
* identifica cualquier llave, con un grupo más que `keyLabel`.
|
|
39
|
+
*
|
|
40
|
+
* Se ata a la CUENTA y no a una máquina porque la cuenta no cambia nunca y la bóveda puede
|
|
41
|
+
* mudarse o tener réplicas. Y son 48 bits y no 32 porque este código es lo único que ata la
|
|
42
|
+
* dirección a la cuenta de verdad: fabricar otra cuenta con la misma huella corta cuesta
|
|
43
|
+
* siglos con 48 bits y horas con los 32 de `keyLabel`.
|
|
44
|
+
*
|
|
45
|
+
* Vive aquí, y no en cada bóveda, porque una dirección que se escriba distinta en el binario
|
|
46
|
+
* y en la extensión no es la misma dirección.
|
|
47
|
+
*/
|
|
48
|
+
export const ACCOUNT_CODE_HEX = 12
|
|
49
|
+
export function accountCode (fingerprint) {
|
|
50
|
+
const hex = String(fingerprint || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
|
51
|
+
if (hex.length < ACCOUNT_CODE_HEX) throw err('bad-account', 'the account fingerprint is too short to build an address')
|
|
52
|
+
return hex.slice(0, ACCOUNT_CODE_HEX).match(/.{4}/g).join('-')
|
|
53
|
+
}
|
|
54
|
+
export const loginAddress = (user, fingerprint) => `${user}@${accountCode(fingerprint)}`
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* EL CANAL DONDE SE ANUNCIA CADA BÓVEDA DE ESA CUENTA, réplicas incluidas.
|
|
58
|
+
*
|
|
59
|
+
* Quien entra con usuario y contraseña no tiene ninguna llave todavía, así que no puede
|
|
60
|
+
* escribirle a la bóveda por su pubkey: no la sabe. Lo único que tiene es el código de la
|
|
61
|
+
* dirección, y con él lista este canal y encuentra a quien atiende.
|
|
62
|
+
*
|
|
63
|
+
* **El canal no prueba nada**: cualquiera puede publicarse en uno. Lo que ata la bóveda a
|
|
64
|
+
* la cuenta es el acta que enseña después — su huella tiene que empezar por ese mismo
|
|
65
|
+
* código. Por eso son 48 bits, y por eso mirar este canal solo deja ver que detrás de un
|
|
66
|
+
* código hay una bóveda encendida: ni de quién es, ni qué guarda.
|
|
67
|
+
*/
|
|
68
|
+
export const vaultChannel = (fingerprint) => `dotrino-vault-${accountCode(fingerprint)}`
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* EL FRENO. Cinco intentos y, a partir de ahí, una espera que se DUPLICA con cada fallo
|
|
72
|
+
* (decidido por el dueño el 2026-09-17). Se eligió frente a bloquear la cuenta porque un
|
|
73
|
+
* bloqueo deja que cualquiera que sepa tu usuario te impida entrar a propósito.
|
|
74
|
+
*
|
|
75
|
+
* **Se cuenta al EMPEZAR el intento, no al terminarlo**, y no es un detalle: en OPAQUE quien
|
|
76
|
+
* prueba una contraseña se entera ÉL SOLO al recibir la respuesta, sin mandar el último
|
|
77
|
+
* mensaje. Contando al final, quien prueba contraseñas no gastaría ni un intento y el freno
|
|
78
|
+
* no frenaría nada. Un inicio de sesión que termina bien reinicia la cuenta.
|
|
79
|
+
*
|
|
80
|
+
* El precio, dicho claro: quien sepa tu usuario puede gastarte los intentos y hacerte
|
|
81
|
+
* esperar. Por eso hay tope de una hora y `clearBlock`, que lo quita desde la máquina de la
|
|
82
|
+
* bóveda. No hay forma de evitarlo del todo: para saber si una contraseña es la buena hay
|
|
83
|
+
* que dejar intentar.
|
|
84
|
+
*/
|
|
85
|
+
export const FREE_TRIES = 5
|
|
86
|
+
export const BACKOFF_BASE_MS = 60_000
|
|
87
|
+
export const BACKOFF_CAP_MS = 60 * 60_000
|
|
88
|
+
export const backoffFor = (fails) =>
|
|
89
|
+
fails < FREE_TRIES ? 0 : Math.min(BACKOFF_BASE_MS * 2 ** (fails - FREE_TRIES), BACKOFF_CAP_MS)
|
|
90
|
+
|
|
91
|
+
/** Un intercambio de inicio de sesión a medias no puede quedarse abierto para siempre. */
|
|
92
|
+
export const EXCHANGE_TTL_MS = 2 * 60_000
|
|
93
|
+
|
|
94
|
+
const err = (code, message) => Object.assign(new Error(message), { code })
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// EL PAQUETE DE LLAVES
|
|
98
|
+
//
|
|
99
|
+
// Lo cierra y lo abre QUIEN SABE LA CONTRASEÑA: la CLI al crear el aparato, y el
|
|
100
|
+
// navegador al entrar. La bóveda solo lo guarda y lo devuelve — nunca tiene con qué
|
|
101
|
+
// abrirlo, y por eso esto no está en ninguna de las tres bóvedas sino aquí, donde las
|
|
102
|
+
// tres lo importan y cierran el paquete EXACTAMENTE igual.
|
|
103
|
+
//
|
|
104
|
+
// AES-GCM con los primeros 32 bytes de la llave que sale de la contraseña (`exportKey`
|
|
105
|
+
// de OPAQUE). El formato lleva marca de versión porque este blob se guarda durante años
|
|
106
|
+
// y lo leen tres programas distintos: sin ella, cambiarlo sería adivinar.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
const BLOB_V = 'k1'
|
|
110
|
+
|
|
111
|
+
/** La llave AES que sale del `exportKey` del intercambio. No se guarda en ningún sitio. */
|
|
112
|
+
async function keyFrom (exportKey, use) {
|
|
113
|
+
const raw = b64urlToBytes(exportKey)
|
|
114
|
+
if (!raw || raw.length < 32) throw err('bad-key', 'the key that opens the package is not valid')
|
|
115
|
+
return crypto.subtle.importKey('raw', raw.subarray(0, 32), 'AES-GCM', false, [use])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Cierra las llaves privadas del aparato. `keys` es JSON: `{ sign, enc }` en JWK. */
|
|
119
|
+
export async function sealDeviceKeys (exportKey, keys) {
|
|
120
|
+
const key = await keyFrom(exportKey, 'encrypt')
|
|
121
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
122
|
+
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(keys)))
|
|
123
|
+
return [BLOB_V, bytesToB64url(iv), bytesToB64url(new Uint8Array(ct))].join('.')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Abre el paquete. Una contraseña equivocada NO llega hasta aquí —OPAQUE la para antes—,
|
|
128
|
+
* así que si esto falla es que el paquete está roto o es de otra versión, y se dice.
|
|
129
|
+
*/
|
|
130
|
+
export async function openDeviceKeys (exportKey, blob) {
|
|
131
|
+
const [v, iv, ct] = String(blob || '').split('.')
|
|
132
|
+
if (v !== BLOB_V || !iv || !ct) throw err('bad-blob', 'the key package is not in a format this version understands')
|
|
133
|
+
const key = await keyFrom(exportKey, 'decrypt')
|
|
134
|
+
let plain
|
|
135
|
+
try {
|
|
136
|
+
plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b64urlToBytes(iv) }, key, b64urlToBytes(ct))
|
|
137
|
+
} catch (_) { throw err('bad-blob', 'the key package did not open with that key') }
|
|
138
|
+
return JSON.parse(new TextDecoder().decode(plain))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {object} opts
|
|
143
|
+
* `load()` devuelve el estado guardado (o `null` la primera vez)
|
|
144
|
+
* `save(s)` lo guarda
|
|
145
|
+
* `now()` el reloj, para poder probar el freno sin esperar una hora
|
|
146
|
+
*/
|
|
147
|
+
export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
148
|
+
if (typeof load !== 'function' || typeof save !== 'function') throw new Error('createLoginDesk: load and save are required')
|
|
149
|
+
|
|
150
|
+
/** Intercambios a medias: viven en memoria, y por eso un reinicio los tira. */
|
|
151
|
+
const exchanges = new Map()
|
|
152
|
+
|
|
153
|
+
const read = () => {
|
|
154
|
+
const s = load()
|
|
155
|
+
if (s && typeof s === 'object') return s
|
|
156
|
+
return { v: 1, setup: null, users: {} }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** La preparación del servidor nace con el primer usuario y no se toca nunca más. */
|
|
160
|
+
function setupOf (state) {
|
|
161
|
+
if (state.setup) return state.setup
|
|
162
|
+
state.setup = opaque.createSetup()
|
|
163
|
+
return state.setup
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const userOf = (state, user) => {
|
|
167
|
+
if (!isValidUser(user)) throw err('bad-user', 'a user name is lowercase letters, digits, dot, dash or underscore (1-32)')
|
|
168
|
+
return state.users[user] || null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sweepExchanges = () => {
|
|
172
|
+
const t = now()
|
|
173
|
+
for (const [lid, x] of exchanges) if (t - x.at > EXCHANGE_TTL_MS) exchanges.delete(lid)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
/** Lo que se puede enseñar: ni el registro, ni el paquete de llaves, ni la preparación. */
|
|
178
|
+
list () {
|
|
179
|
+
const state = read()
|
|
180
|
+
return Object.entries(state.users).map(([user, u]) => ({
|
|
181
|
+
user,
|
|
182
|
+
deviceId: u.deviceId || null,
|
|
183
|
+
label: u.label || '',
|
|
184
|
+
pub: u.pub,
|
|
185
|
+
createdAt: u.createdAt || 0,
|
|
186
|
+
passwordChangedAt: u.passwordChangedAt || u.createdAt || 0,
|
|
187
|
+
fails: u.fails || 0,
|
|
188
|
+
blockedUntil: u.nextTryAt || 0,
|
|
189
|
+
sessions: Object.entries(u.sessions || {}).map(([sid, s]) => ({
|
|
190
|
+
sid, openedAt: s.openedAt, lastUsedAt: s.lastUsedAt || s.openedAt, label: s.label || ''
|
|
191
|
+
})).sort((a, b) => b.lastUsedAt - a.lastUsedAt)
|
|
192
|
+
})).sort((a, b) => a.user.localeCompare(b.user))
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* ALTA, primer paso. La contraseña no llega hasta aquí: lo que llega es el mensaje de
|
|
197
|
+
* registro de OPAQUE, que no la lleva ni permite adivinarla.
|
|
198
|
+
*/
|
|
199
|
+
registerBegin ({ user, request, replace = false } = {}) {
|
|
200
|
+
const state = read()
|
|
201
|
+
const existing = userOf(state, user)
|
|
202
|
+
if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
|
|
203
|
+
if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
|
|
204
|
+
if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
|
|
205
|
+
const setup = setupOf(state)
|
|
206
|
+
const response = opaque.registrationResponse({ setup, request, credentialId: user })
|
|
207
|
+
save(state)
|
|
208
|
+
return { response, suite: suiteId() }
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* ALTA, segundo paso. `blob` son las llaves privadas del aparato, ya cerradas por quien
|
|
213
|
+
* creó el aparato con la llave que sale de la contraseña: aquí no se abre nunca.
|
|
214
|
+
*/
|
|
215
|
+
registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
|
|
216
|
+
const state = read()
|
|
217
|
+
const existing = userOf(state, user)
|
|
218
|
+
if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
|
|
219
|
+
if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
|
|
220
|
+
if (typeof blob !== 'string' || !blob) throw err('bad-input', 'blob required (the device keys, sealed with the password)')
|
|
221
|
+
if (!replace && (typeof pub !== 'string' || !pub)) throw err('bad-input', 'pub required')
|
|
222
|
+
const record = opaque.registrationFinish({ upload })
|
|
223
|
+
const t = now()
|
|
224
|
+
state.users[user] = {
|
|
225
|
+
...(existing || {}),
|
|
226
|
+
pub: replace ? existing.pub : pub,
|
|
227
|
+
encPub: replace ? existing.encPub : encPub,
|
|
228
|
+
deviceId: replace ? existing.deviceId : deviceId,
|
|
229
|
+
label: label || existing?.label || '',
|
|
230
|
+
record,
|
|
231
|
+
blob,
|
|
232
|
+
suite: suiteId(),
|
|
233
|
+
createdAt: existing?.createdAt || t,
|
|
234
|
+
passwordChangedAt: t,
|
|
235
|
+
// Cambiar la contraseña cierra lo abierto: si alguien entró con la vieja, deja de
|
|
236
|
+
// valer. Y el freno se reinicia, que si no la cuenta vieja castigaría a la nueva.
|
|
237
|
+
sessions: {},
|
|
238
|
+
fails: 0,
|
|
239
|
+
nextTryAt: 0
|
|
240
|
+
}
|
|
241
|
+
save(state)
|
|
242
|
+
return { user, suite: suiteId() }
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/** El certificado del aparato, que firma la bóveda al admitirlo en el acta. Es público. */
|
|
246
|
+
setCert ({ user, cert, iss = null } = {}) {
|
|
247
|
+
const state = read()
|
|
248
|
+
const u = userOf(state, user)
|
|
249
|
+
if (!u) throw err('no-user', `there is no login called "${user}"`)
|
|
250
|
+
u.cert = cert
|
|
251
|
+
if (iss) u.iss = iss
|
|
252
|
+
save(state)
|
|
253
|
+
return { ok: true }
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* INICIO DE SESIÓN, primer paso.
|
|
258
|
+
*
|
|
259
|
+
* Un usuario que no existe recibe una respuesta **igual por fuera**, hecha con un
|
|
260
|
+
* registro inventado: desde fuera no se puede averiguar qué usuarios hay. Y cuenta
|
|
261
|
+
* contra el mismo freno, para que tampoco se pueda medir por el tiempo.
|
|
262
|
+
*/
|
|
263
|
+
loginBegin ({ user, request } = {}) {
|
|
264
|
+
sweepExchanges()
|
|
265
|
+
const state = read()
|
|
266
|
+
if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
|
|
267
|
+
const u = isValidUser(user) ? state.users[user] || null : null
|
|
268
|
+
const t = now()
|
|
269
|
+
if (u?.nextTryAt && u.nextTryAt > t) {
|
|
270
|
+
throw Object.assign(err('too-many-tries', 'too many tries: wait before trying again'), { waitMs: u.nextTryAt - t })
|
|
271
|
+
}
|
|
272
|
+
// EL INTENTO SE CUENTA AQUÍ. Ver el comentario del freno: quien prueba una contraseña
|
|
273
|
+
// puede no mandar nunca el último mensaje, así que si se contara al final no se
|
|
274
|
+
// contaría nada. `loginEnd` lo reinicia cuando el intento sale bien.
|
|
275
|
+
if (u) {
|
|
276
|
+
u.fails = (u.fails || 0) + 1
|
|
277
|
+
u.nextTryAt = t + backoffFor(u.fails)
|
|
278
|
+
}
|
|
279
|
+
const setup = setupOf(state)
|
|
280
|
+
const { state: serverState, response } = opaque.loginStart({
|
|
281
|
+
setup,
|
|
282
|
+
record: u?.record || null,
|
|
283
|
+
request,
|
|
284
|
+
credentialId: String(user || ''),
|
|
285
|
+
identifiers: {}
|
|
286
|
+
})
|
|
287
|
+
const lid = crypto.randomUUID()
|
|
288
|
+
exchanges.set(lid, { user: String(user || ''), known: !!u, state: serverState, at: t })
|
|
289
|
+
save(state)
|
|
290
|
+
return { lid, response }
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* INICIO DE SESIÓN, segundo paso. Si la contraseña es la buena, devuelve el paquete de
|
|
295
|
+
* llaves —que solo abre esa contraseña—, el certificado y el acta.
|
|
296
|
+
*/
|
|
297
|
+
loginEnd ({ lid, finalization, label = '' } = {}) {
|
|
298
|
+
sweepExchanges()
|
|
299
|
+
const x = exchanges.get(lid)
|
|
300
|
+
if (!x) throw err('no-exchange', 'that login is not in flight any more: start again')
|
|
301
|
+
exchanges.delete(lid)
|
|
302
|
+
const state = read()
|
|
303
|
+
const u = x.known ? state.users[x.user] : null
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
opaque.loginFinish({ state: x.state, finalization })
|
|
307
|
+
} catch (e) {
|
|
308
|
+
// Contraseña equivocada, usuario inexistente o mensaje alterado: el MISMO error.
|
|
309
|
+
// Distinguirlos diría qué usuarios existen. El intento ya está contado desde
|
|
310
|
+
// `loginBegin`, así que aquí no se vuelve a contar.
|
|
311
|
+
throw err('login-failed', 'wrong user or password')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const t = now()
|
|
315
|
+
const sid = crypto.randomUUID()
|
|
316
|
+
u.fails = 0
|
|
317
|
+
u.nextTryAt = 0
|
|
318
|
+
u.sessions = { ...(u.sessions || {}), [sid]: { openedAt: t, lastUsedAt: t, label: String(label || '').slice(0, 60) } }
|
|
319
|
+
save(state)
|
|
320
|
+
return { sid, user: x.user, blob: u.blob, cert: u.cert || null, iss: u.iss || null, pub: u.pub }
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* QUITAR LA ESPERA, desde la máquina de la bóveda. Existe porque el freno se cuenta al
|
|
325
|
+
* empezar el intento (ver arriba): quien sepa tu usuario puede gastártelos, y el dueño
|
|
326
|
+
* tiene que poder devolverte la entrada sin cambiar la contraseña.
|
|
327
|
+
*/
|
|
328
|
+
clearBlock ({ user } = {}) {
|
|
329
|
+
const state = read()
|
|
330
|
+
const u = userOf(state, user)
|
|
331
|
+
if (!u) throw err('no-user', `there is no login called "${user}"`)
|
|
332
|
+
u.fails = 0
|
|
333
|
+
u.nextTryAt = 0
|
|
334
|
+
save(state)
|
|
335
|
+
return { ok: true }
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Un inicio de sesión NO vence solo: lo decide el cliente (dueño, 2026-09-17). Se cierra
|
|
340
|
+
* al salir, al cambiar la contraseña, al quitar el aparato, o desde la consola — que es
|
|
341
|
+
* la única forma de cortar el que quedó abierto en un equipo prestado.
|
|
342
|
+
*/
|
|
343
|
+
closeSession ({ user, sid } = {}) {
|
|
344
|
+
const state = read()
|
|
345
|
+
const u = userOf(state, user)
|
|
346
|
+
if (!u || !u.sessions?.[sid]) return { ok: false }
|
|
347
|
+
delete u.sessions[sid]
|
|
348
|
+
save(state)
|
|
349
|
+
return { ok: true }
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
/** «Se usó»: es lo que deja ver en la consola cuál sigue vivo y cuál se olvidó abierto. */
|
|
353
|
+
touch ({ user, sid } = {}) {
|
|
354
|
+
const state = read()
|
|
355
|
+
const u = userOf(state, user)
|
|
356
|
+
if (!u || !u.sessions?.[sid]) return { ok: false }
|
|
357
|
+
u.sessions[sid].lastUsedAt = now()
|
|
358
|
+
save(state)
|
|
359
|
+
return { ok: true }
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
/** ¿Tiene esta llave algún inicio de sesión abierto? Lo pregunta quien sirve al aparato. */
|
|
363
|
+
hasOpenSession ({ pub } = {}) {
|
|
364
|
+
const state = read()
|
|
365
|
+
for (const u of Object.values(state.users)) {
|
|
366
|
+
if (u.pub === pub && Object.keys(u.sessions || {}).length) return true
|
|
367
|
+
}
|
|
368
|
+
return false
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
/** Quitar el aparato de aquí. Sacarlo del acta es aparte, y también hay que hacerlo. */
|
|
372
|
+
remove ({ user } = {}) {
|
|
373
|
+
const state = read()
|
|
374
|
+
if (!userOf(state, user)) return { ok: false }
|
|
375
|
+
delete state.users[user]
|
|
376
|
+
save(state)
|
|
377
|
+
return { ok: true }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* EL ALTA ENTERA: guardar el registro, firmar el certificado y meter al aparato en el acta.
|
|
384
|
+
*
|
|
385
|
+
* Vive aquí —y no en cada bóveda— porque las tres versiones tienen que crear exactamente el
|
|
386
|
+
* mismo aparato: el binario, la pestaña y la extensión. Lo que cambia entre ellas es lo que
|
|
387
|
+
* hacen DESPUÉS (la bitácora, avisar a los demás aparatos), y eso se queda fuera.
|
|
388
|
+
*
|
|
389
|
+
* `replace: true` es cambiar la contraseña: el aparato, su llave y su certificado siguen
|
|
390
|
+
* siendo los mismos; lo que cambia es con qué se abre el paquete.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} opts
|
|
393
|
+
* `identity` la identidad que firma (`signDelegation`, `admitMember`)
|
|
394
|
+
* `logins` el escritorio de `createLoginDesk`
|
|
395
|
+
* `scope` permisos del aparato; por defecto firmar, leer y guardar
|
|
396
|
+
* `unattended` si además puede trabajar sin que nadie apruebe
|
|
397
|
+
*/
|
|
398
|
+
export async function registerLogin ({
|
|
399
|
+
identity, logins, user, upload, pub, encPub = null, label = '', blob,
|
|
400
|
+
scope, unattended = false, replace = false
|
|
401
|
+
} = {}) {
|
|
402
|
+
if (replace) {
|
|
403
|
+
logins.registerFinish({ user, upload, blob, replace: true })
|
|
404
|
+
return { ok: true, user, replaced: true }
|
|
405
|
+
}
|
|
406
|
+
// PERMISOS, no tipos: los del scope, más `unattended` si quien lo crea lo eligió
|
|
407
|
+
// (temporary-access.md §3.1). `passwords` no entra todavía — hasta que existan las
|
|
408
|
+
// contraseñas selladas, este aparato se llevaría TODAS (sealed-passwords.md).
|
|
409
|
+
const scopes = Array.isArray(scope) && scope.length ? scope : [SCOPE.SIGN, SCOPE.READ, SCOPE.STORE]
|
|
410
|
+
if (scopes.includes(SCOPE.PASSWORDS)) {
|
|
411
|
+
throw Object.assign(new Error('a password login cannot take `contrasenas` yet: the sealed passwords come first'), { code: 'passwords-not-yet' })
|
|
412
|
+
}
|
|
413
|
+
if (typeof identity?.admitMember !== 'function') {
|
|
414
|
+
throw Object.assign(new Error('this vault cannot add devices to the account record: no login was created'), { code: 'admit-unavailable' })
|
|
415
|
+
}
|
|
416
|
+
const deviceId = await deviceIdOf(pub)
|
|
417
|
+
logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
|
|
418
|
+
try {
|
|
419
|
+
const { cert } = await identity.signDelegation(pub, scopes, { label: label || `login:${user}` })
|
|
420
|
+
const cn = scopeToCn(scopes)
|
|
421
|
+
const caps = [...new Set([...scopeToCaps(scopes), ...(unattended ? ['unattended'] : [])])]
|
|
422
|
+
await identity.admitMember({ pub, encPub, label: label || `login:${user}`, cn, caps, cert })
|
|
423
|
+
logins.setCert({ user, cert, iss: identity.me?.publickey || null })
|
|
424
|
+
return { ok: true, user, deviceId, cert, caps }
|
|
425
|
+
} catch (e) {
|
|
426
|
+
// NADA DE MEDIAS ALTAS: si no entra en el acta, no queda un usuario que pueda entrar a
|
|
427
|
+
// una cuenta que no lo reconoce. Se deshace y se dice.
|
|
428
|
+
logins.remove({ user })
|
|
429
|
+
throw e
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -90,6 +90,19 @@ export const MSG = Object.freeze({
|
|
|
90
90
|
// firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
|
|
91
91
|
REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }
|
|
92
92
|
REPLICA_ACK: 'vault.replica.ack', // réplica → master: { body:{seq,ts}, signature }
|
|
93
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA (`src/passwordLogins.js`,
|
|
94
|
+
// `dotrino-passmanager/docs/temporary-access.md`). Es lo ÚNICO que se atiende sin
|
|
95
|
+
// certificado y sin firma: quien pregunta todavía no tiene llaves — vienen dentro del
|
|
96
|
+
// paquete que solo abre su contraseña. La contraseña NO viaja: viajan los mensajes de
|
|
97
|
+
// OPAQUE, que no la llevan ni dejan adivinarla.
|
|
98
|
+
LOGIN_START: 'vault.login.start', // aparato → vault: { user, request }
|
|
99
|
+
LOGIN_RESPONSE: 'vault.login.response', // vault → aparato: { lid, response }
|
|
100
|
+
LOGIN_FINISH: 'vault.login.finish', // aparato → vault: { lid, finalization, label }
|
|
101
|
+
LOGIN_OK: 'vault.login.ok', // vault → aparato: { sid, blob, cert, iss, acta }
|
|
102
|
+
// Salir SÍ va firmado, con la llave que acaba de abrir: cerrar la sesión de otro sería
|
|
103
|
+
// una forma de echar a alguien de su cuenta.
|
|
104
|
+
LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
|
|
105
|
+
LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
|
|
93
106
|
ERROR: 'vault.error' // vault → dispositivo: { error }
|
|
94
107
|
})
|
|
95
108
|
|