@dotrino/vaultd 0.38.0 → 0.49.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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * LLAVES SSH del teléfono — solo la parte PÚBLICA, que es lo único que vive en esta
3
+ * máquina. La privada nace y muere en el aparato que aprueba (WebCrypto no extraíble hoy;
4
+ * el llavero del sistema en la app nativa): firmar un reto SSH es un pedido de aprobación
5
+ * más, y la firma vuelve con el «sí». Así la llave SSH no está en el disco del PC, que
6
+ * era exactamente lo que había que sacar de aquí.
7
+ *
8
+ * Formatos (RFC 4253 / 5656): el blob de una llave `ecdsa-sha2-nistp256` es
9
+ * string(tipo) string("nistp256") string(0x04‖X‖Y); su firma es string(tipo) string(mpint r ‖ mpint s).
10
+ * Puro: sin disco ni red — quien guarda es `vault.js`.
11
+ */
12
+ import { createPublicKey, createHash, verify as nodeVerify } from 'node:crypto'
13
+
14
+ export const ECDSA_P256 = 'ecdsa-sha2-nistp256'
15
+
16
+ const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b }
17
+ export const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)])
18
+ export function sshMpint (bytes) {
19
+ let b = Buffer.from(bytes)
20
+ while (b.length > 1 && b[0] === 0) b = b.subarray(1)
21
+ if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b])
22
+ return sshString(b)
23
+ }
24
+ /** Lee strings SSH encadenados: `[Buffer, …]`. */
25
+ export function readStrings (buf, max = 16) {
26
+ const out = []; let o = 0
27
+ while (o + 4 <= buf.length && out.length < max) {
28
+ const n = buf.readUInt32BE(o); o += 4
29
+ if (o + n > buf.length) throw new Error('ssh: truncated string')
30
+ out.push(buf.subarray(o, o + n)); o += n
31
+ }
32
+ return out
33
+ }
34
+
35
+ /** Blob de llave pública a partir de las coordenadas (base64url JWK). */
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
+ }
40
+
41
+ /**
42
+ * Acepta una línea `authorized_keys` (`ecdsa-sha2-nistp256 AAAA… comentario`) y devuelve
43
+ * lo que se guarda: tipo, blob, comentario y JWK pública. Solo P-256: es lo que firma
44
+ * WebCrypto y el llavero del teléfono; RSA no entra.
45
+ */
46
+ export function parsePublicKey (line) {
47
+ const [type, b64, ...rest] = String(line || '').trim().split(/\s+/)
48
+ if (type !== ECDSA_P256 || !b64) throw new Error(`ssh: only ${ECDSA_P256} keys are accepted`)
49
+ const blob = Buffer.from(b64, 'base64')
50
+ const [t, curve, point] = readStrings(blob, 3)
51
+ if (t.toString() !== ECDSA_P256 || curve.toString() !== 'nistp256' || point.length !== 65 || point[0] !== 4) throw new Error('ssh: malformed key blob')
52
+ const jwk = { kty: 'EC', crv: 'P-256', x: point.subarray(1, 33).toString('base64url'), y: point.subarray(33).toString('base64url') }
53
+ return { type, blob: blob.toString('base64'), comment: rest.join(' '), jwk, id: fingerprint(blob) }
54
+ }
55
+
56
+ /** `SHA256:…` como lo imprime `ssh-keygen -l`. */
57
+ export function fingerprint (blob) {
58
+ return 'SHA256:' + createHash('sha256').update(Buffer.from(blob)).digest('base64').replace(/=+$/, '')
59
+ }
60
+
61
+ /**
62
+ * Comprueba la firma cruda del teléfono (`r‖s`, 64 bytes, SHA-256 sobre `data`) y la
63
+ * convierte al blob de firma SSH. Una firma que no cuadra NO se convierte: devolver un
64
+ * blob inválido dejaría al `ssh` del usuario con un error opaco y a nosotros sin bitácora.
65
+ */
66
+ export function sshSignature ({ jwk, data, rawSig }) {
67
+ const sig = Buffer.from(rawSig, 'base64')
68
+ if (sig.length !== 64) throw new Error('ssh: raw signature must be 64 bytes (r||s)')
69
+ const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }, format: 'jwk' })
70
+ const ok = nodeVerify('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' }, sig)
71
+ if (!ok) throw new Error('ssh: the signature does not verify against the registered key')
72
+ const rs = Buffer.concat([sshMpint(sig.subarray(0, 32)), sshMpint(sig.subarray(32))])
73
+ return Buffer.concat([sshString(Buffer.from(ECDSA_P256)), sshString(rs)])
74
+ }
75
+
76
+ export default { ECDSA_P256, parsePublicKey, p256Blob, sshSignature, sshString, sshMpint, readStrings, fingerprint }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.38.0",
3
+ "version": "0.49.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -20,8 +20,8 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "^0.49.0",
24
- "@dotrino/proxy-client": "^0.10.0",
23
+ "@dotrino/identity": "^0.58.0",
24
+ "@dotrino/proxy-client": "^0.10.1",
25
25
  "ws": "^8.18.0"
26
26
  },
27
27
  "license": "MIT",
@@ -43,8 +43,8 @@
43
43
  "!lib/src/types.d.ts"
44
44
  ],
45
45
  "devDependencies": {
46
- "@dotrino/remote-agent": "^0.3.0",
47
- "typescript": "^5.7.3",
48
- "@types/node": "^22.0.0"
46
+ "@dotrino/remote-agent": "^0.3.2",
47
+ "@types/node": "^22.0.0",
48
+ "typescript": "5.9.3"
49
49
  }
50
50
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * PEDIDOS DE APROBACIÓN y VENTANAS de un cajón con `approval` (puro: sin red ni disco).
3
+ *
4
+ * Cuando un cajón exige aprobación por uso, leerlo ya no depende solo de tener el cert:
5
+ * el vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su
6
+ * firma libera los secretos. Aprobado, se abre una VENTANA para ese aparato y ese cajón
7
+ * (15 min): una sesión de trabajo no pide veinte veces. Lo que nadie aprueba vence solo.
8
+ *
9
+ * Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién
10
+ * pide y su llave efímera `ek` (a la que se sella la respuesta). Nunca un valor.
11
+ */
12
+ const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
13
+
14
+ export const PENDING_TTL_MS = 5 * 60 * 1000
15
+ export const GRANT_TTL_MS = 15 * 60 * 1000
16
+
17
+ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS, grantTtlMs = GRANT_TTL_MS } = {}) {
18
+ /** id → { id, ns, device, deviceId, label, ek, ts, exp } */
19
+ const pending = new Map()
20
+ /** `${ns}|${device}` → exp */
21
+ const grants = new Map()
22
+ const gkey = (ns, device) => ns + '|' + device
23
+
24
+ const publicOf = (p) => ({ id: p.id, kind: p.kind, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ...(p.ssh ? { ssh: p.ssh } : {}) })
25
+
26
+ return {
27
+ /**
28
+ * Apunta un pedido. `kind`: `secrets` (leer un cajón; uno por cajón y aparato: pedir
29
+ * otra vez reemplaza al anterior) o `ssh` (firmar un reto SSH con la llave del
30
+ * teléfono; cada firma es un pedido, con `ssh: { key, data }` y sus callbacks).
31
+ */
32
+ request ({ kind = 'secrets', ns, device = null, deviceId, label = '', ek = null, ssh = null, resolve = null, reject = null }) {
33
+ if (kind === 'secrets') for (const [id, p] of pending) if (p.kind === 'secrets' && p.ns === ns && p.device === device) pending.delete(id)
34
+ const ts = now()
35
+ const p = { id: rnd(), kind, ns, device, deviceId, label, ek, ssh, resolve, reject, ts, exp: ts + pendingTtlMs }
36
+ pending.set(p.id, p)
37
+ return publicOf(p)
38
+ },
39
+ /** Los pedidos vivos, sin la `ek` (no hace falta fuera de aquí). */
40
+ list () { this.sweep(); return [...pending.values()].map(publicOf) },
41
+ /** Saca un pedido para resolverlo (aprobar o denegar). `null` si no existe o venció. */
42
+ take (id) {
43
+ this.sweep()
44
+ const p = pending.get(id)
45
+ if (p) pending.delete(id)
46
+ return p || null
47
+ },
48
+ /** Abre la ventana de ese aparato sobre ese cajón. */
49
+ grant (ns, device) { const exp = now() + grantTtlMs; grants.set(gkey(ns, device), exp); return exp },
50
+ /** ¿Sigue abierta la ventana? */
51
+ has (ns, device) {
52
+ const exp = grants.get(gkey(ns, device))
53
+ if (exp == null) return false
54
+ if (exp <= now()) { grants.delete(gkey(ns, device)); return false }
55
+ return true
56
+ },
57
+ /** Cierra las ventanas de un aparato (al revocarlo, por ejemplo). */
58
+ forget (device) { for (const k of [...grants.keys()]) if (k.endsWith('|' + device)) grants.delete(k) },
59
+ /** Tira lo vencido; devuelve los pedidos que vencieron, para anotarlos. */
60
+ sweep () {
61
+ const t = now(); const gone = []
62
+ for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p)); try { p.reject?.(new Error('approval: nobody approved the request in time')) } catch (_) {} }
63
+ for (const [k, exp] of grants) if (exp <= t) grants.delete(k)
64
+ return gone
65
+ }
66
+ }
67
+ }
68
+
69
+ export default { createApprovals, PENDING_TTL_MS, GRANT_TTL_MS }