@dotrino/vault 0.2.1 → 0.4.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/README.md +73 -0
- package/bin/dotrino-env.js +142 -0
- package/package.json +13 -3
- package/src/config.js +26 -0
- package/src/enroll.js +250 -0
- package/src/env.js +95 -0
- package/src/index.js +35 -120
- package/src/service.js +9 -3
package/README.md
CHANGED
|
@@ -37,6 +37,79 @@ const machines = await vault.listMachines() // [{ sub, deviceId, label, exp, n
|
|
|
37
37
|
vault.close()
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## Credenciales del vault en vez del `.env` (Node)
|
|
41
|
+
|
|
42
|
+
La cara "dotenv" del paquete: **cualquier proyecto Node** jala sus credenciales del
|
|
43
|
+
vault del dueño y las deja en `process.env`. En el disco del servicio **no queda
|
|
44
|
+
ningún secreto**: solo la llave del dispositivo (generada ahí, nunca sale) y un
|
|
45
|
+
certificado con scope `vault:secrets:<ns>`. Los valores viven **solo en memoria**;
|
|
46
|
+
si la máquina se compromete, revocas el cert y no había nada que robar.
|
|
47
|
+
|
|
48
|
+
### 1) Registro del cliente (una sola vez)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# en el VAULT (tu PC): abres el emparejamiento del servicio y cargas sus secretos
|
|
52
|
+
dotrino-vault pair --service miapp # invitación con scope SOLO vault:secrets:miapp
|
|
53
|
+
dotrino-vault secret set miapp API_KEY sk-…
|
|
54
|
+
|
|
55
|
+
# en el PROYECTO/servidor: enrola esta máquina (pega la invitación)
|
|
56
|
+
npx dotrino-env enroll --ns miapp
|
|
57
|
+
# → muestra un código: dotrino-vault approve 7K3F-92Q1
|
|
58
|
+
|
|
59
|
+
# de vuelta en el VAULT: lo tipeas leyéndolo de esa pantalla
|
|
60
|
+
dotrino-vault approve 7K3F-92Q1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
El código lo **genera el servicio** y **no viaja** por la red: el vault solo puede
|
|
64
|
+
echarlo de vuelta si un humano lo tipeó. Así, un vault falso no puede enrolarte y
|
|
65
|
+
aprobar a ciegas no enrola a nadie. Queda `~/.dotrino/service/<ns>/service-identity.json`
|
|
66
|
+
(0600) con `{ device, cert, iss, proxy, ns }`.
|
|
67
|
+
|
|
68
|
+
Es un **comando previo**, no el primer arranque de la app: el enrolamiento necesita a
|
|
69
|
+
un humano leyendo el código en esta pantalla (bajo systemd/PM2 no hay TTY y el código
|
|
70
|
+
acabaría en un log), bloquea esperando la aprobación y **escribe** en disco consumiendo
|
|
71
|
+
una invitación de un solo uso. El arranque, en cambio, solo **lee** la identidad ya
|
|
72
|
+
guardada: es idempotente y no interactúa con nadie. Corre el `enroll` donde corres el
|
|
73
|
+
`npm ci` al aprovisionar la máquina.
|
|
74
|
+
|
|
75
|
+
### 2) En el código
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import '@dotrino/vault/config' // como `dotenv/config`, pero contra el vault (ns = DOTRINO_NS)
|
|
79
|
+
console.log(process.env.API_KEY)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
o explícito:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import { loadEnv } from '@dotrino/vault/env'
|
|
86
|
+
const { secrets } = await loadEnv({ ns: 'miapp', required: ['API_KEY'] })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Es **asíncrono a propósito**: el `import` bloquea el arranque (top-level await) hasta
|
|
90
|
+
que los secretos estén. Si el vault no está disponible, **espera** (reintento con
|
|
91
|
+
backoff) — un servicio sin vault no arranca, no opera con secretos viejos ni vacíos.
|
|
92
|
+
Un fallo NO transitorio (sin enrolar, cert revocado, scope equivocado) sí aborta.
|
|
93
|
+
|
|
94
|
+
Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
dotrino-env run --ns miapp -- ./mi-binario
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### API `@dotrino/vault/env`
|
|
101
|
+
|
|
102
|
+
- `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, skipped }`
|
|
103
|
+
(por defecto **no pisa** variables ya presentes en el entorno; `override: true` sí)
|
|
104
|
+
- `serviceDir(ns)`, `serviceRoot()`, `listEnrolled()`, `resolveNs(ns?)`
|
|
105
|
+
- Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET`
|
|
106
|
+
- CLI: `dotrino-env enroll|status|check|run` (`check` lista **nombres** de secretos, nunca valores)
|
|
107
|
+
|
|
108
|
+
Bajo el capó es `@dotrino/vault/service` (`enrollService` / `waitForSecrets`): petición
|
|
109
|
+
firmada por la llave del servicio + cert, respuesta **sellada** (ECDH efímero + AES-GCM,
|
|
110
|
+
el proxy no ve los valores) y **firmada por la maestra**, verificada contra la `iss`
|
|
111
|
+
pineada en el enrolamiento.
|
|
112
|
+
|
|
40
113
|
## Modelo de aprobación (seguro por diseño)
|
|
41
114
|
|
|
42
115
|
- El **dispositivo** que se enrola genera un **código aleatorio** (`makePairingCode`) y
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dotrino-env — CLI del "dotenv contra el vault".
|
|
4
|
+
*
|
|
5
|
+
* dotrino-env enroll --ns <ns> [--qr <invitación>] enrola ESTA máquina/servicio (una vez)
|
|
6
|
+
* dotrino-env status qué hay enrolado aquí
|
|
7
|
+
* dotrino-env check [--ns <ns>] pide los secretos y lista sus NOMBRES (nunca valores)
|
|
8
|
+
* dotrino-env run [--ns <ns>] -- <cmd> [args…] corre un comando con los secretos en su entorno
|
|
9
|
+
*
|
|
10
|
+
* El enrolamiento es el registro del cliente contra el vault del dueño:
|
|
11
|
+
* 1. en el vault: dotrino-vault pair --service <ns> (invitación con scope SOLO vault:secrets:<ns>)
|
|
12
|
+
* 2. aquí: dotrino-env enroll --ns <ns> (pegas la invitación; se MUESTRA un código)
|
|
13
|
+
* 3. en el vault: dotrino-vault approve <código> (lo tipeas leyéndolo de esta pantalla)
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import readline from 'node:readline/promises'
|
|
18
|
+
import { spawn } from 'node:child_process'
|
|
19
|
+
import { enrollService, readServiceIdentity } from '../src/service.js'
|
|
20
|
+
import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs } from '../src/env.js'
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2)
|
|
23
|
+
const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : undefined }
|
|
24
|
+
const has = (name) => argv.includes('--' + name)
|
|
25
|
+
|
|
26
|
+
function help () {
|
|
27
|
+
console.log(`dotrino-env — credenciales del vault en vez del .env
|
|
28
|
+
|
|
29
|
+
enroll --ns <ns> [--qr <invitación>] [--dir <dir>]
|
|
30
|
+
Registra ESTE servicio contra el vault (una sola vez).
|
|
31
|
+
Antes, en el vault: dotrino-vault pair --service <ns>
|
|
32
|
+
Si no pasas --qr, se pide por consola (también acepta stdin).
|
|
33
|
+
|
|
34
|
+
status servicios enrolados en esta máquina
|
|
35
|
+
check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
|
|
36
|
+
run [--ns <ns>] -- <cmd> [args…]
|
|
37
|
+
ejecuta <cmd> con los secretos inyectados en su entorno
|
|
38
|
+
|
|
39
|
+
En tu código:
|
|
40
|
+
import '@dotrino/vault/config' // ns por DOTRINO_NS
|
|
41
|
+
import { loadEnv } from '@dotrino/vault/env'; await loadEnv({ ns: '<ns>' })
|
|
42
|
+
|
|
43
|
+
Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* La invitación que imprime `dotrino-vault pair` viene en tres formas: el JSON
|
|
48
|
+
* crudo, el base64url del payload, o la URL de profile con `#vault=<b64>`.
|
|
49
|
+
* Aceptamos las tres para que el operador pegue lo que tenga a mano.
|
|
50
|
+
*/
|
|
51
|
+
function parseInvite (raw) {
|
|
52
|
+
const s = String(raw || '').trim()
|
|
53
|
+
if (!s) throw new Error('invitación vacía')
|
|
54
|
+
if (s.startsWith('{')) return JSON.parse(s)
|
|
55
|
+
const b64 = s.includes('#vault=') ? s.split('#vault=')[1] : (s.includes('#') ? s.split('#').pop() : s)
|
|
56
|
+
const json = Buffer.from(b64.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
|
57
|
+
if (!json.trim().startsWith('{')) throw new Error('no parece una invitación del vault')
|
|
58
|
+
return JSON.parse(json)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readInvite () {
|
|
62
|
+
if (!process.stdin.isTTY) return fs.readFileSync(0, 'utf8')
|
|
63
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
64
|
+
const answer = await rl.question('Pega la invitación del vault (salida de `dotrino-vault pair --service <ns>`):\n> ')
|
|
65
|
+
rl.close()
|
|
66
|
+
return answer
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function cmdEnroll () {
|
|
70
|
+
const ns = flag('ns')
|
|
71
|
+
if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
|
|
72
|
+
const dir = flag('dir') || serviceDir(ns)
|
|
73
|
+
if (readServiceIdentity(dir) && !has('force')) {
|
|
74
|
+
console.error('ya hay un servicio enrolado en %s (usa --force para re-enrolar)', dir); process.exit(2)
|
|
75
|
+
}
|
|
76
|
+
const qr = parseInvite(flag('qr') || await readInvite())
|
|
77
|
+
|
|
78
|
+
console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
|
|
79
|
+
const { cert } = await enrollService({
|
|
80
|
+
qr,
|
|
81
|
+
ns,
|
|
82
|
+
dir,
|
|
83
|
+
label: flag('label') || 'servicio:' + ns,
|
|
84
|
+
onCode: ({ deviceId, code }) => {
|
|
85
|
+
console.log('\n Dispositivo: %s', deviceId)
|
|
86
|
+
console.log(' APRUEBA en el vault tipeando este código:\n')
|
|
87
|
+
console.log(' dotrino-vault approve %s\n', code)
|
|
88
|
+
console.log(' (el vault NO conoce este código: tiene que leerlo de aquí un humano)')
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
console.log('\nListo. Identidad del servicio en: %s', path.join(dir, 'service-identity.json'))
|
|
92
|
+
console.log('Certificado con scope: %s (vence %s)', (cert.scope || []).join(', '), new Date(cert.exp).toISOString())
|
|
93
|
+
console.log('\nEn tu app: import \'@dotrino/vault/config\' (con DOTRINO_NS=%s)', ns)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cmdStatus () {
|
|
97
|
+
const found = listEnrolled()
|
|
98
|
+
if (!found.length) {
|
|
99
|
+
console.log('Ningún servicio enrolado en %s\n Enrola uno: dotrino-env enroll --ns <ns>', serviceRoot())
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
for (const ns of found) {
|
|
103
|
+
const id = readServiceIdentity(serviceDir(ns))
|
|
104
|
+
const exp = id?.cert?.exp
|
|
105
|
+
console.log('%s\n dir: %s\n vault: %s…\n scope: %s\n cert: vence %s',
|
|
106
|
+
ns, serviceDir(ns), String(id.iss).slice(0, 24), (id.cert?.scope || []).join(', '),
|
|
107
|
+
exp ? new Date(exp).toISOString() : '?')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function cmdCheck () {
|
|
112
|
+
const ns = resolveNs(flag('ns'))
|
|
113
|
+
const { secrets } = await loadEnv({ ns, wait: false })
|
|
114
|
+
const keys = Object.keys(secrets)
|
|
115
|
+
console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
|
|
116
|
+
for (const k of keys) console.log(' ' + k) // NUNCA los valores
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function cmdRun () {
|
|
120
|
+
const sep = argv.indexOf('--')
|
|
121
|
+
const cmd = sep >= 0 ? argv.slice(sep + 1) : []
|
|
122
|
+
if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
|
|
123
|
+
await loadEnv({ ns: flag('ns') })
|
|
124
|
+
const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
|
|
125
|
+
child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const run = async () => {
|
|
129
|
+
switch (argv[0]) {
|
|
130
|
+
case 'enroll': return cmdEnroll()
|
|
131
|
+
case 'status': return cmdStatus()
|
|
132
|
+
case 'check': return cmdCheck()
|
|
133
|
+
case 'run': return cmdRun()
|
|
134
|
+
case undefined:
|
|
135
|
+
case 'help':
|
|
136
|
+
case '--help':
|
|
137
|
+
case '-h': return help()
|
|
138
|
+
default: console.error('comando desconocido: %s\n', argv[0]); help(); process.exit(2)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
run().catch((e) => { console.error('\n[dotrino-env] ' + e.message); process.exit(1) })
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
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
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"module": "src/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dotrino-env": "bin/dotrino-env.js"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"import": "./src/index.js"
|
|
@@ -12,6 +15,12 @@
|
|
|
12
15
|
"./service": {
|
|
13
16
|
"import": "./src/service.js"
|
|
14
17
|
},
|
|
18
|
+
"./env": {
|
|
19
|
+
"import": "./src/env.js"
|
|
20
|
+
},
|
|
21
|
+
"./config": {
|
|
22
|
+
"import": "./src/config.js"
|
|
23
|
+
},
|
|
15
24
|
"./sealed": {
|
|
16
25
|
"import": "./src/sealed.js"
|
|
17
26
|
},
|
|
@@ -21,6 +30,7 @@
|
|
|
21
30
|
},
|
|
22
31
|
"files": [
|
|
23
32
|
"src",
|
|
33
|
+
"bin",
|
|
24
34
|
"README.md",
|
|
25
35
|
"LICENSE"
|
|
26
36
|
],
|
|
@@ -32,7 +42,7 @@
|
|
|
32
42
|
"pairing"
|
|
33
43
|
],
|
|
34
44
|
"peerDependencies": {
|
|
35
|
-
"@dotrino/identity": ">=0.
|
|
45
|
+
"@dotrino/identity": ">=0.23.0",
|
|
36
46
|
"@dotrino/proxy-client": ">=0.6.0"
|
|
37
47
|
},
|
|
38
48
|
"license": "MIT",
|
package/src/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `import '@dotrino/vault/config'` — el equivalente de `import 'dotenv/config'`,
|
|
3
|
+
* pero contra el vault del dueño.
|
|
4
|
+
*
|
|
5
|
+
* Bloquea el arranque (top-level await) hasta que los secretos del ns estén en
|
|
6
|
+
* `process.env`. Si el vault no está disponible, ESPERA (reintento con backoff):
|
|
7
|
+
* la regla del ecosistema es que un servicio sin vault no arranca — no opera con
|
|
8
|
+
* secretos viejos ni vacíos. Un fallo NO transitorio (sin enrolar, cert revocado,
|
|
9
|
+
* scope equivocado) sí aborta el proceso.
|
|
10
|
+
*
|
|
11
|
+
* Config por entorno:
|
|
12
|
+
* DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
|
|
13
|
+
* DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
|
|
14
|
+
* DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
|
|
15
|
+
*/
|
|
16
|
+
import { loadEnv } from './env.js'
|
|
17
|
+
|
|
18
|
+
const quiet = process.env.DOTRINO_ENV_QUIET === '1'
|
|
19
|
+
|
|
20
|
+
const { ns, injected } = await loadEnv({
|
|
21
|
+
onRetry: (e, ms) => {
|
|
22
|
+
if (!quiet) console.error('[dotrino-env] vault no disponible (%s); reintentando en %ds…', e.message, Math.round(ms / 1000))
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (!quiet) console.error('[dotrino-env] %d secreto(s) del ns "%s" cargados en process.env', injected.length, ns)
|
package/src/enroll.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enroll.js — núcleo del LADO BÓVEDA del emparejamiento endurecido.
|
|
3
|
+
*
|
|
4
|
+
* Fuente ÚNICA del flujo `vault.enroll` → `vault.enroll.challenge` → `vault.enrolled`
|
|
5
|
+
* y de la revocación firmada. Lo consumen los tres sitios que hacen de bóveda:
|
|
6
|
+
* · el daemon del PC (`dotrino-vault/src/vault.js`)
|
|
7
|
+
* · «este dispositivo es bóveda» (`lib/src/index.js#startDeviceVault`)
|
|
8
|
+
* · la copia vendorizada del iframe de identidad (`dotrino-identity/vault/vendor/vault/`)
|
|
9
|
+
*
|
|
10
|
+
* Módulo PURO: sin `node:*`, sin red, sin disco. Recibe la identidad (que firma), un
|
|
11
|
+
* transporte (`send`/`sendByPubkey`) y callbacks de log/auditoría. Así el binario Node
|
|
12
|
+
* lo embebe al compilar (SEA), el navegador lo importa y el iframe lo vendoriza sin
|
|
13
|
+
* bundler.
|
|
14
|
+
*
|
|
15
|
+
* EL CÓDIGO DE APROBACIÓN, en detalle (esto es lo que hace seguro el emparejamiento):
|
|
16
|
+
* 1. El DISPOSITIVO genera un código aleatorio de 6 dígitos, lo MUESTRA en su pantalla
|
|
17
|
+
* y manda solo su COMPROMISO `SHA-256(code‖dpub‖sn)` dentro del `data` firmado.
|
|
18
|
+
* El código en sí NUNCA viaja.
|
|
19
|
+
* 2. La bóveda no conoce el código: lo aprende cuando un humano lo TIPEA al aprobar.
|
|
20
|
+
* 3. Al aprobar, la bóveda RECOMPUTA el compromiso con el código tipeado y solo firma
|
|
21
|
+
* el cert si coincide → aprobar exige haber ido a leer el código del dispositivo.
|
|
22
|
+
* 4. La bóveda ECHA el código junto al cert; el dispositivo lo acepta solo si es el
|
|
23
|
+
* suyo → una bóveda falsa (que nunca vio el código) no puede enrolarlo.
|
|
24
|
+
*
|
|
25
|
+
* Qué cierra y qué NO (sin exagerar): cierra que se emita un cert sin que quien aprueba
|
|
26
|
+
* tenga el código del dispositivo — antes se firmaba igual y la defensa vivía solo en el
|
|
27
|
+
* cliente honesto, así que un cliente malicioso se quedaba con un cert válido. NO cierra
|
|
28
|
+
* el phishing en el que alguien le DICTA el código al dueño por otro canal: contra eso
|
|
29
|
+
* está la copy de advertencia y que el dueño reconozca el `deviceId` (residual A1/A2 de
|
|
30
|
+
* `docs/pairing-protocol.md`).
|
|
31
|
+
*/
|
|
32
|
+
import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
|
|
33
|
+
|
|
34
|
+
/** Un token de emparejamiento vale 5 min. */
|
|
35
|
+
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
36
|
+
/** Ventana anti-replay del ENROLL (±5 min), mismo criterio que el identify del proxy. */
|
|
37
|
+
export const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
38
|
+
/** Vida por defecto del cert de un dispositivo (tope duro de `MAX_DELEGATION_MS`). */
|
|
39
|
+
export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
40
|
+
|
|
41
|
+
export const MSG_ENROLL = 'vault.enroll'
|
|
42
|
+
export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
|
|
43
|
+
export const MSG_ENROLLED = 'vault.enrolled'
|
|
44
|
+
export const MSG_REVOKED = 'vault.revoked'
|
|
45
|
+
export const MSG_ERROR = 'vault.error'
|
|
46
|
+
|
|
47
|
+
/** Token aleatorio de 128 bits en hex. */
|
|
48
|
+
export function randToken () {
|
|
49
|
+
const b = crypto.getRandomValues(new Uint8Array(16))
|
|
50
|
+
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
|
|
54
|
+
export async function deviceIdOf (pub) {
|
|
55
|
+
const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
|
|
56
|
+
return id.slice(0, 4) + '-' + id.slice(4, 8)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Crea el «mostrador» de emparejamiento de una bóveda.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} opts
|
|
63
|
+
* @param {Object} opts.identity firma: `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
64
|
+
* @param {string} opts.iss pubkey de la maestra de ESTA bóveda (va en el QR).
|
|
65
|
+
* @param {string} opts.proxy URL del proxy (va en el QR).
|
|
66
|
+
* @param {(to:string, obj:object)=>void} opts.send responder por el token de la conexión.
|
|
67
|
+
* @param {(pub:string, obj:object)=>void} opts.sendByPubkey dirigir por pubkey (cola offline 24 h).
|
|
68
|
+
* @param {(op:string, info?:object)=>void} [opts.audit]
|
|
69
|
+
* @param {(...a:any[])=>void} [opts.log]
|
|
70
|
+
* @param {(c:{deviceId:string, scope:any, label:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
|
|
71
|
+
* @param {()=>void} [opts.onPendingChange]
|
|
72
|
+
* @param {string[]} [opts.defaultScope]
|
|
73
|
+
* @param {number} [opts.defaultTtlMs]
|
|
74
|
+
*/
|
|
75
|
+
export function createEnrollDesk ({
|
|
76
|
+
identity, iss, proxy, send, sendByPubkey,
|
|
77
|
+
audit = () => {}, log = () => {},
|
|
78
|
+
onChallenge = () => {}, onPendingChange = () => {},
|
|
79
|
+
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
|
|
80
|
+
} = {}) {
|
|
81
|
+
if (!identity) throw new Error('createEnrollDesk: falta identity')
|
|
82
|
+
if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
|
|
83
|
+
|
|
84
|
+
// token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
|
|
85
|
+
// state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
|
|
86
|
+
const pending = new Map()
|
|
87
|
+
|
|
88
|
+
const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
|
|
89
|
+
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
|
|
90
|
+
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
91
|
+
|
|
92
|
+
/** Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía. */
|
|
93
|
+
function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '' } = {}) {
|
|
94
|
+
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
95
|
+
const token = randToken()
|
|
96
|
+
const sn = randToken()
|
|
97
|
+
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, state: 'AWAITING_ENROLL' })
|
|
98
|
+
return { token, qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stopPairing (token) { pending.delete(token) }
|
|
102
|
+
|
|
103
|
+
function listPending () {
|
|
104
|
+
return [...pending.values()]
|
|
105
|
+
.filter((p) => p.state === 'PENDING_CONFIRM')
|
|
106
|
+
.map((p) => ({ deviceId: p.deviceId, label: p.label || '', scope: p.scope }))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findPending (deviceId) {
|
|
110
|
+
for (const p of pending.values()) {
|
|
111
|
+
if (p.state === 'PENDING_CONFIRM' && p.deviceId === deviceId) return p
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
|
|
118
|
+
* COMPROMISO de su código. Todavía NO se firma ningún cert.
|
|
119
|
+
*/
|
|
120
|
+
async function handleEnroll (from, p) {
|
|
121
|
+
const d = p?.data
|
|
122
|
+
if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
|
|
123
|
+
return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
|
|
124
|
+
}
|
|
125
|
+
const pend = pending.get(d.token)
|
|
126
|
+
if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
|
|
127
|
+
return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
|
|
128
|
+
}
|
|
129
|
+
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
|
|
130
|
+
if (!isFresh(d)) {
|
|
131
|
+
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
132
|
+
return reply(from, { type: MSG_ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
|
|
133
|
+
}
|
|
134
|
+
// PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
|
|
135
|
+
if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
|
|
136
|
+
audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
|
|
137
|
+
return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
|
|
138
|
+
}
|
|
139
|
+
// El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
|
|
140
|
+
// y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
|
|
141
|
+
if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
|
|
142
|
+
audit('rejected', { what: 'enroll', reason: 'no-commit' })
|
|
143
|
+
return reply(from, { type: MSG_ERROR, error: 'este dispositivo usa una versión antigua del emparejamiento (no envía el compromiso del código). Actualízalo y vuelve a intentarlo.' })
|
|
144
|
+
}
|
|
145
|
+
// Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
|
|
146
|
+
if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
|
|
147
|
+
return reply(from, { type: MSG_ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const deviceId = await deviceIdOf(d.dpub)
|
|
151
|
+
pend.state = 'PENDING_CONFIRM'
|
|
152
|
+
pend.dpub = d.dpub
|
|
153
|
+
pend.deviceId = deviceId
|
|
154
|
+
pend.commit = d.commit
|
|
155
|
+
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
156
|
+
if (d.label) pend.label = String(d.label).slice(0, 60)
|
|
157
|
+
|
|
158
|
+
reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
|
|
159
|
+
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
|
|
160
|
+
fire(onPendingChange)
|
|
161
|
+
return { deviceId }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Aprueba TIPEANDO el código que muestra el dispositivo. Recompone el compromiso
|
|
166
|
+
* `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide con el que llegó en el
|
|
167
|
+
* ENROLL — es decir, solo si de verdad fuiste a leer el código del dispositivo.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} code
|
|
170
|
+
* @param {{deviceId?: string}} [opts] cuál aprobar cuando hay varios pendientes.
|
|
171
|
+
*/
|
|
172
|
+
async function approve (code, { deviceId } = {}) {
|
|
173
|
+
code = String(code || '').trim()
|
|
174
|
+
if (!code) throw new Error('falta el código (los dígitos que muestra el dispositivo)')
|
|
175
|
+
|
|
176
|
+
let pend
|
|
177
|
+
if (deviceId) {
|
|
178
|
+
pend = findPending(deviceId)
|
|
179
|
+
if (!pend) throw new Error('no hay ninguna máquina esperando aprobación con ese identificador')
|
|
180
|
+
} else {
|
|
181
|
+
const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
|
|
182
|
+
if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
|
|
183
|
+
if (waiting.length > 1) throw new Error('hay más de un emparejamiento en curso; reinícialo con dotrino-vault pair')
|
|
184
|
+
pend = waiting[0]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// COMPROBACIÓN DEL CÓDIGO — antes de firmar nada.
|
|
188
|
+
const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
|
|
189
|
+
if (expected !== pend.commit) {
|
|
190
|
+
audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
|
|
191
|
+
log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
|
|
192
|
+
throw new Error('el código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Vuelve a mirarlo y prueba otra vez.')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
196
|
+
audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
|
|
197
|
+
// Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
|
|
198
|
+
// con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
|
|
199
|
+
reply(pend.from, { type: MSG_ENROLLED, code, cert, iss })
|
|
200
|
+
pend.state = 'DONE'
|
|
201
|
+
pending.delete(pend.token)
|
|
202
|
+
fire(onPendingChange)
|
|
203
|
+
log('[vault] dispositivo aprobado: %s', pend.deviceId)
|
|
204
|
+
return { ok: true, deviceId: pend.deviceId, cert }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Rechaza un enrolamiento pendiente. */
|
|
208
|
+
function reject (deviceId) {
|
|
209
|
+
const pend = deviceId
|
|
210
|
+
? findPending(deviceId)
|
|
211
|
+
: [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
|
|
212
|
+
if (!pend) return { ok: false }
|
|
213
|
+
reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
|
|
214
|
+
pending.delete(pend.token)
|
|
215
|
+
audit('reject', { device: pend.deviceId })
|
|
216
|
+
fire(onPendingChange)
|
|
217
|
+
log('[vault] dispositivo rechazado: %s', pend.deviceId)
|
|
218
|
+
return { ok: true, deviceId: pend.deviceId }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Emite un REVOKED FIRMADO por la maestra para que el dispositivo se autoborre. El
|
|
223
|
+
* borrado remoto SOLO se dispara con esta firma (nunca con un error cualquiera →
|
|
224
|
+
* cierra el wipe-DoS). Va por `sendByPubkey`: si está apagado, el proxy lo encola 24 h.
|
|
225
|
+
*/
|
|
226
|
+
async function emitRevoke (dpub, nonce) {
|
|
227
|
+
const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
|
|
228
|
+
const { signature } = await identity.signData(body)
|
|
229
|
+
try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
|
|
230
|
+
catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
|
|
234
|
+
async function revoke (nonce) {
|
|
235
|
+
audit('revoke', { nonce })
|
|
236
|
+
const { issued } = await identity.listDelegations()
|
|
237
|
+
const dele = (issued || []).find((d) => d.nonce === nonce)
|
|
238
|
+
const res = await identity.revokeDelegation(nonce)
|
|
239
|
+
if (dele?.sub) await emitRevoke(dele.sub, nonce)
|
|
240
|
+
return res
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
startPairing, stopPairing, handleEnroll, approve, reject,
|
|
245
|
+
listPending, findPending, emitRevoke, revoke,
|
|
246
|
+
get pendingCount () { return pending.size }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export default { createEnrollDesk, deviceIdOf, randToken }
|
package/src/env.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dotrino/vault/env` — el "dotenv contra el vault".
|
|
3
|
+
*
|
|
4
|
+
* Un proyecto Node cualquiera obtiene sus credenciales del vault del dueño en
|
|
5
|
+
* vez de llevarlas en un `.env`:
|
|
6
|
+
*
|
|
7
|
+
* import { loadEnv } from '@dotrino/vault/env'
|
|
8
|
+
* await loadEnv({ ns: 'miapp' }) // → process.env.API_KEY, …
|
|
9
|
+
*
|
|
10
|
+
* o, con la forma clásica de dotenv (side-effect, ns por `DOTRINO_NS`):
|
|
11
|
+
*
|
|
12
|
+
* import '@dotrino/vault/config'
|
|
13
|
+
*
|
|
14
|
+
* Lo que queda en el disco del servicio NO es un secreto: es la llave del
|
|
15
|
+
* dispositivo (generada aquí, nunca sale) y un certificado con scope
|
|
16
|
+
* `vault:secrets:<ns>`. Los valores solo viven en memoria del proceso; si la
|
|
17
|
+
* máquina se compromete, se revoca el cert y no había nada que robar.
|
|
18
|
+
*
|
|
19
|
+
* Enrolar una vez: npx dotrino-env enroll --ns miapp (ver bin/dotrino-env.js)
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { fetchSecrets, waitForSecrets, readServiceIdentity } from './service.js'
|
|
25
|
+
import { isValidSecretsNs } from './protocol.js'
|
|
26
|
+
|
|
27
|
+
/** Raíz donde viven las identidades de servicio de esta máquina/usuario. */
|
|
28
|
+
export function serviceRoot () {
|
|
29
|
+
return process.env.DOTRINO_ENV_HOME || path.join(os.homedir(), '.dotrino', 'service')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
|
|
33
|
+
export function serviceDir (ns) {
|
|
34
|
+
if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
|
|
35
|
+
if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p. ej. "miapp")')
|
|
36
|
+
return path.join(serviceRoot(), ns)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Namespaces ya enrolados en esta máquina. */
|
|
40
|
+
export function listEnrolled () {
|
|
41
|
+
let names = []
|
|
42
|
+
try { names = fs.readdirSync(serviceRoot()) } catch (_) { return [] }
|
|
43
|
+
return names.filter((ns) => isValidSecretsNs(ns) && readServiceIdentity(path.join(serviceRoot(), ns)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resuelve el ns cuando no se pasa explícito: `DOTRINO_NS`, y si no, el único
|
|
48
|
+
* enrolado en esta máquina. Con varios, exige elegir (no adivinamos).
|
|
49
|
+
*/
|
|
50
|
+
export function resolveNs (ns) {
|
|
51
|
+
ns = ns || process.env.DOTRINO_NS
|
|
52
|
+
if (ns) {
|
|
53
|
+
if (!isValidSecretsNs(ns)) throw new Error('ns inválido: ' + ns)
|
|
54
|
+
return ns
|
|
55
|
+
}
|
|
56
|
+
const found = listEnrolled()
|
|
57
|
+
if (found.length === 1) return found[0]
|
|
58
|
+
if (found.length === 0) {
|
|
59
|
+
throw new Error('no hay ningún servicio enrolado en esta máquina: corre `npx dotrino-env enroll --ns <tu-app>`')
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`hay varios servicios enrolados (${found.join(', ')}): elige uno con DOTRINO_NS=<ns> o loadEnv({ ns })`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Trae los secretos del ns desde el vault y los pone en `process.env`.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} [opts]
|
|
68
|
+
* @param {string} [opts.ns] Namespace (por defecto: `DOTRINO_NS` o el único enrolado).
|
|
69
|
+
* @param {string} [opts.dir] Dónde está `service-identity.json` (por defecto: `serviceDir(ns)`).
|
|
70
|
+
* @param {boolean} [opts.override] `true` = pisa variables ya presentes en el entorno (default: no).
|
|
71
|
+
* @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
|
|
72
|
+
* @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
|
|
73
|
+
* @param {(e:Error, ms:number)=>void} [opts.onRetry]
|
|
74
|
+
* @returns {Promise<{ns:string, secrets:Record<string,string>, injected:string[], skipped:string[]}>}
|
|
75
|
+
*/
|
|
76
|
+
export async function loadEnv ({ ns, dir, override = false, wait = true, required = [], onRetry } = {}) {
|
|
77
|
+
ns = resolveNs(ns)
|
|
78
|
+
dir = dir || serviceDir(ns)
|
|
79
|
+
const load = wait ? waitForSecrets : fetchSecrets
|
|
80
|
+
const secrets = await load({ dir, ns, onRetry })
|
|
81
|
+
|
|
82
|
+
const missing = required.filter((k) => !(k in secrets))
|
|
83
|
+
if (missing.length) {
|
|
84
|
+
throw new Error(`faltan secretos en el ns "${ns}": ${missing.join(', ')} (agrégalos con \`dotrino-vault secret set ${ns} <CLAVE> <valor>\`)`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const injected = []
|
|
88
|
+
const skipped = []
|
|
89
|
+
for (const [k, v] of Object.entries(secrets)) {
|
|
90
|
+
if (!override && k in process.env) { skipped.push(k); continue }
|
|
91
|
+
process.env[k] = String(v)
|
|
92
|
+
injected.push(k)
|
|
93
|
+
}
|
|
94
|
+
return { ns, secrets, injected, skipped }
|
|
95
|
+
}
|
package/src/index.js
CHANGED
|
@@ -17,37 +17,29 @@
|
|
|
17
17
|
* → una bóveda falsa (que nunca vio el código) no puede enrolar el dispositivo, y
|
|
18
18
|
* aprobar "a ciegas" (sin ir a leer el código del dispositivo) tampoco enrola nada.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* El flujo de enrolamiento en sí (incluida la comprobación del código antes de firmar) vive
|
|
21
|
+
* en `./enroll.js`, COMPARTIDO con el daemon del PC y con la copia vendorizada del iframe:
|
|
22
|
+
* un solo sitio donde se decide a quién se le emite un certificado.
|
|
23
|
+
*
|
|
24
|
+
* Cripto 100% de `@dotrino/identity/capabilities`; firma con la identidad P
|
|
25
|
+
* (`identity.signDelegation`). Transporte: `@dotrino/proxy-client` (import perezoso).
|
|
26
|
+
* No reimplementa nada del ecosistema.
|
|
23
27
|
*/
|
|
24
|
-
import {
|
|
28
|
+
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
29
|
+
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
25
30
|
|
|
26
31
|
const SIGN_SCOPE = 'vault:sign'
|
|
27
|
-
const PAIRING_TTL_MS = 5 * 60 * 1000 // un emparejamiento (token) vale 5 min
|
|
28
|
-
const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // vida de un cert de dispositivo (30 días)
|
|
29
32
|
const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
|
|
30
|
-
const FRESH_WINDOW_MS = 5 * 60 * 1000 // ventana anti-replay del enroll (±5 min)
|
|
31
33
|
|
|
32
34
|
const MSG = {
|
|
33
35
|
ENROLL: 'vault.enroll',
|
|
34
|
-
ENROLL_CHALLENGE: 'vault.enroll.challenge',
|
|
35
|
-
ENROLLED: 'vault.enrolled',
|
|
36
36
|
DEVICES: 'vault.devices',
|
|
37
37
|
DEVICES_RESULT: 'vault.devices.result',
|
|
38
|
-
REVOKED: 'vault.revoked',
|
|
39
38
|
ERROR: 'vault.error'
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
function randToken () {
|
|
43
|
-
const b = crypto.getRandomValues(new Uint8Array(16))
|
|
44
|
-
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
45
|
-
}
|
|
46
|
-
|
|
47
41
|
/** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
|
|
48
|
-
export
|
|
49
|
-
return pubkeyId(pub).then((id) => id.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
|
|
50
|
-
}
|
|
42
|
+
export { deviceIdOf }
|
|
51
43
|
|
|
52
44
|
/**
|
|
53
45
|
* Levanta la bóveda de este dispositivo: se conecta al proxy identificado como P y
|
|
@@ -94,49 +86,21 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
94
86
|
|
|
95
87
|
const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
|
|
96
88
|
|
|
97
|
-
// token -> { exp, sn, scope, ttlMs, label, state, dpub?, deviceId?, from? }
|
|
98
|
-
const pending = new Map()
|
|
99
89
|
let _onPendingChange = () => {}
|
|
100
90
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
|
|
115
|
-
const ok = await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature })
|
|
116
|
-
if (!ok) return send(from, { type: MSG.ERROR, error: 'firma de dispositivo inválida' })
|
|
117
|
-
// Un solo dispositivo a la vez esperando su código (así `approve` no es ambiguo).
|
|
118
|
-
if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
|
|
119
|
-
return send(from, { type: MSG.ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
|
|
120
|
-
}
|
|
121
|
-
const deviceId = await deviceIdOf(d.dpub)
|
|
122
|
-
pend.state = 'PENDING_CONFIRM'
|
|
123
|
-
pend.dpub = d.dpub
|
|
124
|
-
pend.deviceId = deviceId
|
|
125
|
-
pend.from = from // esta bóveda NO conoce el código (no viaja): el dispositivo lo MUESTRA
|
|
126
|
-
if (d.label) pend.label = String(d.label).slice(0, 60)
|
|
127
|
-
_onPendingChange()
|
|
128
|
-
send(from, { type: MSG.ENROLL_CHALLENGE, deviceId })
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Emite un REVOKED FIRMADO por la maestra a la máquina revocada para que se
|
|
132
|
-
// auto-borre. Va por `sendByPubkey` → si está offline, el proxy lo encola 24 h; y
|
|
133
|
-
// si reaparece más tarde, `handleDevices` lo re-emite en su siguiente consulta.
|
|
134
|
-
// El auto-borrado remoto SOLO se dispara con esta firma (no con un error cualquiera).
|
|
135
|
-
async function emitRevoke (dpub, nonce) {
|
|
136
|
-
const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
|
|
137
|
-
const { signature } = await identity.signData(body)
|
|
138
|
-
try { client.sendByPubkey(dpub, { type: MSG.REVOKED, body, signature }) } catch (_) {}
|
|
139
|
-
}
|
|
91
|
+
// ENROLL / aprobación / revocación: núcleo COMPARTIDO con el daemon del PC y con la
|
|
92
|
+
// copia vendorizada del iframe (`lib/src/enroll.js`). Un solo sitio donde vive el
|
|
93
|
+
// flujo → y por lo tanto un solo sitio donde se comprueba el código antes de firmar.
|
|
94
|
+
const desk = createEnrollDesk({
|
|
95
|
+
identity,
|
|
96
|
+
iss,
|
|
97
|
+
proxy,
|
|
98
|
+
send,
|
|
99
|
+
sendByPubkey: (pub, obj) => { try { client.sendByPubkey(pub, obj) } catch (_) {} },
|
|
100
|
+
defaultScope: [SIGN_SCOPE],
|
|
101
|
+
defaultTtlMs: DEVICE_TTL_MS,
|
|
102
|
+
onPendingChange: () => _onPendingChange()
|
|
103
|
+
})
|
|
140
104
|
|
|
141
105
|
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
142
106
|
// de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
|
|
@@ -155,62 +119,15 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
155
119
|
send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
|
|
156
120
|
// ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
|
|
157
121
|
const mine = (issued || []).find((x) => x.sub === chk.device && x.revokedAt)
|
|
158
|
-
if (mine) emitRevoke(chk.device, mine.nonce)
|
|
122
|
+
if (mine) desk.emitRevoke(chk.device, mine.nonce)
|
|
159
123
|
}
|
|
160
124
|
|
|
161
125
|
client.on('message', (_from, p) => {
|
|
162
126
|
if (!p || typeof p !== 'object') return
|
|
163
|
-
if (p.type === MSG.ENROLL) handleEnroll(_from, p).catch(() => {})
|
|
127
|
+
if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
|
|
164
128
|
else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
|
|
165
129
|
})
|
|
166
130
|
|
|
167
|
-
/**
|
|
168
|
-
* Abre un emparejamiento: devuelve el QR/JSON v2 que el dispositivo consume para
|
|
169
|
-
* enrolarse. `scope`/`ttlMs`/`label` fijan lo que otorgará el cert al aprobar.
|
|
170
|
-
*/
|
|
171
|
-
function startPairing ({ scope = [SIGN_SCOPE], ttlMs = DEVICE_TTL_MS, label = '' } = {}) {
|
|
172
|
-
pending.clear()
|
|
173
|
-
const token = randToken()
|
|
174
|
-
const sn = randToken()
|
|
175
|
-
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, sn, scope, ttlMs, label, state: 'AWAITING_ENROLL' })
|
|
176
|
-
return { qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function listPending () {
|
|
180
|
-
return [...pending.values()]
|
|
181
|
-
.filter((p) => p.state === 'PENDING_CONFIRM')
|
|
182
|
-
.map((p) => ({ deviceId: p.deviceId, label: p.label }))
|
|
183
|
-
}
|
|
184
|
-
function findPending (deviceId) {
|
|
185
|
-
for (const [, p] of pending) if (p.state === 'PENDING_CONFIRM' && p.deviceId === deviceId) return p
|
|
186
|
-
return null
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Aprueba una máquina pendiente TIPEANDO el código que ella muestra. Esta bóveda NO
|
|
191
|
-
* conoce/valida el código: firma el cert y ECHA el código tipeado; la máquina lo acepta
|
|
192
|
-
* solo si coincide con el que generó. (Modelo `dotrino-vault#approveDevice`.)
|
|
193
|
-
*/
|
|
194
|
-
async function approve (deviceId, code) {
|
|
195
|
-
const pend = findPending(deviceId)
|
|
196
|
-
if (!pend || !pend.dpub) throw new Error('no hay ninguna máquina esperando aprobación')
|
|
197
|
-
code = String(code || '').trim()
|
|
198
|
-
if (!code) throw new Error('escribe el código que muestra la máquina')
|
|
199
|
-
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
200
|
-
send(pend.from, { type: MSG.ENROLLED, code, cert, iss })
|
|
201
|
-
pending.delete(pend.token)
|
|
202
|
-
_onPendingChange()
|
|
203
|
-
return { ok: true, deviceId }
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function reject (deviceId) {
|
|
207
|
-
const pend = findPending(deviceId)
|
|
208
|
-
if (!pend) return
|
|
209
|
-
send(pend.from, { type: MSG.ERROR, error: 'emparejamiento rechazado' })
|
|
210
|
-
pending.delete(pend.token)
|
|
211
|
-
_onPendingChange()
|
|
212
|
-
}
|
|
213
|
-
|
|
214
131
|
/**
|
|
215
132
|
* Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
|
|
216
133
|
* propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
|
|
@@ -228,20 +145,18 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
228
145
|
return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
|
|
229
146
|
}
|
|
230
147
|
|
|
231
|
-
async function revoke (nonce) {
|
|
232
|
-
// Deja el registro persistente (revokedAt en la delegación) y AVISA a la máquina
|
|
233
|
-
// con un REVOKED firmado para que se auto-borre (ahora si está online, o al
|
|
234
|
-
// reaparecer vía handleDevices). Ver emitRevoke.
|
|
235
|
-
const { issued } = await identity.listDelegations()
|
|
236
|
-
const dele = (issued || []).find((d) => d.nonce === nonce)
|
|
237
|
-
const res = await identity.revokeDelegation(nonce)
|
|
238
|
-
if (dele?.sub) await emitRevoke(dele.sub, nonce)
|
|
239
|
-
return res
|
|
240
|
-
}
|
|
241
|
-
|
|
242
148
|
return {
|
|
243
149
|
iss, proxy, client,
|
|
244
|
-
startPairing
|
|
150
|
+
startPairing: desk.startPairing,
|
|
151
|
+
// Aprueba TIPEANDO el código que muestra la máquina: el núcleo compartido recompone
|
|
152
|
+
// el compromiso `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide.
|
|
153
|
+
approve: (deviceId, code) => desk.approve(code, { deviceId }),
|
|
154
|
+
reject: (deviceId) => desk.reject(deviceId),
|
|
155
|
+
listPending: desk.listPending,
|
|
156
|
+
listMachines,
|
|
157
|
+
// Revoca y AVISA a la máquina con un REVOKED firmado para que se auto-borre (ahora si
|
|
158
|
+
// está online, o al reaparecer vía handleDevices).
|
|
159
|
+
revoke: (nonce) => desk.revoke(nonce),
|
|
245
160
|
getSelfCert,
|
|
246
161
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
247
162
|
close () { try { client.close() } catch (_) {} }
|
package/src/service.js
CHANGED
|
@@ -22,7 +22,7 @@ import fs from 'node:fs'
|
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import {
|
|
24
24
|
makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig,
|
|
25
|
-
makePairingCode, pubkeyId
|
|
25
|
+
makePairingCode, commitCode, pubkeyId
|
|
26
26
|
} from '@dotrino/identity/capabilities'
|
|
27
27
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
28
28
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
@@ -76,7 +76,10 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
|
|
|
76
76
|
await Promise.race([client.connect(), timeout])
|
|
77
77
|
} catch (e) {
|
|
78
78
|
try { client.close() } catch (_) {}
|
|
79
|
-
|
|
79
|
+
// El 'error' de transporte del cliente puede llegar como un Event sin
|
|
80
|
+
// `message` → sin esto el operador ve una línea de error vacía.
|
|
81
|
+
const why = e?.message || e?.type || 'error de transporte'
|
|
82
|
+
throw new Error(`no se pudo conectar al proxy ${proxyUrl}: ${why}`)
|
|
80
83
|
} finally {
|
|
81
84
|
clearTimeout(timer)
|
|
82
85
|
}
|
|
@@ -141,7 +144,10 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
|
|
|
141
144
|
// Código ALEATORIO generado AQUÍ: el vault no lo conoce; solo puede echarlo
|
|
142
145
|
// de vuelta si el dueño lo tipeó (= tiene esta pantalla a la vista).
|
|
143
146
|
const code = makePairingCode()
|
|
144
|
-
|
|
147
|
+
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
148
|
+
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
149
|
+
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
150
|
+
const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
|
|
145
151
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
146
152
|
|
|
147
153
|
const enrolled = new Promise((resolve, reject) => {
|