@dotrino/vault 0.91.1 → 0.93.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 +1 -1
- package/src/atrest.js +4 -4
- package/src/core/libVersion.js +1 -1
- package/src/core/subacta.js +3 -3
- package/src/core/vault.js +27 -27
- package/src/enroll.js +76 -28
- package/src/index.js +2 -2
- package/src/passwordLogins.js +1 -1
- package/src/service.js +20 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.0",
|
|
4
4
|
"description": "La bóveda de Dotrino como librería: el MISMO núcleo que el binario corriendo en el navegador (`@dotrino/vault/browser`), el mostrador de emparejamiento y el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/atrest.js
CHANGED
|
@@ -169,18 +169,18 @@ export function atRestFor (dir, { password = '' } = {}) {
|
|
|
169
169
|
/**
|
|
170
170
|
* Cifra AHORA un archivo que esté en claro, verificando antes de reemplazar: se escribe a un
|
|
171
171
|
* temporal, se vuelve a leer y descifrar, se compara, y solo entonces se renombra encima.
|
|
172
|
-
* Devuelve '
|
|
172
|
+
* Devuelve 'already-encrypted' | 'migrated' | 'no-file'.
|
|
173
173
|
*/
|
|
174
174
|
export function migrateFile (file, key) {
|
|
175
175
|
let text
|
|
176
|
-
try { text = fs.readFileSync(file, 'utf8') } catch (_) { return '
|
|
177
|
-
if (isEncrypted(text)) return '
|
|
176
|
+
try { text = fs.readFileSync(file, 'utf8') } catch (_) { return 'no-file' }
|
|
177
|
+
if (isEncrypted(text)) return 'already-encrypted'
|
|
178
178
|
const blob = encryptText(text, key)
|
|
179
179
|
if (decryptText(blob, key) !== text) throw new Error('at-rest: verification failed, the original is untouched')
|
|
180
180
|
const tmp = file + '.atrest.tmp'
|
|
181
181
|
fs.writeFileSync(tmp, blob, { mode: 0o600 })
|
|
182
182
|
fs.renameSync(tmp, file)
|
|
183
|
-
return '
|
|
183
|
+
return 'migrated'
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
/** Los archivos de este directorio que están cifrados por nosotros. */
|
package/src/core/libVersion.js
CHANGED
package/src/core/subacta.js
CHANGED
|
@@ -75,12 +75,12 @@ export function openSubacta (disk) {
|
|
|
75
75
|
* aparatos se cuentan lo mismo.
|
|
76
76
|
*/
|
|
77
77
|
async add (e) {
|
|
78
|
-
if (!(await isAcceptable(e))) return { ok: false, reason: '
|
|
78
|
+
if (!(await isAcceptable(e))) return { ok: false, reason: 'not-acceptable' }
|
|
79
79
|
const id = idOf(e)
|
|
80
|
-
if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: '
|
|
80
|
+
if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: 'already-present' }
|
|
81
81
|
data.entries = [...data.entries, e]
|
|
82
82
|
guardar()
|
|
83
|
-
return { ok: true, reason: '
|
|
83
|
+
return { ok: true, reason: 'new' }
|
|
84
84
|
},
|
|
85
85
|
|
|
86
86
|
/**
|
package/src/core/vault.js
CHANGED
|
@@ -872,13 +872,13 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
872
872
|
// acta falta justo cuando algo se rompió (un archivo a medias, un `catch` que devolvió
|
|
873
873
|
// null), que es cuando menos hay que fiarse. Toda cuenta tiene acta desde hace versiones.
|
|
874
874
|
if (!record) {
|
|
875
|
-
audit('rejected', { what, reason: '
|
|
875
|
+
audit('rejected', { what, reason: 'no-record' })
|
|
876
876
|
reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
877
877
|
return false
|
|
878
878
|
}
|
|
879
879
|
if (Acta.memberCanScope(record, chk.device, scope)) return true
|
|
880
|
-
audit('rejected', { what, reason: '
|
|
881
|
-
reply(from, { type: MSG.ERROR, error: 'unauthorized:
|
|
880
|
+
audit('rejected', { what, reason: 'record' })
|
|
881
|
+
reply(from, { type: MSG.ERROR, error: 'unauthorized: record — this member no longer has that permission' })
|
|
882
882
|
return false
|
|
883
883
|
}
|
|
884
884
|
|
|
@@ -976,14 +976,14 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
976
976
|
const t = Date.now()
|
|
977
977
|
for (const [n, v] of aperturas) if (v.exp <= t) aperturas.delete(n)
|
|
978
978
|
const eph = await makeEphemeralKey()
|
|
979
|
-
aperturas.set('
|
|
979
|
+
aperturas.set('pending', { privateKey: eph.privateKey, exp: t + EPHEMERAL_TTL_MS })
|
|
980
980
|
audit('unlock.begin', { by })
|
|
981
981
|
return { ...params, ek: eph.ek }
|
|
982
982
|
},
|
|
983
983
|
|
|
984
984
|
async open ({ nonce, enc, by }) {
|
|
985
|
-
const guardada = aperturas.get('
|
|
986
|
-
aperturas.delete('
|
|
985
|
+
const guardada = aperturas.get('pending')
|
|
986
|
+
aperturas.delete('pending')
|
|
987
987
|
if (!guardada || guardada.exp <= Date.now()) {
|
|
988
988
|
return { ok: false, error: 'unlock: ask for a key first, and use it within a minute' }
|
|
989
989
|
}
|
|
@@ -992,7 +992,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
992
992
|
// EL NONCE VA DENTRO DEL SOBRE. Si fuera solo por fuera, un sobre capturado se
|
|
993
993
|
// reenvía con un nonce nuevo y el candado del nonce no sirve de nada.
|
|
994
994
|
if (!payload || payload.nonce !== nonce || typeof payload.key !== 'string') {
|
|
995
|
-
audit('rejected', { what: 'unlock', by, reason: '
|
|
995
|
+
audit('rejected', { what: 'unlock', by, reason: 'envelope' })
|
|
996
996
|
return { ok: false, error: 'unlock: the envelope does not check out' }
|
|
997
997
|
}
|
|
998
998
|
const derivada = b64ToBytes(payload.key)
|
|
@@ -1314,10 +1314,10 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1314
1314
|
const { issued, revoked } = await identity.listDelegations()
|
|
1315
1315
|
if (!puedeVerTodo) {
|
|
1316
1316
|
if (!record) {
|
|
1317
|
-
audit('rejected', { what: 'devices', reason: '
|
|
1317
|
+
audit('rejected', { what: 'devices', reason: 'no-record' })
|
|
1318
1318
|
return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
1319
1319
|
}
|
|
1320
|
-
audit('devices', { device: await deviceIdOf(chk.device).catch(() => null),
|
|
1320
|
+
audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), only: 'revocations' })
|
|
1321
1321
|
return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, blocked: blocked.pubs(), acta: record, sealerChain: await sealerChainOf() })
|
|
1322
1322
|
}
|
|
1323
1323
|
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
@@ -1401,7 +1401,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1401
1401
|
// Sin acta no se renueva: caer al scope del papel viejo es reconceder a ciegas lo que
|
|
1402
1402
|
// el acta ya no dice. Un repliegue así no protege de nada, tapa.
|
|
1403
1403
|
if (!record) {
|
|
1404
|
-
audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: '
|
|
1404
|
+
audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'no-record' })
|
|
1405
1405
|
return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
1406
1406
|
}
|
|
1407
1407
|
const scope = Acta.memberScopes(record, p.cert.sub)
|
|
@@ -1460,7 +1460,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1460
1460
|
// que se cerró en `handleSign`, en otro mostrador.
|
|
1461
1461
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1462
1462
|
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1463
|
-
audit('rejected', { what: 'enckey', ns, reason: record ? 'cn' : '
|
|
1463
|
+
audit('rejected', { what: 'enckey', ns, reason: record ? 'cn' : 'no-record' })
|
|
1464
1464
|
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1465
1465
|
}
|
|
1466
1466
|
try {
|
|
@@ -1545,7 +1545,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1545
1545
|
if (!chk.ok) return denyChain(from, chk, p, 'digest')
|
|
1546
1546
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1547
1547
|
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1548
|
-
audit('rejected', { what: 'digest', ns, reason: record ? 'cn' : '
|
|
1548
|
+
audit('rejected', { what: 'digest', ns, reason: record ? 'cn' : 'no-record' })
|
|
1549
1549
|
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1550
1550
|
}
|
|
1551
1551
|
const body = { op: 'secrets.digest', ns, digest: drawerDigest(ns, chk.device), ts: Date.now() }
|
|
@@ -1578,8 +1578,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1578
1578
|
if (!chk.ok) return denyChain(from, chk, p, 'update-ask')
|
|
1579
1579
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1580
1580
|
if (!record || !(record.members || []).some((m) => m.pub === chk.device)) {
|
|
1581
|
-
audit('rejected', { what: 'update-ask', reason: record ? '
|
|
1582
|
-
return reply(from, { type: MSG.ERROR, error: 'unauthorized:
|
|
1581
|
+
audit('rejected', { what: 'update-ask', reason: record ? 'record' : 'no-record' })
|
|
1582
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: record — this device is not in the record' })
|
|
1583
1583
|
}
|
|
1584
1584
|
const product = cut(p.data?.product, 128)
|
|
1585
1585
|
const version = cut(p.data?.version, 64)
|
|
@@ -1656,8 +1656,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1656
1656
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1657
1657
|
const member = (record?.members || []).find((m) => m.pub === chk.device)
|
|
1658
1658
|
if (!member) {
|
|
1659
|
-
audit('rejected', { what: op, reason: record ? '
|
|
1660
|
-
return reply(from, { type: MSG.ERROR, error: 'unauthorized:
|
|
1659
|
+
audit('rejected', { what: op, reason: record ? 'record' : 'no-record' })
|
|
1660
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: record — this device is not in the record' })
|
|
1661
1661
|
}
|
|
1662
1662
|
const product = cut(p.data?.product, 128)
|
|
1663
1663
|
const version = cut(p.data?.version, 64)
|
|
@@ -1685,7 +1685,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1685
1685
|
if (!chk.ok) return denyChain(from, chk, p, op)
|
|
1686
1686
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1687
1687
|
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1688
|
-
audit('rejected', { what: op, ns, reason: record ? 'cn' : '
|
|
1688
|
+
audit('rejected', { what: op, ns, reason: record ? 'cn' : 'no-record' })
|
|
1689
1689
|
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1690
1690
|
}
|
|
1691
1691
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
@@ -1756,7 +1756,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1756
1756
|
// llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
|
|
1757
1757
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1758
1758
|
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1759
|
-
audit('rejected', { what: 'secrets', ns, reason: record ? 'cn' : '
|
|
1759
|
+
audit('rejected', { what: 'secrets', ns, reason: record ? 'cn' : 'no-record' })
|
|
1760
1760
|
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1761
1761
|
}
|
|
1762
1762
|
// APROBACIÓN: si este APARATO está marcado (`dotrino-vault approval <ID> on`), liberarle
|
|
@@ -1908,8 +1908,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1908
1908
|
if (!chk.ok) return denyChain(from, chk, p, 'approval')
|
|
1909
1909
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1910
1910
|
if (!record || !Acta.memberCan(record, chk.device, 'approve')) {
|
|
1911
|
-
audit('rejected', { what: 'approval', reason: record ? '
|
|
1912
|
-
return reply(from, { type: MSG.ERROR, error: 'unauthorized:
|
|
1911
|
+
audit('rejected', { what: 'approval', reason: record ? 'record' : 'no-record' })
|
|
1912
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: record — this member does not approve' })
|
|
1913
1913
|
}
|
|
1914
1914
|
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
1915
1915
|
const answer = async (body) => {
|
|
@@ -2157,8 +2157,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2157
2157
|
if (!chk.ok) return denyChain(from, chk, p, 'incident')
|
|
2158
2158
|
const record = await refreshActa()
|
|
2159
2159
|
if (!record || !Acta.memberCan(record, chk.device, 'sign')) {
|
|
2160
|
-
audit('rejected', { what: 'incident', reason: record ? '
|
|
2161
|
-
return reply(from, { type: MSG.ERROR, error: 'unauthorized:
|
|
2160
|
+
audit('rejected', { what: 'incident', reason: record ? 'record' : 'no-record' })
|
|
2161
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: record — this member cannot report incidents' })
|
|
2162
2162
|
}
|
|
2163
2163
|
const d = p.data || {}
|
|
2164
2164
|
const kind = typeof d.kind === 'string' ? d.kind.slice(0, 32) : ''
|
|
@@ -2913,7 +2913,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2913
2913
|
return chk
|
|
2914
2914
|
}
|
|
2915
2915
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
2916
|
-
if (!record || !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: record ? '
|
|
2916
|
+
if (!record || !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: record ? 'record' : 'no-record' }
|
|
2917
2917
|
return chk
|
|
2918
2918
|
}
|
|
2919
2919
|
})
|
|
@@ -2949,12 +2949,12 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2949
2949
|
// la fecha de la última renuncia por miembro y rechace las que no sean posteriores.
|
|
2950
2950
|
const actual = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2951
2951
|
if (!actual) {
|
|
2952
|
-
audit('rejected', { what: 'renounce', reason: '
|
|
2952
|
+
audit('rejected', { what: 'renounce', reason: 'no-record' })
|
|
2953
2953
|
return reply(from, { type: MSG.ERROR, error: 'renounce: this vault has no record to apply it to' })
|
|
2954
2954
|
}
|
|
2955
2955
|
const tiene = new Set(Acta.effectiveCaps(actual, record.member) || [])
|
|
2956
2956
|
if (!(record.caps || []).some((c) => tiene.has(c))) {
|
|
2957
|
-
audit('rejected', { what: 'renounce', reason: '
|
|
2957
|
+
audit('rejected', { what: 'renounce', reason: 'nothing-to-remove' })
|
|
2958
2958
|
return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
|
|
2959
2959
|
}
|
|
2960
2960
|
try {
|
|
@@ -2963,8 +2963,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2963
2963
|
// de la maestra, y con el perfil cerrado la renuncia se perdía con un error — en el
|
|
2964
2964
|
// único momento en que de verdad hace falta.
|
|
2965
2965
|
const guardada = await subacta.add(record)
|
|
2966
|
-
if (!guardada.ok && guardada.reason === '
|
|
2967
|
-
audit('rejected', { what: 'renounce', reason: '
|
|
2966
|
+
if (!guardada.ok && guardada.reason === 'not-acceptable') {
|
|
2967
|
+
audit('rejected', { what: 'renounce', reason: 'not-acceptable' })
|
|
2968
2968
|
return reply(from, { type: MSG.ERROR, error: 'renounce: the entry is not one this vault accepts' })
|
|
2969
2969
|
}
|
|
2970
2970
|
const device = await deviceIdOf(record.member).catch(() => null)
|
package/src/enroll.js
CHANGED
|
@@ -143,6 +143,23 @@ export function createEnrollDesk ({
|
|
|
143
143
|
|
|
144
144
|
const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
|
|
145
145
|
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) } }
|
|
146
|
+
/**
|
|
147
|
+
* LO QUE LA BÓVEDA LE DICE AL APARATO MIENTRAS SE EMPAREJA VA FIRMADO CON LA MAESTRA, y
|
|
148
|
+
* con el `sn` de la sesión dentro. Lo que va a la cuenta lo reciben todas sus bóvedas y
|
|
149
|
+
* cualquiera que lo vea pasar puede contestar; sin firma, un `vault.error` de cualquiera
|
|
150
|
+
* tumbaba el alta (el iPhone, haciendo de bóveda, lo hacía sin querer, 2026-10-18). El
|
|
151
|
+
* aparato solo obedece lo que verifica contra la `iss` de su invitación (`fromMyVault`).
|
|
152
|
+
*/
|
|
153
|
+
// Lo firmado va en un bloque aparte (`signed`), como en el hello: quien envía estampa lo suyo
|
|
154
|
+
// en el sobre (versión, protocolo) y la firma tiene que cubrir exactamente lo que se verifica.
|
|
155
|
+
// Los campos de arriba (`error`, `deviceId`) son los de siempre, para quien los lee.
|
|
156
|
+
const tell = async (to, obj, sn) => {
|
|
157
|
+
const signed = { ...obj, sn: sn || null, iss, ts: Date.now() }
|
|
158
|
+
let signature
|
|
159
|
+
try { ({ signature } = await identity.signData(signed)) } catch (e) { log('[vault] could not sign the reply:', e.message); return }
|
|
160
|
+
reply(to, { ...obj, signed, vsig: signature })
|
|
161
|
+
}
|
|
162
|
+
const fail = (to, error, sn) => tell(to, { type: MSG_ERROR, error }, sn)
|
|
146
163
|
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
147
164
|
|
|
148
165
|
/**
|
|
@@ -182,7 +199,9 @@ export function createEnrollDesk ({
|
|
|
182
199
|
if (!caps.length && !cn) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
|
|
183
200
|
caps = [...new Set(caps)]
|
|
184
201
|
}
|
|
185
|
-
|
|
202
|
+
// Uno a la vez: una sesión nueva supersede a la anterior. Las que se quitan se recuerdan
|
|
203
|
+
// como MÍAS: a un aparato que llegue con una de esas sí se le contesta que ya no vale.
|
|
204
|
+
for (const k of pending.keys()) forget(k)
|
|
186
205
|
const acct = String(account || '').slice(0, 40)
|
|
187
206
|
// INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
|
|
188
207
|
// nonce de la sesión. La llave, el proxy y el nombre de la cuenta los pide el
|
|
@@ -206,7 +225,21 @@ export function createEnrollDesk ({
|
|
|
206
225
|
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
207
226
|
}
|
|
208
227
|
|
|
209
|
-
function stopPairing (token) {
|
|
228
|
+
function stopPairing (token) { forget(token) }
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Las sesiones que ESTA bóveda abrió y ya no valen (superadas, paradas o gastadas), por
|
|
232
|
+
* su token y su `sn`. Distingue «la abrí yo y ya no vale» (se contesta con el error) de
|
|
233
|
+
* «no sé nada de esa invitación» (se calla: la abrió otra bóveda de la cuenta).
|
|
234
|
+
*/
|
|
235
|
+
const gone = new Set()
|
|
236
|
+
function forget (key) {
|
|
237
|
+
const pend = pending.get(key)
|
|
238
|
+
pending.delete(key)
|
|
239
|
+
if (!pend) return
|
|
240
|
+
gone.add(pend.token); if (pend.sn) gone.add(pend.sn)
|
|
241
|
+
while (gone.size > 200) gone.delete(gone.values().next().value)
|
|
242
|
+
}
|
|
210
243
|
|
|
211
244
|
function listPending () {
|
|
212
245
|
return [...pending.values()]
|
|
@@ -229,9 +262,18 @@ export function createEnrollDesk ({
|
|
|
229
262
|
*/
|
|
230
263
|
async function handleHello (from, p) {
|
|
231
264
|
const pend = pending.get(String(p?.sn || ''))
|
|
265
|
+
// UNA INVITACIÓN QUE NO ES MÍA NO SE CONTESTA. Una cuenta tiene varias bóvedas a la vez
|
|
266
|
+
// (la del VPS, la pestaña, el teléfono que hace de bóveda), y lo que va a la cuenta les
|
|
267
|
+
// llega a todas. Si la que no abrió el emparejamiento contesta «no hay sesión», ese
|
|
268
|
+
// error le gana a la respuesta buena y el aparato se queda fuera (2026-10-18: el iPhone
|
|
269
|
+
// tumbaba cada alta de un servicio). Se anota y se calla: la que la abrió, contesta.
|
|
270
|
+
if (!pend && !gone.has(String(p?.sn || ''))) {
|
|
271
|
+
audit('ignored', { what: 'hello', reason: 'not-mine' })
|
|
272
|
+
return { ignored: true }
|
|
273
|
+
}
|
|
232
274
|
if (!pend || Date.now() > pend.exp) {
|
|
233
|
-
audit('rejected', { what: 'hello', reason: '
|
|
234
|
-
return
|
|
275
|
+
audit('rejected', { what: 'hello', reason: 'no-session' })
|
|
276
|
+
return fail(from, 'no pairing session open for that code', String(p?.sn || ''))
|
|
235
277
|
}
|
|
236
278
|
// La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
|
|
237
279
|
// la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
|
|
@@ -250,42 +292,48 @@ export function createEnrollDesk ({
|
|
|
250
292
|
async function handleEnroll (from, p) {
|
|
251
293
|
const d = p?.data
|
|
252
294
|
if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
|
|
253
|
-
return
|
|
295
|
+
return fail(from, 'invalid enroll', p?.data?.sn)
|
|
254
296
|
}
|
|
255
297
|
const pend = pending.get(d.token)
|
|
298
|
+
// Lo mismo que en el hello: una sesión que esta bóveda no abrió no es suya para
|
|
299
|
+
// rechazarla. Solo contesta por las que conoce (gastada o vencida).
|
|
300
|
+
if (!pend && !gone.has(String(d.token || ''))) {
|
|
301
|
+
audit('ignored', { what: 'enroll', reason: 'not-mine' })
|
|
302
|
+
return { ignored: true }
|
|
303
|
+
}
|
|
256
304
|
if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
|
|
257
|
-
return
|
|
305
|
+
return fail(from, 'invalid or expired pairing token', d.sn)
|
|
258
306
|
}
|
|
259
|
-
if (d.sn !== pend.sn) return
|
|
307
|
+
if (d.sn !== pend.sn) return fail(from, 'invalid session', d.sn)
|
|
260
308
|
// V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
|
|
261
309
|
// bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
|
|
262
310
|
// humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
|
|
263
311
|
const intent = d.intent || 'join'
|
|
264
312
|
if (intent !== 'join' && intent !== 'adopt') {
|
|
265
|
-
return
|
|
313
|
+
return fail(from, 'unknown intent: ' + intent, d.sn)
|
|
266
314
|
}
|
|
267
315
|
if (intent !== (pend.mode || 'join')) {
|
|
268
316
|
audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
|
|
269
|
-
return
|
|
317
|
+
return fail(from, `this pairing was opened for "${pend.mode || 'join'}" and the device asked for "${intent}"`, d.sn)
|
|
270
318
|
}
|
|
271
319
|
if (!isFresh(d)) {
|
|
272
320
|
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
273
|
-
return
|
|
321
|
+
return fail(from, 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)', d.sn)
|
|
274
322
|
}
|
|
275
323
|
// PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
|
|
276
324
|
if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
|
|
277
325
|
audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
|
|
278
|
-
return
|
|
326
|
+
return fail(from, 'invalid device signature', d.sn)
|
|
279
327
|
}
|
|
280
328
|
// El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
|
|
281
329
|
// y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
|
|
282
330
|
if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
|
|
283
331
|
audit('rejected', { what: 'enroll', reason: 'no-commit' })
|
|
284
|
-
return
|
|
332
|
+
return fail(from, 'this device speaks an old pairing version (no code commitment). Update it and try again.', d.sn)
|
|
285
333
|
}
|
|
286
334
|
// Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
|
|
287
335
|
if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
|
|
288
|
-
return
|
|
336
|
+
return fail(from, 'another device is already using this pairing session', d.sn)
|
|
289
337
|
}
|
|
290
338
|
|
|
291
339
|
const deviceId = await deviceIdOf(d.dpub)
|
|
@@ -300,7 +348,7 @@ export function createEnrollDesk ({
|
|
|
300
348
|
// persona sí puede entrar sin ella: no lee variables de servicio.
|
|
301
349
|
if (typeof d.encPub === 'string') pend.encPub = d.encPub
|
|
302
350
|
if (!pend.encPub && scopeToCn(pend.scope)) {
|
|
303
|
-
return
|
|
351
|
+
return fail(from, 'a service must send its encryption key (update @dotrino/vault on the service)', d.sn)
|
|
304
352
|
}
|
|
305
353
|
// Certificado de continuidad (opcional): lo firma la identidad que se une, con su
|
|
306
354
|
// propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
|
|
@@ -322,7 +370,7 @@ export function createEnrollDesk ({
|
|
|
322
370
|
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
323
371
|
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
324
372
|
|
|
325
|
-
|
|
373
|
+
await tell(from, { type: MSG_ENROLL_CHALLENGE, deviceId }, pend.sn)
|
|
326
374
|
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', machine: pend.machine || null, mode: pend.mode || 'join' })
|
|
327
375
|
fire(onPendingChange)
|
|
328
376
|
return { deviceId }
|
|
@@ -410,8 +458,8 @@ export function createEnrollDesk ({
|
|
|
410
458
|
log('[vault] could not revoke the undelivered certificate %s: %s', cert.nonce, re.message)
|
|
411
459
|
}
|
|
412
460
|
audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'admit-failed' })
|
|
413
|
-
|
|
414
|
-
|
|
461
|
+
await fail(pend.from, 'the vault could not add this device to the account: pairing failed, try again', pend.sn)
|
|
462
|
+
forget(pend.token)
|
|
415
463
|
fire(onPendingChange)
|
|
416
464
|
log('[vault] could not add %s to the record, no certificate was delivered: %s', pend.deviceId, e.message)
|
|
417
465
|
throw Object.assign(
|
|
@@ -427,7 +475,7 @@ export function createEnrollDesk ({
|
|
|
427
475
|
// El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
|
|
428
476
|
reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta: record })
|
|
429
477
|
pend.state = 'DONE'
|
|
430
|
-
|
|
478
|
+
forget(pend.token)
|
|
431
479
|
fire(onPendingChange)
|
|
432
480
|
log(`[vault] device approved: ${pend.deviceId}`)
|
|
433
481
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
@@ -451,22 +499,22 @@ export function createEnrollDesk ({
|
|
|
451
499
|
async function handleActaSealed (from, p) {
|
|
452
500
|
const record = p?.acta
|
|
453
501
|
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
454
|
-
if (!pend) return
|
|
455
|
-
if (!record || typeof record !== 'object') return
|
|
502
|
+
if (!pend) return fail(from, 'no adoption awaiting a record', pend?.sn)
|
|
503
|
+
if (!record || typeof record !== 'object') return fail(from, 'record missing or unreadable', pend?.sn)
|
|
456
504
|
// Ya no hay campo `sealer`: se le pregunta al PERMISO. Es la misma comprobación —«¿me
|
|
457
505
|
// nombra a mí la que me mandan?»— dicha en el idioma nuevo, y con varios selladores la
|
|
458
506
|
// respuesta puede ser que sí para más de uno, que es lo correcto.
|
|
459
507
|
if (!canSeal(record, iss)) {
|
|
460
508
|
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
461
|
-
return
|
|
509
|
+
return fail(from, 'that record does not let this vault seal it', pend?.sn)
|
|
462
510
|
}
|
|
463
511
|
if (record.sealedBy !== pend.dpub) {
|
|
464
512
|
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
465
|
-
return
|
|
513
|
+
return fail(from, 'that record was not sealed by the device of this pairing', pend?.sn)
|
|
466
514
|
}
|
|
467
515
|
if (pend.profileId && record.profileId !== pend.profileId) {
|
|
468
516
|
audit('rejected', { what: 'adopt', reason: 'other-profile' })
|
|
469
|
-
return
|
|
517
|
+
return fail(from, 'that record belongs to an account other than the one the device announced', pend?.sn)
|
|
470
518
|
}
|
|
471
519
|
|
|
472
520
|
try {
|
|
@@ -478,26 +526,26 @@ export function createEnrollDesk ({
|
|
|
478
526
|
const mine = (await identity.profileActa?.())?.acta || record
|
|
479
527
|
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mine })
|
|
480
528
|
pend.state = 'DONE'
|
|
481
|
-
|
|
529
|
+
forget(pend.token)
|
|
482
530
|
fire(onPendingChange)
|
|
483
531
|
fire(onAdopted, { deviceId: pend.deviceId, profileId: record.profileId, seq: mine.seq })
|
|
484
532
|
log('[vault] account adopted from device %s (profile %s)', pend.deviceId, record.profileId?.slice(0, 12))
|
|
485
533
|
return { ok: true, adopted: true, profileId: record.profileId, seq: mine.seq }
|
|
486
534
|
} catch (e) {
|
|
487
535
|
log('[vault] could not adopt the account: %s', e.message)
|
|
488
|
-
|
|
536
|
+
fail(pend.from, 'the vault could not adopt the account: ' + e.message, pend.sn)
|
|
489
537
|
return { ok: false, error: e.message }
|
|
490
538
|
}
|
|
491
539
|
}
|
|
492
540
|
|
|
493
541
|
/** Rechaza un enrolamiento pendiente. */
|
|
494
|
-
function reject (deviceId) {
|
|
542
|
+
async function reject (deviceId) {
|
|
495
543
|
const pend = deviceId
|
|
496
544
|
? findPending(deviceId)
|
|
497
545
|
: [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
|
|
498
546
|
if (!pend) return { ok: false }
|
|
499
|
-
|
|
500
|
-
|
|
547
|
+
await fail(pend.from, 'pairing rejected', pend.sn)
|
|
548
|
+
forget(pend.token)
|
|
501
549
|
audit('reject', { device: pend.deviceId })
|
|
502
550
|
fire(onPendingChange)
|
|
503
551
|
log('[vault] dispositivo rechazado: %s', pend.deviceId)
|
package/src/index.js
CHANGED
|
@@ -261,7 +261,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
261
261
|
if (expectedScope) {
|
|
262
262
|
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
263
263
|
if (record && !memberCanScope(record, chk.device, expectedScope)) {
|
|
264
|
-
send(from, { type: MSG.ERROR, error: 'unauthorized:
|
|
264
|
+
send(from, { type: MSG.ERROR, error: 'unauthorized: record — this member no longer has that permission' })
|
|
265
265
|
return null
|
|
266
266
|
}
|
|
267
267
|
}
|
|
@@ -356,7 +356,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
356
356
|
if (p.type === MSG.HELLO) handle('hello', desk.handleHello(_from, p), _from)
|
|
357
357
|
else if (p.type === MSG.ENROLL) handle('enroll', desk.handleEnroll(_from, p), _from)
|
|
358
358
|
// Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
|
|
359
|
-
else if (p.type === MSG.ACTA_SEALED) handle('
|
|
359
|
+
else if (p.type === MSG.ACTA_SEALED) handle('record-sealed', desk.handleActaSealed(_from, p), _from)
|
|
360
360
|
else if (p.type === MSG.RENEW) handle('renew', handleRenew(_from, p), _from)
|
|
361
361
|
else if (p.type === MSG.DEVICES) handle('devices', handleDevices(_from, p), _from)
|
|
362
362
|
// Lo que faltaba para que un aparato enrolado aquí pueda hacer lo mismo que contra
|
package/src/passwordLogins.js
CHANGED
|
@@ -67,7 +67,7 @@ export const loginAddress = (user, fingerprint) => `${user}@${accountCode(finger
|
|
|
67
67
|
*/
|
|
68
68
|
export async function accountFingerprint (identity) {
|
|
69
69
|
const pid = (await identity?.profileActa?.().catch(() => null))?.acta?.profileId
|
|
70
|
-
if (!pid) throw err('no-
|
|
70
|
+
if (!pid) throw err('no-record', 'this vault has no account record yet: there is no address to build')
|
|
71
71
|
return (await pubkeyId(pid)).slice(0, 16)
|
|
72
72
|
}
|
|
73
73
|
|
package/src/service.js
CHANGED
|
@@ -307,15 +307,29 @@ function writeServiceIdentity (dir, obj) {
|
|
|
307
307
|
* Devuelve el mismo `qr` con `iss`, `proxy` y `acct` puestos. Con un QR largo no pregunta
|
|
308
308
|
* nada: ya los trae.
|
|
309
309
|
*/
|
|
310
|
+
/**
|
|
311
|
+
* ¿LO DICE MI BÓVEDA, DE ESTA SESIÓN? Lo que va a la cuenta lo reciben todas sus bóvedas y
|
|
312
|
+
* cualquiera que lo vea pasar puede contestar. Un reto o un error solo cuentan si vienen
|
|
313
|
+
* firmados con la maestra de la invitación (`iss`) y con el `sn` de esta sesión; lo demás
|
|
314
|
+
* se descarta, venga de quien venga. Sin esto, un `vault.error` de cualquiera tumbaba el alta.
|
|
315
|
+
*/
|
|
316
|
+
export async function fromMyVault (p, qr) {
|
|
317
|
+
const b = p?.signed
|
|
318
|
+
if (!p?.vsig || !b || typeof b !== 'object' || !qr?.iss) return false
|
|
319
|
+
if (b.iss !== qr.iss || b.sn !== qr.sn || b.type !== p.type) return false
|
|
320
|
+
try { return await verifyDeviceSig({ publickey: qr.iss, data: b, signature: p.vsig }) } catch (_) { return false }
|
|
321
|
+
}
|
|
322
|
+
|
|
310
323
|
export async function sayHello (client, qr) {
|
|
311
324
|
if (qr.iss) return qr
|
|
312
325
|
const target = await resolveAppointment(client, qr.conn)
|
|
313
326
|
const hello = await new Promise((resolve, reject) => {
|
|
327
|
+
// Un error aquí no se puede verificar (todavía no se sabe quién es la bóveda), así que no
|
|
328
|
+
// se le hace caso: o llega el hello firmado, o se agota el plazo.
|
|
314
329
|
const off = client.on('message', (_f, p) => {
|
|
315
330
|
if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
|
|
316
|
-
else if (p?.type === MSG.ERROR) { finish(); reject(vaultError(p)) }
|
|
317
331
|
})
|
|
318
|
-
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
332
|
+
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired or be someone else\'s')) }, 15000)
|
|
319
333
|
const finish = () => { off(); clearTimeout(t) }
|
|
320
334
|
try { client.send(target, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { finish(); reject(e) }
|
|
321
335
|
})
|
|
@@ -382,8 +396,11 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
382
396
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
383
397
|
|
|
384
398
|
const enrolled = new Promise((resolve, reject) => {
|
|
385
|
-
const off = client.on('message', (_from, p) => {
|
|
399
|
+
const off = client.on('message', async (_from, p) => {
|
|
386
400
|
if (!p || typeof p !== 'object') return
|
|
401
|
+
// El reto y los errores solo valen firmados por MI bóveda para ESTA sesión; el resto
|
|
402
|
+
// (otra bóveda de la cuenta, o cualquiera que viera pasar el alta) se descarta.
|
|
403
|
+
if ((p.type === MSG.ENROLL_CHALLENGE || p.type === MSG.ERROR) && !await fromMyVault(p, qr)) return
|
|
387
404
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
388
405
|
const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
|
|
389
406
|
show({ deviceId, code })
|