@dotrino/vaultd 0.49.1 → 0.52.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 +25 -31
- package/lib/README.md +6 -5
- package/lib/src/enroll.js +1 -1
- package/lib/src/index.js +124 -7
- package/lib/src/invite.js +6 -4
- package/lib/src/protocol.js +5 -1
- package/lib/src/service.js +1 -49
- package/lib/src/sshAgent.js +21 -27
- package/lib/src/sshKeys.js +87 -45
- package/package.json +4 -3
- package/src/approvals.js +16 -36
- package/src/ctl.js +50 -125
- package/src/daemon.js +11 -13
- package/src/passwords.js +81 -0
- package/src/secretsStore.js +3 -29
- package/src/tui/app.js +1 -1
- package/src/tui/i18n.js +10 -6
- package/src/vault.js +239 -114
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ dotrino-vault revoke <nonce> # revoca un dispositivo (le ordena autoborrar
|
|
|
240
240
|
dotrino-vault activity [n] # bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
241
241
|
dotrino-vault pair --service <ns> # empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
242
242
|
dotrino-vault pair --scope <lista> # los PERMISOS del cert: sign,read,store,secrets:<ns>. Sin esto, sign,read,store.
|
|
243
|
-
dotrino-vault
|
|
243
|
+
dotrino-vault caps <ID> +permiso # ese aparato pide tu aprobación (teléfono) al recibir claves; pair --approval al enrolar
|
|
244
244
|
# Se combina con --service: `--service eco --scope sign` = un bot que firma
|
|
245
245
|
# como aparato del acta y lee SOLO su cajón. `admin` no se empareja (caps).
|
|
246
246
|
dotrino-vault secret set <ns> <CLAVE> <valor> # variable del SCOPE: la comparten todos los
|
|
@@ -756,46 +756,40 @@ CLI de apoyo: `dotrino-env status` (qué hay enrolado aquí), `dotrino-env check
|
|
|
756
756
|
los secretos en el entorno de un proceso que no es Node). Primer consumidor:
|
|
757
757
|
`dotrino-proxy` (TURN de Cloudflare).
|
|
758
758
|
|
|
759
|
-
### Aprobación
|
|
759
|
+
### Aprobación desde el teléfono (un aparato que pide permiso para recibir claves)
|
|
760
760
|
|
|
761
|
-
|
|
762
|
-
|
|
761
|
+
Liberar claves privadas a un aparato puede exigir el **visto bueno de otro aparato** (el
|
|
762
|
+
teléfono, con `caps <ID> +aprueba`). Es una propiedad **del aparato**, no del cajón: el VPS
|
|
763
|
+
desatendido no pide; la PC del dueño sí. Por defecto nadie pide; se fija al enrolar o se
|
|
764
|
+
cambia después como un permiso más:
|
|
763
765
|
|
|
764
766
|
```sh
|
|
765
|
-
dotrino-vault caps <ID-del-teléfono> +aprueba
|
|
766
|
-
dotrino-vault
|
|
767
|
-
dotrino-
|
|
767
|
+
dotrino-vault caps <ID-del-teléfono> +aprueba # quién aprueba (no viaja en un QR)
|
|
768
|
+
dotrino-vault pair --service claude --approval # el que entre pedirá permiso
|
|
769
|
+
dotrino-vault caps <ID> +permiso | -permiso # cambiarlo después
|
|
770
|
+
dotrino-env run --ns claude -- node mi-script.js # el proceso espera el sí…
|
|
768
771
|
```
|
|
769
772
|
|
|
770
|
-
…la bóveda apunta el pedido, avisa al teléfono (cola del proxio → aviso nativo
|
|
771
|
-
firma entrega las claves — **al proceso que pidió, en memoria
|
|
772
|
-
|
|
773
|
-
|
|
773
|
+
…la bóveda apunta el pedido, avisa al teléfono (cola del proxio → aviso nativo en la app de
|
|
774
|
+
Dotrino), y solo su firma entrega las claves — **al proceso que pidió, en memoria**. Pide en
|
|
775
|
+
**cada petición**, que para un servicio bien hecho es **una por arranque**: pide al iniciar,
|
|
776
|
+
se queda las claves en memoria y no vuelve a pedir. Lo denegado corta sin reintentos; lo que
|
|
777
|
+
nadie atiende vence a los 5 min; todo queda en `dotrino-vault activity`.
|
|
774
778
|
|
|
775
|
-
|
|
776
|
-
`ssh-agent`, socket en `$XDG_RUNTIME_DIR/dotrino-vault/ssh-agent.sock`) que **no guarda
|
|
777
|
-
ninguna llave privada**: la llave nace en el teléfono (vault.dotrino.com → *Llave SSH de
|
|
778
|
-
este aparato*; WebCrypto no extraíble, `ecdsa-sha2-nistp256`) y cada firma es un pedido
|
|
779
|
-
que apruebas ahí. En el PC no queda nada que copiar.
|
|
779
|
+
### La llave SSH como un secreto más (`dotrino-env ssh-agent`)
|
|
780
780
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
ssh mi-servidor # el teléfono pide tu «sí» y firma
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
**La bóveda puede estar en OTRA máquina** (la de Dotrino en el VPS): en tu PC corre el
|
|
788
|
-
agente **delgado**, que no custodia nada y reenvía cada reto como un pedido a la bóveda
|
|
789
|
-
que enroló ese servicio:
|
|
781
|
+
La llave privada SSH vive **sellada en la bóveda** (cajón `ssh`, variables `SSH_KEY_*` con el
|
|
782
|
+
archivo en base64) y solo existe en claro en la memoria del agente que la pidió. En el disco
|
|
783
|
+
de la PC no queda nada; cerrar el agente es olvidar las llaves.
|
|
790
784
|
|
|
791
785
|
```sh
|
|
792
|
-
dotrino-
|
|
793
|
-
dotrino-env
|
|
786
|
+
dotrino-vault secret set ssh SSH_KEY_DOTRINO "$(base64 -w0 ~/.ssh/id_ed25519)"
|
|
787
|
+
dotrino-env enroll --ns ssh --code <código> # una vez (pair --service ssh --approval en la bóveda)
|
|
788
|
+
dotrino-env ssh-agent --ns ssh # pide el cajón (tu sí en el teléfono) e imprime export SSH_AUTH_SOCK=…
|
|
789
|
+
ssh mi-servidor # firma en local, con la llave en memoria
|
|
794
790
|
```
|
|
795
791
|
|
|
796
|
-
|
|
797
|
-
`ControlPersist 15m` en `~/.ssh/config`: esa es la ventana de 15 min del SSH.
|
|
798
|
-
`DOTRINO_VAULT_SSH_AGENT=0` apaga el agente.
|
|
792
|
+
ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en PEM.
|
|
799
793
|
|
|
800
794
|
## Alcance
|
|
801
795
|
|
|
@@ -840,7 +834,7 @@ Para no aprobar cada comando, reusa la conexión con `ControlMaster auto` +
|
|
|
840
834
|
- `packaging/` — `build.sh` (binario SEA + tarball), `build-deb.sh`, `build-win.sh`, `install.sh`/`uninstall.sh`, unit systemd.
|
|
841
835
|
- `Dockerfile` — la imagen que publica `.github/workflows/docker.yml` en GHCR.
|
|
842
836
|
- `test/` — las pruebas (`npm test`, `node --test`, sin dependencias).
|
|
843
|
-
- `web/` — `vault.dotrino.com` (Vite + Vue): la página pública **y la consola «Dónde vive tu perfil»**, la única pantalla del ecosistema donde se ven y gestionan los dispositivos de un perfil
|
|
837
|
+
- `web/` — `vault.dotrino.com` (Vite + Vue): la página pública **y la consola «Dónde vive tu perfil»**, la única pantalla del ecosistema donde se ven y gestionan los dispositivos de un perfil — en `/vault`, que además decide sola si esta máquina hace de bóveda o se conecta a la que ya hay. Sirve también `/d`, la ruta corta del QR. La publica `.github/workflows/deploy.yml`.
|
|
844
838
|
- `docs/` — las decisiones de diseño, que mandan sobre el código:
|
|
845
839
|
- [`acta-de-perfil.md`](./docs/acta-de-perfil.md) — el modelo vigente: un perfil es un conjunto de llaves con un acta firmada por un solo sellador.
|
|
846
840
|
- [`pairing-protocol.md`](./docs/pairing-protocol.md) — el emparejamiento endurecido: por qué el token dejó de ser autoridad suficiente.
|
package/lib/README.md
CHANGED
|
@@ -280,9 +280,10 @@ cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30
|
|
|
280
280
|
|
|
281
281
|
MIT · parte de [Dotrino](https://dotrino.com).
|
|
282
282
|
|
|
283
|
-
## Agente SSH
|
|
283
|
+
## Agente SSH con llaves en memoria (`dotrino-env ssh-agent`)
|
|
284
284
|
|
|
285
|
-
La llave SSH
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
`
|
|
285
|
+
La llave SSH es un secreto más del cajón (`SSH_KEY_*`, el archivo en base64). Al arrancar,
|
|
286
|
+
el agente pide el cajón a la bóveda (con la aprobación del teléfono si el aparato la pide),
|
|
287
|
+
carga las llaves en memoria y sirve el protocolo de `ssh-agent`; en el disco no queda nada.
|
|
288
|
+
`dotrino-env ssh-agent --ns ssh` imprime el `SSH_AUTH_SOCK`. Como librería:
|
|
289
|
+
`loadPrivateKey`, `publicLine`, `signSsh` (`src/sshKeys.js`) y `startSshAgent` (`src/sshAgent.js`).
|
package/lib/src/enroll.js
CHANGED
|
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
|
|
|
52
52
|
export const MSG_ERROR = 'vault.error'
|
|
53
53
|
|
|
54
54
|
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
55
|
-
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
|
|
55
|
+
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
|
|
56
56
|
export const scopeToCaps = (scope) =>
|
|
57
57
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
58
58
|
|
package/lib/src/index.js
CHANGED
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
* (`identity.signDelegation`). Transporte: `@dotrino/proxy-client` (import perezoso).
|
|
26
26
|
* No reimplementa nada del ecosistema.
|
|
27
27
|
*/
|
|
28
|
-
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
28
|
+
import { verifyChain, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
29
29
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
30
30
|
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
31
|
-
// local se queda corta, el dispositivo deja de
|
|
31
|
+
// local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
|
|
32
32
|
import { MSG, SCOPE } from './protocol.js'
|
|
33
33
|
|
|
34
34
|
const SIGN_SCOPE = SCOPE.SIGN
|
|
@@ -168,15 +168,132 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
168
168
|
if (mine) desk.emitRevoke(chk.device, mine.nonce)
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Lo que el daemon comprueba antes de cualquier operación firmada, en un solo sitio:
|
|
173
|
+
* frescura (anti-replay), cadena de certs, scope esperado y revocaciones.
|
|
174
|
+
*
|
|
175
|
+
* Estaba repetido en `handleRenew` y `handleDevices` con matices distintos; al añadir
|
|
176
|
+
* el resto de operaciones eso habría sido cuatro copias divergiendo.
|
|
177
|
+
*/
|
|
178
|
+
async function authorise (from, p, expectedScope) {
|
|
179
|
+
const d = p?.data
|
|
180
|
+
if (!d || !p.signature || !p.cert) {
|
|
181
|
+
send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
185
|
+
send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or a clock out of sync)' })
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
const chk = await verifyChain({
|
|
189
|
+
data: d, signature: p.signature, cert: p.cert,
|
|
190
|
+
...(expectedScope ? { expectedScope } : {}),
|
|
191
|
+
trustedIssuer: iss, revoked: await revocationSet(),
|
|
192
|
+
})
|
|
193
|
+
if (!chk.ok) {
|
|
194
|
+
send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
return chk
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* FIRMAR en name de la identidad. Es la razón de ser de una bóveda, y faltaba: un
|
|
202
|
+
* aparato enrolado contra este dispositivo podía renovar su cert y listar aparatos,
|
|
203
|
+
* pero no pedir la única cosa para la que se enroló.
|
|
204
|
+
*/
|
|
205
|
+
async function handleSign (from, p) {
|
|
206
|
+
const chk = await authorise(from, p, SCOPE.SIGN)
|
|
207
|
+
if (!chk) return
|
|
208
|
+
const toSign = p.data?.payload
|
|
209
|
+
if (toSign == null) return send(from, { type: MSG.ERROR, error: 'data.payload required' })
|
|
210
|
+
const { signature, publickey } = await identity.signData(toSign)
|
|
211
|
+
send(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Leer del almacén del perfil. Mismo scope que en el daemon: `read`. */
|
|
215
|
+
async function handleGet (from, p) {
|
|
216
|
+
const chk = await authorise(from, p, SCOPE.READ)
|
|
217
|
+
if (!chk) return
|
|
218
|
+
const id = p.data?.id || 'root'
|
|
219
|
+
try {
|
|
220
|
+
const node = await identity.getNode?.(id)
|
|
221
|
+
send(from, { type: MSG.DATA, id, node: node ?? null })
|
|
222
|
+
} catch (e) {
|
|
223
|
+
send(from, { type: MSG.ERROR, error: 'get: ' + e.message })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Escribir en el almacén. Se pasa por `vaultStore`, que es el mismo camino que usa
|
|
229
|
+
* un aparato contra el daemon — no se reimplementa el store aquí.
|
|
230
|
+
*/
|
|
231
|
+
async function handleStore (from, p) {
|
|
232
|
+
const d = p?.data
|
|
233
|
+
if (!d || typeof d.method !== 'string') {
|
|
234
|
+
return send(from, { type: MSG.ERROR, error: 'store: invalid method' })
|
|
235
|
+
}
|
|
236
|
+
const chk = await authorise(from, p, SCOPE.STORE)
|
|
237
|
+
if (!chk) return
|
|
238
|
+
try {
|
|
239
|
+
const result = await identity.vaultStore?.(d.method, d.args || [])
|
|
240
|
+
send(from, { type: MSG.DATA, id: d.method, node: result ?? null })
|
|
241
|
+
} catch (e) {
|
|
242
|
+
send(from, { type: MSG.ERROR, error: 'store: ' + e.message })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* ¿Sigue este aparato dentro del acta? Lo pregunta un aparato al arrancar, y por eso
|
|
248
|
+
* NO va firmado con cert: va firmado con su propia llave. Un aparato revocado tiene
|
|
249
|
+
* que poder enterarse de que lo está.
|
|
250
|
+
*/
|
|
251
|
+
async function handleCheck (from, p) {
|
|
252
|
+
const d = p?.data
|
|
253
|
+
if (!d || typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
254
|
+
const pub = d.publickey
|
|
255
|
+
if (typeof pub !== 'string') return send(from, { type: MSG.ERROR, error: 'unauthorized: shape' })
|
|
256
|
+
if (!(await verifyDeviceSig({ publickey: pub, data: d, signature: p.signature }))) {
|
|
257
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature' })
|
|
258
|
+
}
|
|
259
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
260
|
+
const inside = (record?.members || []).some((m) => m?.pub === pub)
|
|
261
|
+
if (inside) return send(from, { type: MSG.CHECKED, in: true })
|
|
262
|
+
|
|
263
|
+
// Fuera del acta: se le dice, y además se le re-emite el aviso firmado si consta
|
|
264
|
+
// revocado — para que se apague solo en vez de quedarse creyendo que sigue dentro.
|
|
265
|
+
const { revokedCerts, issued } = await identity.listDelegations()
|
|
266
|
+
const mine = (revokedCerts || issued || []).find((x) => x.sub === pub && x.revokedAt)
|
|
267
|
+
if (mine) desk.emitRevoke(pub, mine.nonce)
|
|
268
|
+
send(from, { type: MSG.CHECKED, in: false })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Un fallo dentro de un handler NO se traga.
|
|
273
|
+
*
|
|
274
|
+
* El router llevaba `.catch(() => {})` en cada rama: si algo reventaba, el aparato del
|
|
275
|
+
* otro lado se quedaba esperando para siempre y aquí no quedaba rastro. Ahora se
|
|
276
|
+
* contesta el error — que es lo que permite depurarlo desde el lado que pregunta.
|
|
277
|
+
*/
|
|
278
|
+
const handle = (name, promise, from) => Promise.resolve(promise).catch((e) => {
|
|
279
|
+
send(from, { type: MSG.ERROR, error: `${name}: ${e?.message || e}` })
|
|
280
|
+
})
|
|
281
|
+
|
|
171
282
|
client.on('message', (_from, p) => {
|
|
172
283
|
if (!p || typeof p !== 'object') return
|
|
173
284
|
// El QR corto no lleva la llave: el aparato la pide con un HELLO presentando el `sn`.
|
|
174
|
-
if (p.type === MSG.HELLO)
|
|
175
|
-
else if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p)
|
|
285
|
+
if (p.type === MSG.HELLO) handle('hello', desk.handleHello(_from, p), _from)
|
|
286
|
+
else if (p.type === MSG.ENROLL) handle('enroll', desk.handleEnroll(_from, p), _from)
|
|
176
287
|
// Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
|
|
177
|
-
else if (p.type === MSG.ACTA_SEALED)
|
|
178
|
-
else if (p.type === MSG.RENEW) handleRenew(_from, p)
|
|
179
|
-
else if (p.type === MSG.DEVICES) handleDevices(_from, p)
|
|
288
|
+
else if (p.type === MSG.ACTA_SEALED) handle('acta', desk.handleActaSealed(_from, p), _from)
|
|
289
|
+
else if (p.type === MSG.RENEW) handle('renew', handleRenew(_from, p), _from)
|
|
290
|
+
else if (p.type === MSG.DEVICES) handle('devices', handleDevices(_from, p), _from)
|
|
291
|
+
// Lo que faltaba para que un aparato enrolado aquí pueda hacer lo mismo que contra
|
|
292
|
+
// el daemon del PC: firmar, leer, guardar y comprobar que sigue dentro.
|
|
293
|
+
else if (p.type === MSG.SIGN) handle('sign', handleSign(_from, p), _from)
|
|
294
|
+
else if (p.type === MSG.GET) handle('get', handleGet(_from, p), _from)
|
|
295
|
+
else if (p.type === MSG.STORE) handle('store', handleStore(_from, p), _from)
|
|
296
|
+
else if (p.type === MSG.CHECK) handle('check', handleCheck(_from, p), _from)
|
|
180
297
|
})
|
|
181
298
|
|
|
182
299
|
/**
|
package/lib/src/invite.js
CHANGED
|
@@ -69,10 +69,12 @@ export const FMT_SHORT = 't'
|
|
|
69
69
|
export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* La base del enlace del QR. Corta a propósito (`/d#v=` en vez de
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
72
|
+
* La base del enlace del QR. Corta a propósito (`/d#v=` en vez de `/vault#vault=`):
|
|
73
|
+
* son caracteres menos dentro del QR, y ahí los caracteres se pagan en módulos.
|
|
74
|
+
*
|
|
75
|
+
* `parseInvite` no mira la ruta, solo el #fragment: por eso un enlace emitido con
|
|
76
|
+
* cualquiera de las formas anteriores sigue emparejando aunque su dirección ya no
|
|
77
|
+
* exista.
|
|
76
78
|
*/
|
|
77
79
|
export const PAIR_URL = 'https://vault.dotrino.com/d#v='
|
|
78
80
|
|
package/lib/src/protocol.js
CHANGED
|
@@ -98,7 +98,11 @@ export const SCOPE = Object.freeze({
|
|
|
98
98
|
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
99
99
|
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
100
100
|
ADMIN: 'vault:admin',
|
|
101
|
-
APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
101
|
+
APPROVE: 'vault:approve', // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
102
|
+
// El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
|
|
103
|
+
// Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
|
|
104
|
+
// lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
|
|
105
|
+
PASSWORDS: 'vault:passwords'
|
|
102
106
|
})
|
|
103
107
|
|
|
104
108
|
/**
|
package/lib/src/service.js
CHANGED
|
@@ -338,7 +338,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
338
338
|
export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
|
|
339
339
|
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
340
340
|
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
341
|
-
label = label ||
|
|
341
|
+
label = label || ns
|
|
342
342
|
|
|
343
343
|
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
344
344
|
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
@@ -491,54 +491,6 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
491
491
|
} finally { client.close() }
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
-
/**
|
|
495
|
-
* AGENTE SSH DELGADO (`dotrino-env ssh-agent`): este proceso no custodia nada. Lista las
|
|
496
|
-
* llaves públicas que la bóveda tiene registradas y, por cada reto, le pide a la bóveda
|
|
497
|
-
* que lo convierta en un PEDIDO que el teléfono firma. Cualquier aparato con `vault:sign`
|
|
498
|
-
* puede pedir; quien decide es el teléfono.
|
|
499
|
-
*/
|
|
500
|
-
function serviceArgs ({ dir, ns, proxyUrl, masterPubkey, device, cert }) {
|
|
501
|
-
let saved = dir ? readServiceIdentity(dir) : null
|
|
502
|
-
if (!saved && device && cert) saved = { ns, iss: masterPubkey, proxy: proxyUrl, device, cert }
|
|
503
|
-
const out = { ns: ns || saved?.ns, proxyUrl: proxyUrl || saved?.proxy, masterPubkey: masterPubkey || saved?.iss, device: device || saved?.device, cert: cert || saved?.cert }
|
|
504
|
-
if (!out.proxyUrl || !out.masterPubkey || !out.device || !out.cert) throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
|
|
505
|
-
return out
|
|
506
|
-
}
|
|
507
|
-
async function sshRpc (args, data, { timeoutMs = 30000, waitResult = null } = {}) {
|
|
508
|
-
const { proxyUrl, masterPubkey, device, cert } = serviceArgs(args)
|
|
509
|
-
const client = await freshClient(proxyUrl)
|
|
510
|
-
try {
|
|
511
|
-
await identifyAsService(client, device)
|
|
512
|
-
const signed = { ...data, publickey: device.publickey, ts: Date.now() }
|
|
513
|
-
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
|
|
514
|
-
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
515
|
-
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
|
|
516
|
-
let res = await pending
|
|
517
|
-
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
518
|
-
if (waitResult && res.body?.op === 'ssh.pending') {
|
|
519
|
-
try { waitResult.onPending?.({ id: res.body.id, exp: res.body.exp }) } catch (_) {}
|
|
520
|
-
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : APPROVAL_TIMEOUT_MS
|
|
521
|
-
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'ssh.sign.result') || p.type === MSG.ERROR, Math.min(until, APPROVAL_TIMEOUT_MS))
|
|
522
|
-
.catch((e) => { throw new Error(/timeout/.test(e.message) ? 'ssh: nobody approved the request in time' : e.message) })
|
|
523
|
-
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
524
|
-
}
|
|
525
|
-
const ok = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
|
|
526
|
-
if (!ok) throw new Error('invalid master signature on the reply')
|
|
527
|
-
return res.body
|
|
528
|
-
} finally { client.close() }
|
|
529
|
-
}
|
|
530
|
-
/** Las llaves SSH públicas registradas en la bóveda: `[{ id, blob, comment }]`. */
|
|
531
|
-
export async function listSshKeys (args = {}) {
|
|
532
|
-
const body = await sshRpc(args, { op: 'ssh.keys.public' })
|
|
533
|
-
return Array.isArray(body.items) ? body.items : []
|
|
534
|
-
}
|
|
535
|
-
/** Pide la firma SSH de `data` con la llave `keyId`; devuelve el blob de firma (Buffer). */
|
|
536
|
-
export async function requestSshSign (args = {}, { keyId, data, onPending } = {}) {
|
|
537
|
-
const body = await sshRpc(args, { op: 'ssh.sign', key: keyId, data: Buffer.from(data).toString('base64') }, { waitResult: { onPending } })
|
|
538
|
-
if (typeof body.sig !== 'string') throw new Error('ssh: malformed signature reply')
|
|
539
|
-
return Buffer.from(body.sig, 'base64')
|
|
540
|
-
}
|
|
541
|
-
|
|
542
494
|
/**
|
|
543
495
|
* Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
|
|
544
496
|
* descifra con ella las variables privadas. Las públicas vienen en claro.
|
package/lib/src/sshAgent.js
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AGENTE SSH
|
|
3
|
-
*
|
|
2
|
+
* AGENTE SSH: un socket Unix con el protocolo de `ssh-agent` (draft-miller-ssh-agent)
|
|
3
|
+
* cuyas llaves viven SOLO EN MEMORIA — se las dio la bóveda al arrancar (`dotrino-env
|
|
4
|
+
* ssh-agent`, con la aprobación del teléfono si el aparato la pide). En el disco no hay
|
|
5
|
+
* nada. No acepta que le añadan llaves (`ssh-add` de un archivo se rechaza: la idea es
|
|
6
|
+
* justo que no haya archivos) ni hace de proxy de nada más.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* del disco se rechaza: la idea es justo que no haya llaves en el disco) y no hace de
|
|
9
|
-
* proxy de nada más.
|
|
10
|
-
*
|
|
11
|
-
* export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/dotrino-vault/ssh-agent.sock
|
|
12
|
-
* ssh-add -L # las llaves del teléfono
|
|
13
|
-
* ssh mi-servidor # el teléfono pide tu «sí» y firma
|
|
8
|
+
* export SSH_AUTH_SOCK=… # lo imprime dotrino-env ssh-agent
|
|
9
|
+
* ssh-add -L # las llaves del cajón
|
|
10
|
+
* ssh mi-servidor # firma en local, con la llave en memoria
|
|
14
11
|
*/
|
|
15
12
|
import fs from 'node:fs'
|
|
16
13
|
import net from 'node:net'
|
|
17
14
|
import path from 'node:path'
|
|
18
|
-
import { sshString, readStrings } from './sshKeys.js'
|
|
15
|
+
import { sshString, readStrings, signSsh } from './sshKeys.js'
|
|
19
16
|
|
|
20
17
|
const AGENT_FAILURE = 5
|
|
21
18
|
const AGENT_SUCCESS = 6
|
|
@@ -37,30 +34,27 @@ const frame = (type, payload = Buffer.alloc(0)) => {
|
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
/**
|
|
40
|
-
* @param {{ socketPath: string,
|
|
37
|
+
* @param {{ socketPath: string, keys: () => any[], log?: Function }} opts
|
|
38
|
+
* `keys()`: las llaves cargadas (`loadPrivateKey` de sshKeys.js), con su privada en memoria.
|
|
41
39
|
*/
|
|
42
|
-
export function startSshAgent ({ socketPath,
|
|
40
|
+
export function startSshAgent ({ socketPath, keys, log = () => {} }) {
|
|
43
41
|
fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 })
|
|
44
42
|
try { fs.unlinkSync(socketPath) } catch (_) {}
|
|
45
43
|
|
|
46
44
|
async function handle (type, payload) {
|
|
47
|
-
|
|
48
|
-
const v = vault()
|
|
45
|
+
const list = keys() || []
|
|
49
46
|
if (type === REQUEST_IDENTITIES) {
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const parts = keys.map((k) => Buffer.concat([sshString(Buffer.from(k.blob, 'base64')), sshString(Buffer.from(k.comment || ''))]))
|
|
47
|
+
const n = Buffer.alloc(4); n.writeUInt32BE(list.length)
|
|
48
|
+
const parts = list.map((k) => Buffer.concat([sshString(k.blob), sshString(Buffer.from(k.comment || ''))]))
|
|
53
49
|
return frame(IDENTITIES_ANSWER, Buffer.concat([n, ...parts]))
|
|
54
50
|
}
|
|
55
51
|
if (type === SIGN_REQUEST) {
|
|
56
52
|
const [blob, data] = readStrings(payload, 2)
|
|
57
|
-
const
|
|
53
|
+
const flags = payload.length >= 4 ? payload.readUInt32BE(payload.length - 4) : 0
|
|
54
|
+
const key = list.find((k) => k.blob.equals(blob))
|
|
58
55
|
if (!key) return frame(AGENT_FAILURE)
|
|
59
|
-
try {
|
|
60
|
-
|
|
61
|
-
return frame(SIGN_RESPONSE, sshString(sig))
|
|
62
|
-
} catch (e) {
|
|
63
|
-
log('[vault] ssh-agent: not signed: ' + e.message)
|
|
56
|
+
try { return frame(SIGN_RESPONSE, sshString(signSsh(key, data, flags))) } catch (e) {
|
|
57
|
+
log('[ssh-agent] not signed: ' + e.message)
|
|
64
58
|
return frame(AGENT_FAILURE)
|
|
65
59
|
}
|
|
66
60
|
}
|
|
@@ -86,10 +80,10 @@ export function startSshAgent ({ socketPath, vault, log = () => {}, refresh = nu
|
|
|
86
80
|
})
|
|
87
81
|
sock.on('error', () => {})
|
|
88
82
|
})
|
|
89
|
-
server.on('error', (e) => log('[
|
|
83
|
+
server.on('error', (e) => log('[ssh-agent] ' + e.message))
|
|
90
84
|
server.listen(socketPath, () => {
|
|
91
85
|
try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
|
|
92
|
-
log(`[
|
|
86
|
+
log(`[ssh-agent] listening at ${socketPath}`)
|
|
93
87
|
})
|
|
94
88
|
return {
|
|
95
89
|
socketPath,
|
package/lib/src/sshKeys.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LLAVES SSH
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* era exactamente lo que había que sacar de aquí.
|
|
2
|
+
* LLAVES SSH como secretos de la bóveda. La llave privada vive sellada en un cajón
|
|
3
|
+
* (variables `SSH_KEY_*`, valor = el archivo de la llave en base64) y solo existe en claro
|
|
4
|
+
* en la memoria del agente que la pidió (`dotrino-env ssh-agent`). Aquí: leerla, sacar su
|
|
5
|
+
* pública en el formato de `authorized_keys` y firmar como manda SSH (RFC 4253/8332/5656).
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* · ed25519: formato OpenSSH («-----BEGIN OPENSSH PRIVATE KEY-----»), parseado a mano
|
|
8
|
+
* (Node no lo lee) — solo sin frase.
|
|
9
|
+
* · RSA / ECDSA P-256: PEM (PKCS#8 o tradicional), vía `node:crypto`.
|
|
10
|
+
*
|
|
11
|
+
* Puro: sin disco ni red.
|
|
11
12
|
*/
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
export const ECDSA_P256 = 'ecdsa-sha2-nistp256'
|
|
13
|
+
import { createPrivateKey, createPublicKey, sign as nodeSign, createHash } from 'node:crypto'
|
|
15
14
|
|
|
16
15
|
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b }
|
|
17
16
|
export const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)])
|
|
@@ -21,7 +20,6 @@ export function sshMpint (bytes) {
|
|
|
21
20
|
if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b])
|
|
22
21
|
return sshString(b)
|
|
23
22
|
}
|
|
24
|
-
/** Lee strings SSH encadenados: `[Buffer, …]`. */
|
|
25
23
|
export function readStrings (buf, max = 16) {
|
|
26
24
|
const out = []; let o = 0
|
|
27
25
|
while (o + 4 <= buf.length && out.length < max) {
|
|
@@ -31,46 +29,90 @@ export function readStrings (buf, max = 16) {
|
|
|
31
29
|
}
|
|
32
30
|
return out
|
|
33
31
|
}
|
|
32
|
+
export const fingerprint = (blob) => 'SHA256:' + createHash('sha256').update(Buffer.from(blob)).digest('base64').replace(/=+$/, '')
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
export function p256Blob ({ x, y }) {
|
|
37
|
-
const point = Buffer.concat([Buffer.from([4]), Buffer.from(x, 'base64url'), Buffer.from(y, 'base64url')])
|
|
38
|
-
return Buffer.concat([sshString(Buffer.from(ECDSA_P256)), sshString(Buffer.from('nistp256')), sshString(point)])
|
|
39
|
-
}
|
|
34
|
+
const OPENSSH_MAGIC = 'openssh-key-v1\0'
|
|
40
35
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
36
|
+
/** Llave ed25519 en formato OpenSSH sin frase → { type, privateKey (KeyObject), blob }. */
|
|
37
|
+
function parseOpenSsh (text) {
|
|
38
|
+
const b64 = text.replace(/-----(BEGIN|END) OPENSSH PRIVATE KEY-----/g, '').replace(/\s+/g, '')
|
|
39
|
+
const buf = Buffer.from(b64, 'base64')
|
|
40
|
+
if (buf.subarray(0, OPENSSH_MAGIC.length).toString('latin1') !== OPENSSH_MAGIC) throw new Error('ssh: not an OpenSSH private key')
|
|
41
|
+
// string cipher · string kdf · string kdfoptions · uint32 nkeys · string pub · string priv
|
|
42
|
+
let body = buf.subarray(OPENSSH_MAGIC.length)
|
|
43
|
+
const [cipher, kdf, kdfopts] = readStrings(body, 3)
|
|
44
|
+
body = body.subarray(12 + cipher.length + kdf.length + kdfopts.length)
|
|
45
|
+
if (cipher.toString() !== 'none' || kdf.toString() !== 'none') throw new Error('ssh: passphrase-protected keys are not supported (store the key without one; the vault is the lock)')
|
|
46
|
+
const [pubBlob, priv] = readStrings(body.subarray(4), 2)
|
|
47
|
+
const [type] = readStrings(pubBlob, 1)
|
|
48
|
+
if (type.toString() !== 'ssh-ed25519') {
|
|
49
|
+
// Otros tipos en formato OpenSSH: conviértelos a PEM (`ssh-keygen -p -m PEM`).
|
|
50
|
+
throw new Error(`ssh: ${type.toString()} in OpenSSH format is not supported; convert it with ssh-keygen -p -m PEM`)
|
|
51
|
+
}
|
|
52
|
+
// priv: uint32 check ×2, string type, string pub(32), string priv(64 = seed‖pub), string comment
|
|
53
|
+
const [t2, , sk] = readStrings(priv.subarray(8), 3)
|
|
54
|
+
if (t2.toString() !== 'ssh-ed25519' || sk.length !== 64) throw new Error('ssh: malformed ed25519 key')
|
|
55
|
+
const seed = sk.subarray(0, 32)
|
|
56
|
+
// PKCS#8 de ed25519: prefijo fijo + semilla de 32 bytes.
|
|
57
|
+
const pkcs8 = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed])
|
|
58
|
+
const privateKey = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' })
|
|
59
|
+
return { type: 'ssh-ed25519', privateKey, blob: Buffer.from(pubBlob) }
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
/**
|
|
57
|
-
export function
|
|
58
|
-
|
|
62
|
+
/** Blob público SSH de una KeyObject RSA / P-256 / ed25519. */
|
|
63
|
+
export function publicBlob (privateKey) {
|
|
64
|
+
const pub = createPublicKey(privateKey)
|
|
65
|
+
const jwk = pub.export({ format: 'jwk' })
|
|
66
|
+
if (jwk.kty === 'RSA') {
|
|
67
|
+
return Buffer.concat([sshString(Buffer.from('ssh-rsa')), sshMpint(Buffer.from(jwk.e, 'base64url')), sshMpint(Buffer.from(jwk.n, 'base64url'))])
|
|
68
|
+
}
|
|
69
|
+
if (jwk.kty === 'EC' && jwk.crv === 'P-256') {
|
|
70
|
+
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')])
|
|
71
|
+
return Buffer.concat([sshString(Buffer.from('ecdsa-sha2-nistp256')), sshString(Buffer.from('nistp256')), sshString(point)])
|
|
72
|
+
}
|
|
73
|
+
if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') {
|
|
74
|
+
return Buffer.concat([sshString(Buffer.from('ssh-ed25519')), sshString(Buffer.from(jwk.x, 'base64url'))])
|
|
75
|
+
}
|
|
76
|
+
throw new Error('ssh: unsupported key type ' + jwk.kty + '/' + (jwk.crv || ''))
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* blob inválido dejaría al `ssh` del usuario con un error opaco y a nosotros sin bitácora.
|
|
80
|
+
* Lee una llave privada (texto del archivo: OpenSSH ed25519 o PEM RSA/P-256) y devuelve
|
|
81
|
+
* lo que el agente necesita: `{ type, privateKey, blob, id, comment }`.
|
|
65
82
|
*/
|
|
66
|
-
export function
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
export function loadPrivateKey (text, comment = '') {
|
|
84
|
+
const t = String(text || '').trim()
|
|
85
|
+
let privateKey, blob
|
|
86
|
+
if (t.startsWith('-----BEGIN OPENSSH PRIVATE KEY-----')) ({ privateKey, blob } = parseOpenSsh(t))
|
|
87
|
+
else { privateKey = createPrivateKey(t); blob = publicBlob(privateKey) }
|
|
88
|
+
const [type] = readStrings(blob, 1)
|
|
89
|
+
return { type: type.toString(), privateKey, blob, id: fingerprint(blob), comment }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Línea `authorized_keys` de una llave cargada. */
|
|
93
|
+
export const publicLine = (k) => `${k.type} ${k.blob.toString('base64')}${k.comment ? ' ' + k.comment : ''}`
|
|
94
|
+
|
|
95
|
+
const SSH_AGENT_RSA_SHA2_256 = 2
|
|
96
|
+
const SSH_AGENT_RSA_SHA2_512 = 4
|
|
97
|
+
|
|
98
|
+
/** Firma `data` como manda SSH para ese tipo de llave; devuelve el blob de firma. */
|
|
99
|
+
export function signSsh (k, data, flags = 0) {
|
|
100
|
+
if (k.type === 'ssh-ed25519') {
|
|
101
|
+
return Buffer.concat([sshString(Buffer.from('ssh-ed25519')), sshString(nodeSign(null, Buffer.from(data), k.privateKey))])
|
|
102
|
+
}
|
|
103
|
+
if (k.type === 'ecdsa-sha2-nistp256') {
|
|
104
|
+
const raw = nodeSign('sha256', Buffer.from(data), { key: k.privateKey, dsaEncoding: 'ieee-p1363' })
|
|
105
|
+
const rs = Buffer.concat([sshMpint(raw.subarray(0, 32)), sshMpint(raw.subarray(32))])
|
|
106
|
+
return Buffer.concat([sshString(Buffer.from('ecdsa-sha2-nistp256')), sshString(rs)])
|
|
107
|
+
}
|
|
108
|
+
if (k.type === 'ssh-rsa') {
|
|
109
|
+
// Sin flags es el `ssh-rsa` (SHA-1) histórico, que los servidores modernos rechazan;
|
|
110
|
+
// OpenSSH pide rsa-sha2-256/512 con las banderas del agente (RFC 8332).
|
|
111
|
+
const algo = (flags & SSH_AGENT_RSA_SHA2_512) ? 'rsa-sha2-512' : (flags & SSH_AGENT_RSA_SHA2_256) ? 'rsa-sha2-256' : 'ssh-rsa'
|
|
112
|
+
const hash = algo === 'rsa-sha2-512' ? 'sha512' : algo === 'rsa-sha2-256' ? 'sha256' : 'sha1'
|
|
113
|
+
return Buffer.concat([sshString(Buffer.from(algo)), sshString(nodeSign(hash, Buffer.from(data), k.privateKey))])
|
|
114
|
+
}
|
|
115
|
+
throw new Error('ssh: cannot sign with ' + k.type)
|
|
74
116
|
}
|
|
75
117
|
|
|
76
|
-
export default {
|
|
118
|
+
export default { loadPrivateKey, publicBlob, publicLine, signSsh, sshString, sshMpint, readStrings, fingerprint }
|