@dotrino/vault 0.73.0 → 0.75.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/bin/dotrino-env.js +31 -3
- package/package.json +4 -1
- package/src/deviceInfo.js +70 -0
- package/src/enroll.js +23 -5
package/bin/dotrino-env.js
CHANGED
|
@@ -26,6 +26,7 @@ import { parseInvite as sharedParseInvite } from '../src/invite.js'
|
|
|
26
26
|
import { selfContext, commandLine } from '../src/proc.js'
|
|
27
27
|
import { updateNotice } from '@dotrino/update/notice'
|
|
28
28
|
import { PKG_VERSION as VERSION } from '../src/version.js'
|
|
29
|
+
import { deviceInfo, formatDeviceInfo, shortId } from '../src/deviceInfo.js'
|
|
29
30
|
|
|
30
31
|
const argv = process.argv.slice(2)
|
|
31
32
|
|
|
@@ -83,6 +84,9 @@ function help () {
|
|
|
83
84
|
Antes, en el vault: dotrino-vault pair --service <ns>
|
|
84
85
|
Si no pasas --code (alias --qr), se pide por consola (también acepta stdin).
|
|
85
86
|
|
|
87
|
+
info [--ns <ns>] [--vault <bóveda>] [--json]
|
|
88
|
+
qué aparato es cada servicio de esta máquina: su ID (el de
|
|
89
|
+
«dotrino-vault members»), su bóveda, sus permisos. Sin red.
|
|
86
90
|
status servicios enrolados en esta máquina
|
|
87
91
|
check [--ns <ns>] [--public]
|
|
88
92
|
pide los secretos al vault y lista sus NOMBRES (nunca los valores)
|
|
@@ -227,7 +231,7 @@ async function cmdEnroll () {
|
|
|
227
231
|
console.log('\nEn tu app: import \'@dotrino/vault/config\' (con DOTRINO_NS=%s)', ns)
|
|
228
232
|
}
|
|
229
233
|
|
|
230
|
-
function cmdStatus () {
|
|
234
|
+
async function cmdStatus () {
|
|
231
235
|
const found = listEnrolled()
|
|
232
236
|
if (!found.length) {
|
|
233
237
|
console.log('Ningún servicio enrolado en %s\n Enrola uno: dotrino-env enroll --ns <ns> --code "<invitación>"', serviceRoot())
|
|
@@ -251,11 +255,34 @@ function cmdStatus () {
|
|
|
251
255
|
const papel = typeof c?.seq === 'number'
|
|
252
256
|
? `acta #${c.seq}`
|
|
253
257
|
: (typeof c?.exp === 'number' ? `modelo viejo · vence ${new Date(c.exp).toISOString().slice(0, 10)}` : '?')
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
const aparato = id?.device?.publickey ? await shortId(id.device.publickey) : '?'
|
|
259
|
+
const boveda = id?.iss ? await shortId(id.iss) : '?'
|
|
260
|
+
console.log('%s/%s\n id: %s\n dir: %s\n vault: %s\n scope: %s\n cert: %s',
|
|
261
|
+
e.vault, e.ns, aparato, dir, boveda, (c?.scope || []).join(', '), papel)
|
|
256
262
|
}
|
|
257
263
|
}
|
|
258
264
|
|
|
265
|
+
/**
|
|
266
|
+
* `info`: qué aparato es cada servicio de esta máquina, con la pieza común de todos los
|
|
267
|
+
* comandos (`deviceInfo`). Lo que se viene a mirar es el ID, para buscarlo en el acta.
|
|
268
|
+
*/
|
|
269
|
+
async function cmdInfo () {
|
|
270
|
+
const onlyNs = flag('ns')
|
|
271
|
+
const onlyVault = flag('vault')
|
|
272
|
+
const found = listEnrolled().filter((e) => !e.legacy && (!onlyNs || e.ns === onlyNs) && (!onlyVault || e.vault === onlyVault))
|
|
273
|
+
if (!found.length) {
|
|
274
|
+
console.error('Ningún servicio enrolado%s en %s', onlyNs ? ` con --ns ${onlyNs}` : '', serviceRoot())
|
|
275
|
+
process.exit(1)
|
|
276
|
+
}
|
|
277
|
+
const infos = []
|
|
278
|
+
for (const e of found) {
|
|
279
|
+
const dir = serviceDir(e.vault, e.ns)
|
|
280
|
+
infos.push(await deviceInfo(readServiceIdentity(dir), { ns: `${e.vault}/${e.ns}`, version: VERSION, dir }))
|
|
281
|
+
}
|
|
282
|
+
if (argv.includes('--json')) { console.log(JSON.stringify(infos.length === 1 ? infos[0] : infos, null, 2)); return }
|
|
283
|
+
console.log(infos.map(formatDeviceInfo).join('\n\n'))
|
|
284
|
+
}
|
|
285
|
+
|
|
259
286
|
async function cmdCheck () {
|
|
260
287
|
const ns = resolveNs(flag('ns'))
|
|
261
288
|
// `fetchSecrets` y no `loadEnv`: listar NO debe tener efectos secundarios. Con
|
|
@@ -349,6 +376,7 @@ const run = async () => {
|
|
|
349
376
|
switch (argv[0]) {
|
|
350
377
|
case 'enroll': return cmdEnroll()
|
|
351
378
|
case 'status': return cmdStatus()
|
|
379
|
+
case 'info': return cmdInfo()
|
|
352
380
|
case 'check': return cmdCheck()
|
|
353
381
|
case 'run': return cmdRun()
|
|
354
382
|
case 'ssh-agent': return cmdSshAgent()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.0",
|
|
4
4
|
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye 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",
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
},
|
|
40
40
|
"./admin": {
|
|
41
41
|
"import": "./src/admin.js"
|
|
42
|
+
},
|
|
43
|
+
"./device-info": {
|
|
44
|
+
"import": "./src/deviceInfo.js"
|
|
42
45
|
}
|
|
43
46
|
},
|
|
44
47
|
"files": [
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deviceInfo.js — QUÉ APARATO SOY, dicho igual en todos los comandos del ecosistema.
|
|
3
|
+
*
|
|
4
|
+
* Regla del dueño (2026-09-29): todo comando que se enrola en una bóveda tiene un `info`
|
|
5
|
+
* que enseña la información del aparato, **sobre todo su id**. El id (`AB12-CD34`) es lo que
|
|
6
|
+
* sale en `dotrino-vault members` y lo que pide `dotrino-vault caps <ID>`: sin verlo aquí no
|
|
7
|
+
* hay forma de saber qué fila del acta es esta máquina. `dotrino-env status` enseñaba la
|
|
8
|
+
* llave de la bóveda en crudo (`{"key_ops":["verify"],…`) y ningún id.
|
|
9
|
+
*
|
|
10
|
+
* Una sola pieza para todos: `dotrino-env`, los agentes de remote-agent (terminal, ia) y lo
|
|
11
|
+
* que venga. Local y sin red: lee el enlace que ya está en el disco, así que responde con
|
|
12
|
+
* la bóveda apagada, que es justo cuando más falta hace saber quién eres.
|
|
13
|
+
*
|
|
14
|
+
* Sirve cualquier enlace con la forma común `{ device: { publickey }, cert, iss, proxy }`
|
|
15
|
+
* (el de remote-agent y el de un servicio de dotrino-env).
|
|
16
|
+
*/
|
|
17
|
+
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
18
|
+
|
|
19
|
+
/** El id corto de una llave, como lo enseña la bóveda: `AB12-CD34`. */
|
|
20
|
+
export async function shortId (publickey) {
|
|
21
|
+
return (await pubkeyId(publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* La información del aparato a partir de su enlace.
|
|
26
|
+
* @param {{ device?: { publickey?: string }, cert?: object, iss?: string, proxy?: string }} link
|
|
27
|
+
* @param {Record<string, any>} [extra] lo propio de cada comando (nombre, versión, carpeta…).
|
|
28
|
+
*/
|
|
29
|
+
export async function deviceInfo (link, extra = {}) {
|
|
30
|
+
const pub = link?.device?.publickey
|
|
31
|
+
if (!pub) throw Object.assign(new Error('this link has no device key: enroll first'), { code: 'not-enrolled' })
|
|
32
|
+
const cert = link.cert || null
|
|
33
|
+
return {
|
|
34
|
+
id: await shortId(pub),
|
|
35
|
+
vault: link.iss ? await shortId(link.iss) : null,
|
|
36
|
+
scope: Array.isArray(cert?.scope) ? cert.scope : [],
|
|
37
|
+
// El papel se describe por el acta a la que se ató; uno del modelo viejo, por su fecha,
|
|
38
|
+
// porque eso sí importa: es lo único que todavía caduca.
|
|
39
|
+
record: typeof cert?.seq === 'number' ? cert.seq : null,
|
|
40
|
+
legacyExpires: typeof cert?.seq !== 'number' && typeof cert?.exp === 'number' ? cert.exp : null,
|
|
41
|
+
proxy: link.proxy || null,
|
|
42
|
+
publickey: pub,
|
|
43
|
+
...extra
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LABELS = {
|
|
48
|
+
id: 'Aparato', vault: 'Bóveda', kind: 'Tipo', name: 'Nombre', ns: 'Cajón', version: 'Versión',
|
|
49
|
+
scope: 'Permisos', record: 'Papel', dir: 'Enlace', proxy: 'Proxio'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* El texto que imprime `info`. El id va primero y con la pista de dónde buscarlo: es lo que
|
|
54
|
+
* se viene a mirar.
|
|
55
|
+
* @param {Awaited<ReturnType<typeof deviceInfo>>} info
|
|
56
|
+
*/
|
|
57
|
+
export function formatDeviceInfo (info) {
|
|
58
|
+
const rows = []
|
|
59
|
+
const put = (k, v) => { if (v != null && v !== '') rows.push([LABELS[k] || k, v]) }
|
|
60
|
+
put('id', `${info.id} (así sale en \`dotrino-vault members\`)`)
|
|
61
|
+
put('vault', info.vault)
|
|
62
|
+
put('kind', info.kind); put('name', info.name); put('ns', info.ns); put('version', info.version)
|
|
63
|
+
put('scope', info.scope?.length ? info.scope.join(', ') : null)
|
|
64
|
+
put('record', info.record != null
|
|
65
|
+
? `acta #${info.record}`
|
|
66
|
+
: (info.legacyExpires ? `modelo viejo, vence ${new Date(info.legacyExpires).toISOString().slice(0, 10)}` : null))
|
|
67
|
+
put('dir', info.dir); put('proxy', info.proxy)
|
|
68
|
+
const w = Math.max(...rows.map(([k]) => k.length))
|
|
69
|
+
return rows.map(([k, v]) => `${(k + ':').padEnd(w + 2)}${v}`).join('\n')
|
|
70
|
+
}
|
package/src/enroll.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* `docs/pairing-protocol.md`).
|
|
31
31
|
*/
|
|
32
32
|
import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
|
|
33
|
-
import { verifyContinuity, canSeal } from '@dotrino/identity/acta'
|
|
33
|
+
import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
34
34
|
|
|
35
35
|
/** Un token de emparejamiento vale 5 min. */
|
|
36
36
|
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
@@ -159,8 +159,24 @@ export function createEnrollDesk ({
|
|
|
159
159
|
* · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
|
|
160
160
|
* aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
|
|
161
161
|
* identidad de verdad de la cuenta es `iss`.
|
|
162
|
+
* · `caps` → LOS PERMISOS con los que entra al acta, elegidos por quien
|
|
163
|
+
* empareja (dueño, 2026-09-26: «al emparejar debería escoger los
|
|
164
|
+
* permisos, caso contrario estoy obligado a hacer dos actas»). Sin
|
|
165
|
+
* esto el aparato entraba con lo que da el scope y cualquier otro
|
|
166
|
+
* permiso —`admin`, `approve`…— costaba una segunda acta. Se quedan
|
|
167
|
+
* AQUÍ, en la sesión de la bóveda: NO viajan en el QR, así que la
|
|
168
|
+
* invitación que circula no concede nada; lo concede quien teclea
|
|
169
|
+
* el código, igual que hacía `caps` después.
|
|
162
170
|
*/
|
|
163
|
-
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
171
|
+
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
|
|
172
|
+
if (caps != null) {
|
|
173
|
+
if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
|
|
174
|
+
const cn = scopeToCn(scope)
|
|
175
|
+
const bad = caps.filter((c) => !(DEVICE_CAPS.includes(c) || (c === 'secrets' && cn)))
|
|
176
|
+
if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
|
|
177
|
+
if (!caps.length) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
|
|
178
|
+
caps = [...new Set(caps)]
|
|
179
|
+
}
|
|
164
180
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
165
181
|
const acct = String(account || '').slice(0, 40)
|
|
166
182
|
// INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
|
|
@@ -176,12 +192,12 @@ export function createEnrollDesk ({
|
|
|
176
192
|
const conn = typeof connToken === 'function' ? await connToken() : connToken
|
|
177
193
|
if (conn) {
|
|
178
194
|
const sn = randToken(8)
|
|
179
|
-
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
195
|
+
pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
180
196
|
return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
|
|
181
197
|
}
|
|
182
198
|
const token = randToken(PAIR_TOKEN_BYTES)
|
|
183
199
|
const sn = randToken(PAIR_TOKEN_BYTES)
|
|
184
|
-
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
200
|
+
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
185
201
|
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
186
202
|
}
|
|
187
203
|
|
|
@@ -364,8 +380,10 @@ export function createEnrollDesk ({
|
|
|
364
380
|
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
365
381
|
// cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
|
|
366
382
|
// con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
|
|
383
|
+
// Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
|
|
384
|
+
// esta misma acta; si no, los que da el scope.
|
|
367
385
|
const cn = scopeToCn(pend.scope)
|
|
368
|
-
const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
|
|
386
|
+
const caps = [...new Set([...(pend.caps || scopeToCaps(pend.scope)), ...(cn ? ['secrets'] : [])])]
|
|
369
387
|
if (!caps.length) {
|
|
370
388
|
throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
|
|
371
389
|
}
|