@dotrino/vault 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/package.json +35 -0
  4. package/src/index.js +229 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 seyacat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @dotrino/vault
2
+
3
+ Usa **este dispositivo (navegador) como bóveda/CA** del ecosistema Dotrino, sin un PC
4
+ con el daemon. Es la contraparte browser del daemon `dotrino-vault`: atiende el mismo
5
+ protocolo de enrolamiento endurecido por el proxy y firma certificados de delegación
6
+ `D ← P` (donde `P` es la identidad de este dispositivo, `@dotrino/identity`).
7
+
8
+ Pensado para que **cualquier app** del ecosistema (no solo la terminal) pueda ofrecer
9
+ "usar este dispositivo como bóveda".
10
+
11
+ ## Uso
12
+
13
+ ```js
14
+ import { Identity } from '@dotrino/identity'
15
+ import { startDeviceVault } from '@dotrino/vault'
16
+
17
+ const identity = await Identity.connect()
18
+ const vault = await startDeviceVault(identity) // se conecta al proxy como P
19
+
20
+ // 1) Abrir un emparejamiento y mostrar el QR/JSON al dispositivo a enrolar:
21
+ const { qr } = vault.startPairing({ label: 'mi-agente' })
22
+ // El dispositivo (p. ej. @dotrino/identity#enrollDevice) consume `qr`, GENERA un
23
+ // código aleatorio y lo MUESTRA (no lo envía).
24
+
25
+ // 2) Cuando el dispositivo pide acceso, aparece en la lista de pendientes:
26
+ vault.onPendingChange(() => {
27
+ for (const { deviceId } of vault.listPending()) {
28
+ // Un humano LEE el código del dispositivo y lo TIPEA aquí:
29
+ // await vault.approve(deviceId, codigoTipeado)
30
+ }
31
+ })
32
+
33
+ // 3) Máquinas ya enroladas / revocar:
34
+ const machines = await vault.listMachines() // [{ sub, deviceId, label, exp, nonce, scope }]
35
+ // await vault.revoke(nonce)
36
+
37
+ vault.close()
38
+ ```
39
+
40
+ ## Modelo de aprobación (seguro por diseño)
41
+
42
+ - El **dispositivo** que se enrola genera un **código aleatorio** (`makePairingCode`) y
43
+ lo **muestra**; el código **no viaja** por la red.
44
+ - Esta bóveda **no conoce** el código: un humano lo **lee del dispositivo** y lo **tipea**
45
+ aquí. Al aprobar, la bóveda firma el cert y **echa** el código tipeado de vuelta.
46
+ - El dispositivo acepta el cert **solo si el código echado coincide** con el que generó.
47
+ Así, una bóveda falsa (que nunca vio el código) no puede enrolarlo, y **aprobar a ciegas**
48
+ (sin ir a leer el código del dispositivo) no enrola a nadie.
49
+
50
+ ## API
51
+
52
+ `startDeviceVault(identity, { proxyUrl? }) → Promise<handle>`
53
+
54
+ - `startPairing({ scope?, ttlMs?, label? }) → { qr, expiresInMs }`
55
+ - `listPending() → [{ deviceId, label }]`
56
+ - `approve(deviceId, code) → Promise<{ ok, deviceId }>` (code = lo que muestra el dispositivo)
57
+ - `reject(deviceId)`
58
+ - `listMachines() → Promise<[{ sub, deviceId, label, scope, exp, nonce }]>`
59
+ - `revoke(nonce) → Promise`
60
+ - `getSelfCert() → Promise<cert>` (self-cert `P ← P`, para actuar además de cliente)
61
+ - `onPendingChange(fn)`, `close()`
62
+
63
+ Cripto y firma: `@dotrino/identity`. Transporte: `@dotrino/proxy-client`. No reimplementa
64
+ nada del ecosistema.
65
+
66
+ MIT · parte de [Dotrino](https://dotrino.com).
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@dotrino/vault",
3
+ "version": "0.1.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. Contraparte browser del daemon dotrino-vault, reutilizable por cualquier app.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./src/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "dotrino",
20
+ "vault",
21
+ "identity",
22
+ "delegation",
23
+ "pairing"
24
+ ],
25
+ "peerDependencies": {
26
+ "@dotrino/identity": ">=0.17.0",
27
+ "@dotrino/proxy-client": ">=0.6.0"
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/imdotrino/dotrino-vault.git",
33
+ "directory": "lib"
34
+ }
35
+ }
package/src/index.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * @dotrino/vault — "este dispositivo es una bóveda" (lado SERVIDOR, browser+node).
3
+ *
4
+ * Convierte la identidad de ESTE dispositivo (`@dotrino/identity`, la clave P) en una
5
+ * bóveda/CA: atiende el MISMO protocolo de enrolamiento endurecido que el daemon
6
+ * `dotrino-vault` (`vault.enroll` → `vault.enroll.challenge` → `vault.enrolled`) por el
7
+ * proxy del ecosistema, firma certificados de delegación `D ← P` al aprobar, y responde
8
+ * consultas de revocación (`vault.devices`). Así CUALQUIER app (no solo la terminal)
9
+ * puede dejar que el usuario use su dispositivo como bóveda, sin un PC con el daemon.
10
+ *
11
+ * Modelo de aprobación SEGURO (idéntico al daemon `dotrino-vault#approveDevice`):
12
+ * - El DISPOSITIVO que se enrola (p. ej. `@dotrino/identity#enrollDevice`) genera un
13
+ * código ALEATORIO (`makePairingCode`) y lo MUESTRA; NO lo envía por la red.
14
+ * - Esta bóveda NO conoce el código: un humano lo LEE del dispositivo y lo TIPEA aquí.
15
+ * - Al aprobar, la bóveda firma el cert y ECHA el código tipeado de vuelta.
16
+ * - El dispositivo acepta el cert SOLO si el código echado coincide con el que generó.
17
+ * → una bóveda falsa (que nunca vio el código) no puede enrolar el dispositivo, y
18
+ * aprobar "a ciegas" (sin ir a leer el código del dispositivo) tampoco enrola nada.
19
+ *
20
+ * Cripto 100% de `@dotrino/identity/capabilities` (verifyDeviceSig/verifyChain/pubkeyId)
21
+ * + firma con la identidad P (`identity.signDelegation`). Transporte: `@dotrino/proxy-client`
22
+ * (import perezoso). No reimplementa nada del ecosistema.
23
+ */
24
+ import { verifyDeviceSig, verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
25
+
26
+ 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
+ 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
+
32
+ const MSG = {
33
+ ENROLL: 'vault.enroll',
34
+ ENROLL_CHALLENGE: 'vault.enroll.challenge',
35
+ ENROLLED: 'vault.enrolled',
36
+ DEVICES: 'vault.devices',
37
+ DEVICES_RESULT: 'vault.devices.result',
38
+ ERROR: 'vault.error'
39
+ }
40
+
41
+ function randToken () {
42
+ const b = crypto.getRandomValues(new Uint8Array(16))
43
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
44
+ }
45
+
46
+ /** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
47
+ export function deviceIdOf (pub) {
48
+ return pubkeyId(pub).then((id) => id.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
49
+ }
50
+
51
+ /**
52
+ * Levanta la bóveda de este dispositivo: se conecta al proxy identificado como P y
53
+ * atiende enrolamientos + consultas de revocación de los dispositivos que se enrolan.
54
+ *
55
+ * @param {object} identity instancia de `@dotrino/identity` (P): expone
56
+ * `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
57
+ * @param {object} [opts]
58
+ * @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
59
+ * @returns {Promise<object>} handle: { iss, proxy, client, startPairing, approve, reject,
60
+ * listPending, listMachines, revoke, getSelfCert, onPendingChange, close }
61
+ */
62
+ export async function startDeviceVault (identity, { proxyUrl } = {}) {
63
+ const iss = identity.me?.publickey
64
+ if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
65
+ const proxy = proxyUrl || 'wss://proxy.dotrino.com'
66
+
67
+ // ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
68
+ // de sus propias máquinas: lo firma la propia P y verifyChain lo acepta) -----
69
+ let _selfCert = null
70
+ const getSelfCert = async () => {
71
+ if (_selfCert && _selfCert.exp > Date.now() + 60_000) return _selfCert
72
+ const { cert } = await identity.signDelegation(iss, SIGN_SCOPE, { ttlMs: SELFCERT_TTL_MS })
73
+ _selfCert = cert
74
+ return cert
75
+ }
76
+
77
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
78
+ const client = new WebSocketProxyClient({
79
+ url: proxy, enableWebRTC: false, autoReconnect: true,
80
+ maxReconnectAttempts: 100000, reconnectDelay: 4000
81
+ })
82
+ await client.connect()
83
+
84
+ const selfCert = await getSelfCert()
85
+ const identify = async () => {
86
+ if (!client.token) return
87
+ const data = { op: 'identify', publickey: iss, token: client.token, ts: Date.now() }
88
+ const { signature } = await identity.signData(data)
89
+ await client.identify({ data, signature, cert: selfCert })
90
+ }
91
+ await identify()
92
+ client.on('token', () => identify().catch(() => {}))
93
+
94
+ const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
95
+
96
+ // token -> { exp, sn, scope, ttlMs, label, state, dpub?, deviceId?, from? }
97
+ const pending = new Map()
98
+ let _onPendingChange = () => {}
99
+
100
+ async function handleEnroll (from, p) {
101
+ const d = p?.data
102
+ if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
103
+ return send(from, { type: MSG.ERROR, error: 'enroll inválido' })
104
+ }
105
+ const pend = pending.get(d.token)
106
+ if (!pend || Date.now() > pend.exp) {
107
+ return send(from, { type: MSG.ERROR, error: 'token de emparejamiento inválido o expirado' })
108
+ }
109
+ if (d.sn !== pend.sn) return send(from, { type: MSG.ERROR, error: 'sesión inválida' })
110
+ if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
111
+ return send(from, { type: MSG.ERROR, error: 'enroll vencido (posible replay, o el reloj desfasado)' })
112
+ }
113
+ // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
114
+ const ok = await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature })
115
+ if (!ok) return send(from, { type: MSG.ERROR, error: 'firma de dispositivo inválida' })
116
+ // Un solo dispositivo a la vez esperando su código (así `approve` no es ambiguo).
117
+ if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
118
+ return send(from, { type: MSG.ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
119
+ }
120
+ const deviceId = await deviceIdOf(d.dpub)
121
+ pend.state = 'PENDING_CONFIRM'
122
+ pend.dpub = d.dpub
123
+ pend.deviceId = deviceId
124
+ pend.from = from // esta bóveda NO conoce el código (no viaja): el dispositivo lo MUESTRA
125
+ if (d.label) pend.label = String(d.label).slice(0, 60)
126
+ _onPendingChange()
127
+ send(from, { type: MSG.ENROLL_CHALLENGE, deviceId })
128
+ }
129
+
130
+ // Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
131
+ // de dispositivos enrolados + revocados para que el dispositivo refresque su set.
132
+ async function handleDevices (from, p) {
133
+ const d = p?.data
134
+ if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
135
+ if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
136
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
137
+ if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
138
+ const { issued, revoked } = await identity.listDelegations()
139
+ const devices = await Promise.all((issued || []).map(async (x) => ({
140
+ deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
141
+ label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
142
+ })))
143
+ send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
144
+ }
145
+
146
+ client.on('message', (_from, p) => {
147
+ if (!p || typeof p !== 'object') return
148
+ if (p.type === MSG.ENROLL) handleEnroll(_from, p).catch(() => {})
149
+ else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
150
+ })
151
+
152
+ /**
153
+ * Abre un emparejamiento: devuelve el QR/JSON v2 que el dispositivo consume para
154
+ * enrolarse. `scope`/`ttlMs`/`label` fijan lo que otorgará el cert al aprobar.
155
+ */
156
+ function startPairing ({ scope = [SIGN_SCOPE], ttlMs = DEVICE_TTL_MS, label = '' } = {}) {
157
+ pending.clear()
158
+ const token = randToken()
159
+ const sn = randToken()
160
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, sn, scope, ttlMs, label, state: 'AWAITING_ENROLL' })
161
+ return { qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
162
+ }
163
+
164
+ function listPending () {
165
+ return [...pending.values()]
166
+ .filter((p) => p.state === 'PENDING_CONFIRM')
167
+ .map((p) => ({ deviceId: p.deviceId, label: p.label }))
168
+ }
169
+ function findPending (deviceId) {
170
+ for (const [, p] of pending) if (p.state === 'PENDING_CONFIRM' && p.deviceId === deviceId) return p
171
+ return null
172
+ }
173
+
174
+ /**
175
+ * Aprueba una máquina pendiente TIPEANDO el código que ella muestra. Esta bóveda NO
176
+ * conoce/valida el código: firma el cert y ECHA el código tipeado; la máquina lo acepta
177
+ * solo si coincide con el que generó. (Modelo `dotrino-vault#approveDevice`.)
178
+ */
179
+ async function approve (deviceId, code) {
180
+ const pend = findPending(deviceId)
181
+ if (!pend || !pend.dpub) throw new Error('no hay ninguna máquina esperando aprobación')
182
+ code = String(code || '').trim()
183
+ if (!code) throw new Error('escribe el código que muestra la máquina')
184
+ const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
185
+ send(pend.from, { type: MSG.ENROLLED, code, cert, iss })
186
+ pending.delete(pend.token)
187
+ _onPendingChange()
188
+ return { ok: true, deviceId }
189
+ }
190
+
191
+ function reject (deviceId) {
192
+ const pend = findPending(deviceId)
193
+ if (!pend) return
194
+ send(pend.from, { type: MSG.ERROR, error: 'emparejamiento rechazado' })
195
+ pending.delete(pend.token)
196
+ _onPendingChange()
197
+ }
198
+
199
+ /**
200
+ * Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
201
+ * propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
202
+ */
203
+ async function listMachines () {
204
+ const { issued } = await identity.listDelegations()
205
+ const now = Date.now()
206
+ const bySub = new Map()
207
+ for (const x of (issued || [])) {
208
+ if (!x.sub || (x.exp && x.exp <= now)) continue
209
+ if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
210
+ if (!x.label || x.label === 'cli') continue
211
+ if (!bySub.has(x.sub) || (x.exp || 0) > (bySub.get(x.sub).exp || 0)) bySub.set(x.sub, x)
212
+ }
213
+ return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
214
+ }
215
+
216
+ async function revoke (nonce) {
217
+ return identity.revokeDelegation(nonce)
218
+ }
219
+
220
+ return {
221
+ iss, proxy, client,
222
+ startPairing, approve, reject, listPending, listMachines, revoke,
223
+ getSelfCert,
224
+ onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
225
+ close () { try { client.close() } catch (_) {} }
226
+ }
227
+ }
228
+
229
+ export default { startDeviceVault, deviceIdOf }