@dotrino/identity 0.114.1 → 0.116.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 +7 -1
- package/src/index.js +9 -1
- package/src/node.js +4 -180
- package/vault/acta.js +5 -1
- package/vault/core.js +95 -15
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/machine.js +95 -0
- package/vault/remote.js +54 -9
- package/vault/vault.js +29 -58
- package/vault/vendor/compat/VERSION.txt +3 -0
- package/vault/vendor/compat/advisory.js +141 -0
- package/vault/vendor/compat/index.js +188 -0
- package/vault/vendor/compat/ranges.js +93 -0
- package/vault/vendor/opaque/VERSION.txt +1 -1
- package/vault/vendor/passmanager/VERSION.txt +3 -0
- package/vault/vendor/passmanager/crypto.js +167 -0
- package/vault/vendor/passmanager/fields.js +183 -0
- package/vault/vendor/passmanager/generate.js +74 -0
- package/vault/vendor/passmanager/import.js +156 -0
- package/vault/vendor/passmanager/index.js +27 -0
- package/vault/vendor/passmanager/match.js +137 -0
- package/vault/vendor/passmanager/model.js +332 -0
- package/vault/vendor/passmanager/pubkey.js +33 -0
- package/vault/vendor/passmanager/sealed/convert.js +72 -0
- package/vault/vendor/passmanager/sealed/device.js +211 -0
- package/vault/vendor/passmanager/sealed/entry.js +209 -0
- package/vault/vendor/passmanager/sealed/index.js +20 -0
- package/vault/vendor/passmanager/sealed/keys.js +120 -0
- package/vault/vendor/passmanager/sealed/recovery.js +136 -0
- package/vault/vendor/passmanager/sealed/store.js +508 -0
- package/vault/vendor/passmanager/session-cache.js +60 -0
- package/vault/vendor/passmanager/totp.js +111 -0
- package/vault/vendor/passmanager/transport/protocol.js +83 -0
- package/vault/vendor/passmanager/transport/proxy.js +112 -0
- package/vault/vendor/passmanager/transport/sealed.js +74 -0
- package/vault/vendor/passmanager/vault/approval.js +63 -0
- package/vault/vendor/passmanager/vault/errors.js +34 -0
- package/vault/vendor/passmanager/vault/guard.js +114 -0
- package/vault/vendor/passmanager/vault/interface.js +34 -0
- package/vault/vendor/passmanager/vault/local.js +213 -0
- package/vault/vendor/passmanager/vault/remote.js +85 -0
- package/vault/vendor/passmanager/vault/responder.js +192 -0
- package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
- package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
- package/vault/vendor/passmanager/vault/sealed.js +362 -0
- package/vault/vendor/passmanager/webauthn.js +221 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +36 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +797 -0
- package/vault/vendor/store/VERSION.txt +3 -0
- package/vault/vendor/store/core.js +309 -0
- package/vault/vendor/vault/VERSION.txt +4 -1
- package/vault/vendor/vault/admin.js +437 -0
- package/vault/vendor/vault/browser.js +167 -0
- package/vault/vendor/vault/core/approvals.js +204 -0
- package/vault/vendor/vault/core/blocked.js +68 -0
- package/vault/vendor/vault/core/browserDisk.js +109 -0
- package/vault/vendor/vault/core/commKey.js +68 -0
- package/vault/vendor/vault/core/disk.js +65 -0
- package/vault/vendor/vault/core/libVersion.js +8 -0
- package/vault/vendor/vault/core/machines.js +66 -0
- package/vault/vendor/vault/core/notices.js +71 -0
- package/vault/vendor/vault/core/notifiable.js +73 -0
- package/vault/vendor/vault/core/passwords.js +125 -0
- package/vault/vendor/vault/core/sealKey.js +77 -0
- package/vault/vendor/vault/core/sealer.js +172 -0
- package/vault/vendor/vault/core/sealers.js +159 -0
- package/vault/vendor/vault/core/secretsStore.js +1326 -0
- package/vault/vendor/vault/core/sha256.js +69 -0
- package/vault/vendor/vault/core/store.js +68 -0
- package/vault/vendor/vault/core/subacta.js +101 -0
- package/vault/vendor/vault/core/threadStore.js +273 -0
- package/vault/vendor/vault/core/transport.js +270 -0
- package/vault/vendor/vault/core/vault.js +4414 -0
- package/vault/vendor/vault/enroll.js +8 -4
- package/vault/vendor/vault/procContext.js +97 -0
- package/vault/vendor/vault/revocation.js +67 -0
- package/vault/vendor/vault/sealed.js +84 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// LA BÓVEDA DE CONTRASEÑAS QUE NO PUEDE LEERLAS (`sealed-passwords.md`).
|
|
2
|
+
//
|
|
3
|
+
// Guarda sobres dirigidos a los aparatos y las envolturas de sus llaves, y **no tiene
|
|
4
|
+
// ninguna llave propia con la que abrir nada** — ni abierta ni cerrada. Es el mismo modelo
|
|
5
|
+
// que los cajones de secretos del vault (`dotrino-vault/src/secretsStore.js`), y se parece
|
|
6
|
+
// a propósito: quien lea uno entiende el otro.
|
|
7
|
+
//
|
|
8
|
+
// Lo que sí hace, porque no necesita la llave para hacerlo:
|
|
9
|
+
//
|
|
10
|
+
// · **comprobar los destinatarios**. Es LA comprobación que sostiene el modelo: un
|
|
11
|
+
// aparato no puede guardar una entrada que solo él pueda abrir, ni dejar fuera la copia
|
|
12
|
+
// de recuperación, ni meter la privada de una passkey en el sobre de las contraseñas;
|
|
13
|
+
// · **filtrar por destinatario** al entregar: a cada aparato, lo suyo;
|
|
14
|
+
// · **numerar las generaciones** y barrer las que ya no usa nadie.
|
|
15
|
+
//
|
|
16
|
+
// El almacén se inyecta (`{ get(k), set(k, v) }`), como en `../vault/local.js`: en el
|
|
17
|
+
// demonio es un archivo, en la pestaña IndexedDB, en las pruebas un Map. Una sola pieza
|
|
18
|
+
// para las tres bóvedas y el `serve`, que es lo que la regla de las tres versiones exige.
|
|
19
|
+
|
|
20
|
+
import { wrapForMember } from '@dotrino/identity/content'
|
|
21
|
+
import { PASSKEY_FIELD } from './entry.js'
|
|
22
|
+
import { entryAuthorBody, patchAuthorBody } from './device.js'
|
|
23
|
+
|
|
24
|
+
export const KEY = 'passmanager/sealed/v2'
|
|
25
|
+
/** La envoltura que abre la frase del perfil. Sin ella, una entrada nace ilegible. */
|
|
26
|
+
export const RECOVERY = '#recovery'
|
|
27
|
+
|
|
28
|
+
const isEnv = (e) => !!e && typeof e.iv === 'string' && typeof e.ct === 'string'
|
|
29
|
+
const sorted = (a) => [...a].sort()
|
|
30
|
+
const same = (a, b) => a.length === b.length && sorted(a).every((x, i) => x === sorted(b)[i])
|
|
31
|
+
|
|
32
|
+
export class SealedError extends Error {
|
|
33
|
+
constructor (code, message) { super(message); this.code = code; this.name = 'SealedError' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SealedStore {
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} store `{ get(k), set(k, v) }`
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* `recipients(kind)` → `[{ pub, encPub }]` quién DEBE tener envoltura. `kind` es
|
|
41
|
+
* `'main'` (los aparatos con `passwords`) o `'passkeys'` (los que además tienen
|
|
42
|
+
* `passkeys`). Lo pone quien conoce el acta: esta pieza no la lee.
|
|
43
|
+
* `verifyAuthor({ body, author })` → ¿la firmó quien dice, y puede escribir? Lo
|
|
44
|
+
* contesta quien tiene el acta. **No es opcional**: sin ella, cualquiera que llegue
|
|
45
|
+
* al almacén podría dejar una entrada a nombre de otro, y la bóveda no puede mirar
|
|
46
|
+
* dentro para notarlo.
|
|
47
|
+
* `recovery()` → ¿la cuenta YA tiene copia de recuperación? Si la tiene, toda envoltura
|
|
48
|
+
* lleva la suya (`#recovery`). Si todavía no, la bóveda funciona igual, sellada solo a
|
|
49
|
+
* los aparatos del acta, y la copia se añade después con `addRecovery`. Por defecto
|
|
50
|
+
* contesta que sí: lo estricto es el valor por omisión, no el permisivo.
|
|
51
|
+
* `now()` el reloj, para poder probar sin esperar.
|
|
52
|
+
*/
|
|
53
|
+
constructor (store, { recipients, verifyAuthor, recovery = async () => true, now = () => Date.now() } = {}) {
|
|
54
|
+
if (!store) throw new Error('SealedStore: missing store')
|
|
55
|
+
if (typeof recipients !== 'function') throw new Error('SealedStore: missing recipients(kind)')
|
|
56
|
+
if (typeof verifyAuthor !== 'function') throw new Error('SealedStore: missing verifyAuthor({ body, author })')
|
|
57
|
+
this.store = store
|
|
58
|
+
this.recipients = recipients
|
|
59
|
+
this.verifyAuthor = verifyAuthor
|
|
60
|
+
this.recovery = recovery
|
|
61
|
+
this.now = now
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async #state () {
|
|
65
|
+
const s = await this.store.get(KEY)
|
|
66
|
+
if (s && typeof s === 'object') return s
|
|
67
|
+
return { v: 2, keyring: [], profile: null, entries: [] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async #save (s) { await this.store.set(KEY, s) }
|
|
71
|
+
|
|
72
|
+
/** ¿Hay algo guardado en el formato sellado? Lo pregunta quien tiene que convertir. */
|
|
73
|
+
async sealed () {
|
|
74
|
+
const s = await this.#state()
|
|
75
|
+
return !!s.profile
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#gen (s, gen) { return (s.keyring || []).find((g) => g.gen === gen) || null }
|
|
79
|
+
|
|
80
|
+
/** La envoltura de ESTE aparato para esa generación, o `null` si no le tocaba. */
|
|
81
|
+
#wrapFor (s, gen, pub) { return this.#gen(s, gen)?.wraps?.[pub] || null }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ¿SON EXACTAMENTE LOS DESTINATARIOS QUE MANDA EL ACTA? Ni de más ni de menos, más la
|
|
85
|
+
* copia de recuperación.
|
|
86
|
+
*
|
|
87
|
+
* Ni de menos, porque un aparato podría guardar algo que los demás no pueden abrir —y la
|
|
88
|
+
* pérdida no se nota hasta que hace falta—. Ni de más, porque entonces cualquiera podría
|
|
89
|
+
* darle acceso a una llave que el acta no reconoce. Y la de recuperación siempre que la
|
|
90
|
+
* cuenta tenga copia: sin ella, ni el dueño con su frase puede volver a repartir esa
|
|
91
|
+
* entrada. Una cuenta que aún no la creó guarda igual, y la pantalla lo avisa.
|
|
92
|
+
*/
|
|
93
|
+
async #checkWraps (kind, wraps) {
|
|
94
|
+
if (!wraps || typeof wraps !== 'object') throw new SealedError('wraps-missing', 'the envelope wraps are missing')
|
|
95
|
+
if (!wraps[RECOVERY] && await this.recovery()) {
|
|
96
|
+
throw new SealedError('recovery-missing',
|
|
97
|
+
'the wraps must include the recovery one (#recovery), or this entry could never be handed out again')
|
|
98
|
+
}
|
|
99
|
+
const deben = (await this.recipients(kind)).filter((m) => m?.encPub).map((m) => m.pub)
|
|
100
|
+
// NADIE A QUIEN ENVOLVÉRSELA NO ES «CERO DESTINATARIOS», ES UN ERROR. Guardar una
|
|
101
|
+
// passkey que ningún aparato puede abrir la deja viva solo para la frase del perfil: no
|
|
102
|
+
// se pierde, pero no sirve, y el usuario se entera el día que la necesita. Se dice
|
|
103
|
+
// ahora, con un código que explica qué falta.
|
|
104
|
+
if (kind === 'passkeys' && !deben.length) {
|
|
105
|
+
throw new SealedError('no-passkeys-device',
|
|
106
|
+
'no device in the account record can open passkeys: grant `passkeys` to one (`dotrino-vault caps <ID> +passkeys`) before saving it')
|
|
107
|
+
}
|
|
108
|
+
const hay = Object.keys(wraps).filter((k) => k !== RECOVERY)
|
|
109
|
+
if (!same(deben, hay)) {
|
|
110
|
+
throw new SealedError('wrong-recipients',
|
|
111
|
+
`the wraps do not match the account record: it expects [${sorted(deben).join(', ')}] and got [${sorted(hay).join(', ')}]`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** La forma de una entrada sellada, antes de tocar el disco. */
|
|
116
|
+
#checkEntry (entry, tienePasskeys) {
|
|
117
|
+
if (!entry || typeof entry.id !== 'string' || !entry.id) throw new SealedError('bad-entry', 'the entry has no id')
|
|
118
|
+
if (!isEnv(entry.view)) throw new SealedError('bad-entry', 'the entry has no sealed public view')
|
|
119
|
+
if (!entry.fields || typeof entry.fields !== 'object') throw new SealedError('bad-entry', 'the entry has no fields')
|
|
120
|
+
for (const [k, e] of Object.entries(entry.fields)) {
|
|
121
|
+
if (!isEnv(e)) throw new SealedError('bad-entry', `field «${k}» is not a sealed envelope`)
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(entry.idx)) throw new SealedError('bad-entry', 'the entry has no site index')
|
|
124
|
+
const llevaPasskey = !!entry.fields[PASSKEY_FIELD]
|
|
125
|
+
// Una passkey en el sobre de las contraseñas la abriría un aparato sin el permiso, que
|
|
126
|
+
// es exactamente lo que §2.8 separa. Y al revés: una generación de passkeys sin passkey
|
|
127
|
+
// reparte una llave que nadie va a usar.
|
|
128
|
+
if (llevaPasskey && !tienePasskeys) {
|
|
129
|
+
throw new SealedError('passkeys-generation-missing',
|
|
130
|
+
'this entry carries a passkey: it needs its own wraps, only for devices with `passkeys`')
|
|
131
|
+
}
|
|
132
|
+
if (!llevaPasskey && tienePasskeys) {
|
|
133
|
+
throw new SealedError('passkeys-generation-unused', 'there is no passkey in this entry to wrap')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* GUARDAR. Llega todo cerrado: la bóveda no ve ni un valor, tampoco al escribir.
|
|
139
|
+
*
|
|
140
|
+
* La GENERACIÓN la pone el almacén y no quien escribe —igual que en los cajones—: es lo
|
|
141
|
+
* que ordena el llavero, y dejar elegirla permitiría pisar una anterior.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} o
|
|
144
|
+
* `entry` la entrada de `sealEntry`, con los sobres SIN `gen`
|
|
145
|
+
* `main` `{ wraps }` de la llave con la que se cerró todo menos la passkey
|
|
146
|
+
* `passkeys` `{ wraps }` de la llave de la passkey, si la entrada lleva una
|
|
147
|
+
* `by` el aparato que escribe (para la bitácora; quién PUEDE lo decide quien llama)
|
|
148
|
+
*/
|
|
149
|
+
async putSealed ({ entry, main, passkeys = null, by = null } = {}) {
|
|
150
|
+
this.#checkEntry(entry, !!passkeys)
|
|
151
|
+
await this.#checkWraps('main', main?.wraps)
|
|
152
|
+
if (passkeys) await this.#checkWraps('passkeys', passkeys.wraps)
|
|
153
|
+
// LA FIRMA DEL AUTOR, sobre lo que de verdad se guarda. Es lo que ata cada entrada a un
|
|
154
|
+
// aparato del acta: la bóveda no ve los valores, así que esto es lo único que puede
|
|
155
|
+
// comprobar sobre su procedencia.
|
|
156
|
+
if (!entry.author?.pub || !entry.author?.sig) throw new SealedError('unsigned', 'the entry is not signed by its author')
|
|
157
|
+
const ok = await this.verifyAuthor({ body: entryAuthorBody(entry, entry.author.ts), author: entry.author })
|
|
158
|
+
if (!ok) throw new SealedError('bad-author', 'that entry was not signed by a device allowed to write passwords')
|
|
159
|
+
|
|
160
|
+
const s = await this.#state()
|
|
161
|
+
const t = this.now()
|
|
162
|
+
const siguiente = () => (s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1)
|
|
163
|
+
|
|
164
|
+
const genMain = siguiente()
|
|
165
|
+
s.keyring.push({ gen: genMain, kind: 'main', createdAt: t, wraps: main.wraps })
|
|
166
|
+
let genPk = null
|
|
167
|
+
if (passkeys) {
|
|
168
|
+
genPk = siguiente()
|
|
169
|
+
s.keyring.push({ gen: genPk, kind: 'passkeys', createdAt: t, wraps: passkeys.wraps })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const fields = {}
|
|
173
|
+
for (const [k, e] of Object.entries(entry.fields)) {
|
|
174
|
+
fields[k] = { ...e, gen: k === PASSKEY_FIELD ? genPk : genMain }
|
|
175
|
+
}
|
|
176
|
+
const guardada = {
|
|
177
|
+
id: entry.id,
|
|
178
|
+
createdAt: entry.createdAt || t,
|
|
179
|
+
updatedAt: t,
|
|
180
|
+
by: by || entry.author.pub,
|
|
181
|
+
author: entry.author,
|
|
182
|
+
idx: [...entry.idx],
|
|
183
|
+
wa: entry.wa || null,
|
|
184
|
+
view: { ...entry.view, gen: genMain },
|
|
185
|
+
fields,
|
|
186
|
+
digests: { ...(entry.digests || {}) },
|
|
187
|
+
priv: [...(entry.priv || [])]
|
|
188
|
+
}
|
|
189
|
+
const i = s.entries.findIndex((e) => e.id === guardada.id)
|
|
190
|
+
if (i >= 0) { guardada.createdAt = s.entries[i].createdAt || guardada.createdAt; s.entries[i] = guardada }
|
|
191
|
+
else s.entries.push(guardada)
|
|
192
|
+
|
|
193
|
+
this.#gc(s)
|
|
194
|
+
await this.#save(s)
|
|
195
|
+
return { id: guardada.id, gen: genMain, updatedAt: guardada.updatedAt }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* CAMBIAR UNOS CAMPOS SIN LEER LA ENTRADA (§2.4).
|
|
200
|
+
*
|
|
201
|
+
* Es la razón de que exista `patch` y no se haya caído con el formato nuevo: antes, para
|
|
202
|
+
* escribir un teléfono había que sacar la entrada ENTERA de la bóveda —contraseña
|
|
203
|
+
* incluida—, y si esa lectura fallaba, lo que se escribía detrás perdía lo que no se pudo
|
|
204
|
+
* leer. Perder datos por una autorización denegada no es un fallo raro.
|
|
205
|
+
*
|
|
206
|
+
* Aquí los campos que no vienen **se quedan con su sobre y su generación**: ni se abren
|
|
207
|
+
* ni se reescriben. Lo único que hay que rehacer siempre es la VISTA, porque dice qué
|
|
208
|
+
* campos lleva la entrada — y eso lo sabe quien la cambia, sin abrir ningún valor.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} o
|
|
211
|
+
* `id` la entrada
|
|
212
|
+
* `view` la vista nueva (sobre), obligatoria: cambia con cualquier campo
|
|
213
|
+
* `fields` solo los campos que cambian `{ clave: sobre }`
|
|
214
|
+
* `drop` las claves que se van
|
|
215
|
+
* `digests` los resúmenes de lo que cambió (los de `drop` se quitan)
|
|
216
|
+
* `idx`/`wa` si cambian los sitios o la passkey
|
|
217
|
+
* `main` / `passkeys` `{ wraps }` de las llaves nuevas
|
|
218
|
+
*/
|
|
219
|
+
async patchSealed ({ id, view, fields = {}, drop = [], digests = {}, priv = null, idx = null, wa, main, passkeys = null, author = null, by = null } = {}) {
|
|
220
|
+
if (!isEnv(view)) throw new SealedError('bad-entry', 'a patch has to bring the public view again')
|
|
221
|
+
for (const [k, e] of Object.entries(fields)) {
|
|
222
|
+
if (!isEnv(e)) throw new SealedError('bad-entry', `field «${k}» is not a sealed envelope`)
|
|
223
|
+
}
|
|
224
|
+
await this.#checkWraps('main', main?.wraps)
|
|
225
|
+
if (passkeys) await this.#checkWraps('passkeys', passkeys.wraps)
|
|
226
|
+
if (fields[PASSKEY_FIELD] && !passkeys) {
|
|
227
|
+
throw new SealedError('passkeys-generation-missing',
|
|
228
|
+
'that patch carries a passkey: it needs its own wraps, only for devices with `passkeys`')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const s = await this.#state()
|
|
232
|
+
const e = s.entries.find((x) => x.id === id)
|
|
233
|
+
if (!e) throw new SealedError('not-found', 'there is no entry with that id')
|
|
234
|
+
|
|
235
|
+
if (!author?.pub || !author?.sig) throw new SealedError('unsigned', 'the patch is not signed by its author')
|
|
236
|
+
const body = patchAuthorBody({ id, view, fields, drop, digests, priv, idx, wa }, author.ts)
|
|
237
|
+
if (!(await this.verifyAuthor({ body, author }))) {
|
|
238
|
+
throw new SealedError('bad-author', 'that patch was not signed by a device allowed to write passwords')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const t = this.now()
|
|
242
|
+
const siguiente = () => (s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1)
|
|
243
|
+
const genMain = siguiente()
|
|
244
|
+
s.keyring.push({ gen: genMain, kind: 'main', createdAt: t, wraps: main.wraps })
|
|
245
|
+
let genPk = null
|
|
246
|
+
if (passkeys) {
|
|
247
|
+
genPk = siguiente()
|
|
248
|
+
s.keyring.push({ gen: genPk, kind: 'passkeys', createdAt: t, wraps: passkeys.wraps })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const k of drop) { delete e.fields[k]; delete e.digests[k] }
|
|
252
|
+
for (const [k, env] of Object.entries(fields)) {
|
|
253
|
+
e.fields[k] = { ...env, gen: k === PASSKEY_FIELD ? genPk : genMain }
|
|
254
|
+
}
|
|
255
|
+
for (const [k, d] of Object.entries(digests)) e.digests[k] = d
|
|
256
|
+
e.view = { ...view, gen: genMain }
|
|
257
|
+
if (Array.isArray(priv)) e.priv = [...priv]
|
|
258
|
+
if (Array.isArray(idx)) e.idx = [...idx]
|
|
259
|
+
if (wa !== undefined) e.wa = wa
|
|
260
|
+
e.updatedAt = t
|
|
261
|
+
e.by = by || author.pub
|
|
262
|
+
e.author = author
|
|
263
|
+
|
|
264
|
+
this.#gc(s)
|
|
265
|
+
await this.#save(s)
|
|
266
|
+
return { id: e.id, gen: genMain, updatedAt: e.updatedAt }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* LAS VISTAS que este aparato puede abrir. Es lo que sustituye a `list`, `search` y
|
|
271
|
+
* `sites`: la bóveda ya no puede buscar por texto —no ve ninguno— así que entrega las
|
|
272
|
+
* vistas selladas y busca quien las abre (§2.2).
|
|
273
|
+
*
|
|
274
|
+
* **Vistas, no valores**: de aquí no sale ninguna contraseña. Y solo las que este aparato
|
|
275
|
+
* puede abrir: a quien lleva una selección de entradas no se le dice ni que existen las
|
|
276
|
+
* demás (§2.6).
|
|
277
|
+
*/
|
|
278
|
+
async views ({ pub } = {}) {
|
|
279
|
+
const s = await this.#state()
|
|
280
|
+
const out = []
|
|
281
|
+
for (const e of s.entries) {
|
|
282
|
+
const wrap = this.#wrapFor(s, e.view.gen, pub)
|
|
283
|
+
if (!wrap) continue
|
|
284
|
+
out.push({ id: e.id, updatedAt: e.updatedAt, idx: e.idx, wa: e.wa, view: e.view, wrap, digests: e.digests, author: e.author })
|
|
285
|
+
}
|
|
286
|
+
return out
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* QUÉ HAY PARA ESTE SITIO. `idx` son las huellas que calculó el aparato con la llave del
|
|
291
|
+
* perfil: la bóveda compara cadenas, sin saber de qué sitio se habla.
|
|
292
|
+
*
|
|
293
|
+
* `anySite` trae además las entradas SIN sitios, que son las que sirven en cualquier
|
|
294
|
+
* parte — es lo que ha significado siempre no tener sitios.
|
|
295
|
+
*/
|
|
296
|
+
async find ({ pub, idx = [], anySite = true } = {}) {
|
|
297
|
+
const busca = new Set(idx)
|
|
298
|
+
return (await this.views({ pub })).filter((v) =>
|
|
299
|
+
v.idx.some((h) => busca.has(h)) || (anySite && v.idx.length === 0))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Lo mismo para una passkey: por el sitio que la pide o por el id de la credencial. */
|
|
303
|
+
async findPasskey ({ pub, rp = null, cred = null } = {}) {
|
|
304
|
+
return (await this.views({ pub })).filter((v) =>
|
|
305
|
+
v.wa && ((rp && v.wa.rp === rp) || (cred && v.wa.cred === cred)))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* LOS SOBRES DE LOS CAMPOS PEDIDOS, con la envoltura de quien pregunta.
|
|
310
|
+
*
|
|
311
|
+
* Lo que este aparato no puede abrir no se manda y **se dice** (`withheld`): una passkey
|
|
312
|
+
* para un aparato sin el permiso se queda aquí, y la pantalla lo explica en vez de
|
|
313
|
+
* enseñar un hueco.
|
|
314
|
+
*/
|
|
315
|
+
async get (id, { pub, keys = null } = {}) {
|
|
316
|
+
const s = await this.#state()
|
|
317
|
+
const e = s.entries.find((x) => x.id === id)
|
|
318
|
+
if (!e) throw new SealedError('not-found', 'there is no entry with that id')
|
|
319
|
+
const quiere = Array.isArray(keys) ? new Set(keys) : null
|
|
320
|
+
const envelopes = {}
|
|
321
|
+
const wraps = {}
|
|
322
|
+
const withheld = []
|
|
323
|
+
const vista = this.#wrapFor(s, e.view.gen, pub)
|
|
324
|
+
if (!vista) throw new SealedError('not-yours', 'this device has no key for that entry')
|
|
325
|
+
for (const [k, env] of Object.entries(e.fields)) {
|
|
326
|
+
if (quiere && !quiere.has(k)) continue
|
|
327
|
+
const w = this.#wrapFor(s, env.gen, pub)
|
|
328
|
+
if (!w) { withheld.push(k); continue }
|
|
329
|
+
envelopes[k] = env
|
|
330
|
+
wraps[env.gen] = w
|
|
331
|
+
}
|
|
332
|
+
wraps[e.view.gen] = vista
|
|
333
|
+
return { id: e.id, view: e.view, envelopes, wraps, withheld, updatedAt: e.updatedAt, digests: e.digests }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* QUÉ CLAVES DE ESA ENTRADA SON PRIVADAS. Es lo único de la vista que la bóveda ve, y lo
|
|
338
|
+
* ve porque la aprobación es suya: sin esto no podría distinguir «rellena mi nombre» de
|
|
339
|
+
* «dame la contraseña» (ver la nota en `entry.js`).
|
|
340
|
+
*/
|
|
341
|
+
async privateKeysOf (id) {
|
|
342
|
+
const s = await this.#state()
|
|
343
|
+
return s.entries.find((e) => e.id === id)?.priv || []
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async remove (id) {
|
|
347
|
+
const s = await this.#state()
|
|
348
|
+
const antes = s.entries.length
|
|
349
|
+
s.entries = s.entries.filter((e) => e.id !== id)
|
|
350
|
+
if (s.entries.length === antes) return { ok: false }
|
|
351
|
+
this.#gc(s)
|
|
352
|
+
await this.#save(s)
|
|
353
|
+
return { ok: true }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* LA LLAVE DEL PERFIL (de la que salen el índice y los resúmenes), sellada como un valor
|
|
358
|
+
* más. Se escribe una vez y se vuelve a repartir cuando cambia quién puede leer.
|
|
359
|
+
*/
|
|
360
|
+
async profile ({ pub } = {}) {
|
|
361
|
+
const s = await this.#state()
|
|
362
|
+
if (!s.profile) throw new SealedError('not-sealed', 'this vault has no sealed password key yet: open it to convert')
|
|
363
|
+
const wrap = this.#wrapFor(s, s.profile.gen, pub)
|
|
364
|
+
if (!wrap) throw new SealedError('not-yours', 'this device has no key for the profile password key')
|
|
365
|
+
return { envelope: s.profile, wrap }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async setProfile ({ envelope, wraps } = {}) {
|
|
369
|
+
if (!isEnv(envelope)) throw new SealedError('bad-entry', 'the profile key is not a sealed envelope')
|
|
370
|
+
await this.#checkWraps('main', wraps)
|
|
371
|
+
const s = await this.#state()
|
|
372
|
+
const gen = s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1
|
|
373
|
+
s.keyring.push({ gen, kind: 'main', createdAt: this.now(), wraps })
|
|
374
|
+
s.profile = { ...envelope, gen }
|
|
375
|
+
this.#gc(s)
|
|
376
|
+
await this.#save(s)
|
|
377
|
+
return { gen }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* LAS GENERACIONES QUE YA NO ABRE NADA se van. Cada escritura estrena una, así que sin
|
|
382
|
+
* este barrido el llavero crece para siempre (§2.4).
|
|
383
|
+
*/
|
|
384
|
+
#gc (s) {
|
|
385
|
+
const vivas = new Set()
|
|
386
|
+
if (s.profile?.gen) vivas.add(s.profile.gen)
|
|
387
|
+
for (const e of s.entries) {
|
|
388
|
+
vivas.add(e.view.gen)
|
|
389
|
+
for (const env of Object.values(e.fields)) vivas.add(env.gen)
|
|
390
|
+
}
|
|
391
|
+
s.keyring = s.keyring.filter((g) => vivas.has(g.gen))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* PAGAR LA DEUDA: reenvolver cada generación para exactamente quien dice el acta AHORA.
|
|
396
|
+
*
|
|
397
|
+
* Lo hace quien puede abrir la copia de RECUPERACIÓN, o sea la bóveda con la frase del
|
|
398
|
+
* perfil delante — al abrirla, que es cuando la tiene (§2.5). Sin esto, un aparato que
|
|
399
|
+
* entra después de escrita una entrada no puede abrirla nunca: nadie más tiene con qué
|
|
400
|
+
* envolvérsela.
|
|
401
|
+
*
|
|
402
|
+
* Y de paso se va lo que sobra: la envoltura de quien ya no está en la lista. Ni se
|
|
403
|
+
* descifra ni se reescribe ningún valor — solo cambian las envolturas de las llaves.
|
|
404
|
+
*
|
|
405
|
+
* **O lo hace un MIEMBRO que ya tiene su envoltura** (`holder`). Es el caso de la bóveda de
|
|
406
|
+
* dentro de la extensión: ella misma está entre los destinatarios de todo, así que puede
|
|
407
|
+
* completar a un aparato que entró después sin pedirle a nadie la frase. No regala nada:
|
|
408
|
+
* quien hace esto ya podía leerlo, y a quien se lo entrega lo reconoce el acta. La llave
|
|
409
|
+
* tampoco sale de él — `rewrapFor` es de `@dotrino/identity`, que abre y vuelve a
|
|
410
|
+
* envolver sin devolverla.
|
|
411
|
+
*
|
|
412
|
+
* @param {object} o hace falta uno de los dos, y si están los dos manda `holder`
|
|
413
|
+
* @param {(wrap:object) => Promise<string>} [o.openRecovery] abre la envoltura `#recovery`
|
|
414
|
+
* @param {{ pub:string, rewrapFor:({wrap, encPub}) => Promise<object> }} [o.holder]
|
|
415
|
+
* un miembro con envoltura propia: su pública y cómo re-envuelve la llave que ya abre
|
|
416
|
+
*/
|
|
417
|
+
async rewrapAll ({ openRecovery, holder } = {}) {
|
|
418
|
+
const conHolder = !!holder?.pub && typeof holder.rewrapFor === 'function'
|
|
419
|
+
if (typeof openRecovery !== 'function' && !conHolder) {
|
|
420
|
+
throw new Error('rewrapAll: missing openRecovery(wrap) or holder { pub, rewrapFor }')
|
|
421
|
+
}
|
|
422
|
+
const s = await this.#state()
|
|
423
|
+
const out = { generations: 0, wrapped: 0, dropped: 0, failed: [] }
|
|
424
|
+
for (const g of s.keyring) {
|
|
425
|
+
const kind = g.kind || 'main'
|
|
426
|
+
const deben = (await this.recipients(kind)).filter((m) => m?.encPub)
|
|
427
|
+
const antes = Object.keys(g.wraps).filter((k) => k !== RECOVERY)
|
|
428
|
+
const faltan = deben.filter((m) => !g.wraps[m.pub])
|
|
429
|
+
const sobran = antes.filter((p) => !deben.some((m) => m.pub === p))
|
|
430
|
+
if (!faltan.length && !sobran.length) continue
|
|
431
|
+
try {
|
|
432
|
+
if (faltan.length) {
|
|
433
|
+
const suya = conHolder ? g.wraps[holder.pub] : null
|
|
434
|
+
if (suya) {
|
|
435
|
+
for (const m of faltan) { g.wraps[m.pub] = await holder.rewrapFor({ wrap: suya, encPub: m.encPub }); out.wrapped++ }
|
|
436
|
+
} else if (typeof openRecovery === 'function') {
|
|
437
|
+
const cek = await openRecovery(g.wraps[RECOVERY])
|
|
438
|
+
for (const m of faltan) { g.wraps[m.pub] = await wrapForMember({ cek, memberEncPub: m.encPub }); out.wrapped++ }
|
|
439
|
+
} else {
|
|
440
|
+
// Ni envoltura propia en esta generación ni la frase: no hay con qué, y se dice.
|
|
441
|
+
throw new SealedError('no-opener', 'this generation is not wrapped for the holder and no recovery was given')
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
for (const p of sobran) { delete g.wraps[p]; out.dropped++ }
|
|
445
|
+
out.generations++
|
|
446
|
+
} catch (e) {
|
|
447
|
+
out.failed.push({ gen: g.gen, error: e?.message || String(e) })
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (out.wrapped || out.dropped) await this.#save(s)
|
|
451
|
+
return out
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* LA COPIA DE RECUPERACIÓN LLEGA DESPUÉS. Lo guardado antes de crearla solo lo abren los
|
|
456
|
+
* aparatos del acta; esto le añade a cada generación la envoltura `#recovery`, con la
|
|
457
|
+
* llave de un miembro que ya la abre (`holder`, como en `rewrapAll`). La llave no sale
|
|
458
|
+
* de él: `rewrapFor` abre y vuelve a envolver sin devolverla.
|
|
459
|
+
*
|
|
460
|
+
* Lo que no se pudo envolver se devuelve en `failed`, no se calla.
|
|
461
|
+
*/
|
|
462
|
+
async addRecovery ({ recoveryPub, holder } = {}) {
|
|
463
|
+
if (!recoveryPub) throw new Error('addRecovery: missing recoveryPub')
|
|
464
|
+
if (!holder?.pub || typeof holder.rewrapFor !== 'function') throw new Error('addRecovery: missing holder { pub, rewrapFor }')
|
|
465
|
+
const s = await this.#state()
|
|
466
|
+
const out = { wrapped: 0, failed: [] }
|
|
467
|
+
for (const g of s.keyring) {
|
|
468
|
+
if (g.wraps?.[RECOVERY]) continue
|
|
469
|
+
const suya = g.wraps?.[holder.pub]
|
|
470
|
+
if (!suya) { out.failed.push({ gen: g.gen, error: 'this generation is not wrapped for the holder' }); continue }
|
|
471
|
+
try {
|
|
472
|
+
g.wraps[RECOVERY] = await holder.rewrapFor({ wrap: suya, encPub: recoveryPub })
|
|
473
|
+
out.wrapped++
|
|
474
|
+
} catch (e) {
|
|
475
|
+
out.failed.push({ gen: g.gen, error: e?.message || String(e) })
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (out.wrapped) await this.#save(s)
|
|
479
|
+
return out
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* QUIÉN SE QUEDÓ SIN ENVOLTURA, para que la consola lo diga en vez de que un aparato se
|
|
484
|
+
* encuentre con que no puede abrir nada (§2.5). No hace falta ninguna llave: se mira
|
|
485
|
+
* quién debería estar en cada generación y quién está.
|
|
486
|
+
*/
|
|
487
|
+
async incompleteMembers () {
|
|
488
|
+
const s = await this.#state()
|
|
489
|
+
const faltan = new Map()
|
|
490
|
+
for (const kind of ['main', 'passkeys']) {
|
|
491
|
+
const deben = (await this.recipients(kind)).filter((m) => m?.encPub).map((m) => m.pub)
|
|
492
|
+
for (const g of s.keyring.filter((x) => (x.kind || 'main') === kind)) {
|
|
493
|
+
for (const pub of deben) {
|
|
494
|
+
if (!g.wraps?.[pub]) faltan.set(pub, (faltan.get(pub) || 0) + 1)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return [...faltan].map(([pub, generations]) => ({ pub, generations }))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Cuántas entradas y cuántas generaciones. Diagnóstico: no abre nada. */
|
|
502
|
+
async stats () {
|
|
503
|
+
const s = await this.#state()
|
|
504
|
+
return { entries: s.entries.length, generations: s.keyring.length, sealed: !!s.profile }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export default { SealedStore, SealedError, KEY, RECOVERY }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Recuerdo de lo YA entregado, durante esta sesión del navegador.
|
|
2
|
+
//
|
|
3
|
+
// No es la caché que se descartó (DISENO §3.1): aquella era una copia de la bóveda
|
|
4
|
+
// entera, y para abrirla hacía falta la llave. Esto guarda solo las credenciales que
|
|
5
|
+
// la bóveda ya entregó, en memoria de sesión, y muere al cerrar el navegador.
|
|
6
|
+
//
|
|
7
|
+
// Para qué: entrar tres veces al mismo sitio en una tarde no debería ser tres
|
|
8
|
+
// aprobaciones en el teléfono. Lo que nunca pediste, nunca estuvo aquí.
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TTL = 5 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
export class SessionCache {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} store almacén de SESIÓN `{ get(k), set(k, v) }` — en la
|
|
15
|
+
* extensión, `chrome.storage.session`: memoria, nunca disco.
|
|
16
|
+
* @param {object} opts `{ ttlMs, now }`
|
|
17
|
+
*/
|
|
18
|
+
constructor (store, { ttlMs = DEFAULT_TTL, now = () => Date.now() } = {}) {
|
|
19
|
+
this.store = store
|
|
20
|
+
this.ttlMs = ttlMs
|
|
21
|
+
this.now = now
|
|
22
|
+
this.key = 'passmanager/session/v1'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async #all () {
|
|
26
|
+
return (await this.store.get(this.key)) || {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Devuelve la entrada si sigue vigente. Una caducada se tira al pasar por ella. */
|
|
30
|
+
async get (id) {
|
|
31
|
+
const all = await this.#all()
|
|
32
|
+
const hit = all[id]
|
|
33
|
+
if (!hit) return null
|
|
34
|
+
if (this.now() - hit.ts > this.ttlMs) {
|
|
35
|
+
delete all[id]
|
|
36
|
+
await this.store.set(this.key, all)
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
return hit.entry
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Guarda lo entregado. */
|
|
43
|
+
async put (id, entry) {
|
|
44
|
+
if (!entry) return false
|
|
45
|
+
const all = await this.#all()
|
|
46
|
+
all[id] = { entry, ts: this.now() }
|
|
47
|
+
await this.store.set(this.key, all)
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async forget (id) {
|
|
52
|
+
const all = await this.#all()
|
|
53
|
+
if (id) delete all[id]
|
|
54
|
+
await this.store.set(this.key, id ? all : {})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async size () {
|
|
58
|
+
return Object.keys(await this.#all()).length
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Códigos de dos pasos (TOTP, RFC 6238 / HOTP, RFC 4226).
|
|
2
|
+
//
|
|
3
|
+
// Se guardan como URI `otpauth://`, que es lo que exportan e importan todos los
|
|
4
|
+
// demás y lo que lleva un QR. El secreto viaja cifrado con la CEK como cualquier
|
|
5
|
+
// otro campo (ver crypto.js); aquí solo se calcula el código.
|
|
6
|
+
|
|
7
|
+
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
8
|
+
|
|
9
|
+
export function base32Decode (input) {
|
|
10
|
+
const s = String(input || '').toUpperCase().replace(/=+$/, '').replace(/\s+/g, '')
|
|
11
|
+
let bits = 0
|
|
12
|
+
let value = 0
|
|
13
|
+
const out = []
|
|
14
|
+
for (const ch of s) {
|
|
15
|
+
const idx = B32.indexOf(ch)
|
|
16
|
+
if (idx === -1) throw new Error('base32: carácter inválido')
|
|
17
|
+
value = (value << 5) | idx
|
|
18
|
+
bits += 5
|
|
19
|
+
if (bits >= 8) {
|
|
20
|
+
bits -= 8
|
|
21
|
+
out.push((value >>> bits) & 0xff)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return new Uint8Array(out)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bitwarden (y algún otro) exporta el TOTP como el secreto base32 pelado, sin URI.
|
|
29
|
+
* Se acepta y se envuelve, para que el resto del código vea siempre un `otpauth://`.
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeOtpauth (input, label = 'dotrino') {
|
|
32
|
+
const s = String(input || '').trim()
|
|
33
|
+
if (!s) return ''
|
|
34
|
+
if (s.toLowerCase().startsWith('otpauth://')) return s
|
|
35
|
+
return `otpauth://totp/${encodeURIComponent(label)}?secret=${encodeURIComponent(s.replace(/\s+/g, ''))}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parsea `otpauth://totp/Emisor:cuenta?secret=...&digits=6&period=30&algorithm=SHA1`. */
|
|
39
|
+
export function parseOtpauth (uri) {
|
|
40
|
+
const u = new URL(normalizeOtpauth(uri))
|
|
41
|
+
if (u.protocol !== 'otpauth:') throw new Error('otpauth: esquema inválido')
|
|
42
|
+
const type = u.hostname.toLowerCase()
|
|
43
|
+
if (type !== 'totp' && type !== 'hotp') throw new Error('otpauth: tipo no soportado')
|
|
44
|
+
const q = u.searchParams
|
|
45
|
+
const secret = q.get('secret')
|
|
46
|
+
if (!secret) throw new Error('otpauth: falta secret')
|
|
47
|
+
|
|
48
|
+
const label = decodeURIComponent(u.pathname.replace(/^\//, ''))
|
|
49
|
+
const [maybeIssuer, account] = label.includes(':') ? label.split(/:(.+)/) : [null, label]
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
type,
|
|
53
|
+
secret,
|
|
54
|
+
issuer: q.get('issuer') || maybeIssuer || '',
|
|
55
|
+
account: (account || '').trim(),
|
|
56
|
+
digits: clampDigits(q.get('digits')),
|
|
57
|
+
period: clampPeriod(q.get('period')),
|
|
58
|
+
algorithm: (q.get('algorithm') || 'SHA1').toUpperCase(),
|
|
59
|
+
counter: Number(q.get('counter') || 0),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clampDigits (v) {
|
|
64
|
+
const n = Number(v || 6)
|
|
65
|
+
return Number.isInteger(n) && n >= 6 && n <= 10 ? n : 6
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clampPeriod (v) {
|
|
69
|
+
const n = Number(v || 30)
|
|
70
|
+
return Number.isInteger(n) && n > 0 && n <= 300 ? n : 30
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const HASHES = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' }
|
|
74
|
+
|
|
75
|
+
async function hotp (secretBytes, counter, digits, algorithm) {
|
|
76
|
+
const hash = HASHES[algorithm]
|
|
77
|
+
if (!hash) throw new Error('otp: algoritmo no soportado: ' + algorithm)
|
|
78
|
+
|
|
79
|
+
const buf = new ArrayBuffer(8)
|
|
80
|
+
const view = new DataView(buf)
|
|
81
|
+
view.setBigUint64(0, BigInt(counter), false)
|
|
82
|
+
|
|
83
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
84
|
+
'raw', secretBytes, { name: 'HMAC', hash }, false, ['sign'])
|
|
85
|
+
const mac = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, buf))
|
|
86
|
+
|
|
87
|
+
const offset = mac[mac.length - 1] & 0x0f
|
|
88
|
+
const code = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) |
|
|
89
|
+
(mac[offset + 2] << 8) | mac[offset + 3]
|
|
90
|
+
return String(code % 10 ** digits).padStart(digits, '0')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Código actual y cuántos segundos le quedan.
|
|
95
|
+
* `now` en milisegundos, para poder probarlo con vectores fijos.
|
|
96
|
+
*/
|
|
97
|
+
export async function totpNow (uri, now = Date.now()) {
|
|
98
|
+
const cfg = parseOtpauth(uri)
|
|
99
|
+
const secret = base32Decode(cfg.secret)
|
|
100
|
+
const seconds = Math.floor(now / 1000)
|
|
101
|
+
|
|
102
|
+
if (cfg.type === 'hotp') {
|
|
103
|
+
return { code: await hotp(secret, cfg.counter, cfg.digits, cfg.algorithm), expiresIn: null, cfg }
|
|
104
|
+
}
|
|
105
|
+
const counter = Math.floor(seconds / cfg.period)
|
|
106
|
+
return {
|
|
107
|
+
code: await hotp(secret, counter, cfg.digits, cfg.algorithm),
|
|
108
|
+
expiresIn: cfg.period - (seconds % cfg.period),
|
|
109
|
+
cfg,
|
|
110
|
+
}
|
|
111
|
+
}
|