@dotrino/vault 0.30.0 → 0.32.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 CHANGED
@@ -279,3 +279,10 @@ cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30
279
279
  | bitácora, cifrado en reposo, candado, multi-perfil | son del daemon; en el navegador dependen de `@dotrino/identity` |
280
280
 
281
281
  MIT · parte de [Dotrino](https://dotrino.com).
282
+
283
+ ## Agente SSH delgado (`dotrino-env ssh-agent`)
284
+
285
+ La llave SSH vive en el teléfono del dueño (registrada en su bóveda). En esta máquina solo
286
+ corre un agente `ssh-agent` sin llaves: cada `SIGN_REQUEST` viaja a la bóveda como un
287
+ pedido que el teléfono aprueba firmando. `dotrino-env ssh-agent --ns <ns>` imprime el
288
+ `SSH_AUTH_SOCK`. Como librería: `listSshKeys(args)` y `requestSshSign(args, { keyId, data })`.
@@ -38,6 +38,9 @@ function help () {
38
38
  status servicios enrolados en esta máquina
39
39
  check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
40
40
  run [--ns <ns>] -- <cmd> [args…]
41
+ ssh-agent [--ns <ns>] [--socket <ruta>]
42
+ agente SSH delgado: las llaves viven en el teléfono; cada firma es
43
+ un pedido a la bóveda. Pon SSH_AUTH_SOCK en la ruta que imprime.
41
44
  ejecuta <cmd> con los secretos inyectados en su entorno
42
45
 
43
46
  En tu código:
@@ -140,19 +143,50 @@ async function cmdRun () {
140
143
  const sep = argv.indexOf('--')
141
144
  const cmd = sep >= 0 ? argv.slice(sep + 1) : []
142
145
  if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
143
- const { injected, overridden } = await loadEnv({ ns: flag('ns') })
146
+ const onPending = () => console.error('[dotrino-env] waiting for approval on your phone…')
147
+ const { injected, overridden } = await loadEnv({ ns: flag('ns'), onPending })
144
148
  console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
145
149
  overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
146
150
  const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
147
151
  child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)))
148
152
  }
149
153
 
154
+ /**
155
+ * Agente SSH DELGADO. Aquí no hay ninguna llave: el `ssh` del usuario pide firmar, esto
156
+ * convierte el reto en un pedido a la bóveda (la que enroló este servicio, esté donde
157
+ * esté) y el teléfono firma. Las públicas se cachean 30 s para no preguntar en cada `ssh`.
158
+ */
159
+ async function cmdSshAgent () {
160
+ const { startSshAgent, defaultSocketPath } = await import('../src/sshAgent.js')
161
+ const { listSshKeys, requestSshSign } = await import('../src/service.js')
162
+ const ns = flag('ns') || undefined
163
+ const { serviceDir, resolveNs } = await import('../src/env.js')
164
+ const dir = flag('dir') || serviceDir(resolveNs(ns))
165
+ const socketPath = flag('socket') || defaultSocketPath(dir)
166
+ let cache = { at: 0, keys: [] }
167
+ const keys = async () => {
168
+ if (Date.now() - cache.at > 30 * 1000) { try { cache = { at: Date.now(), keys: await listSshKeys({ dir }) } } catch (e) { console.error('[dotrino-env] could not list keys: ' + e.message) } }
169
+ return cache.keys
170
+ }
171
+ const vault = {
172
+ sshKeys: () => cache.keys,
173
+ requestSshSign: ({ keyId, data }) => requestSshSign({ dir }, { keyId, data, onPending: () => console.error('[dotrino-env] ssh: waiting for approval on your phone…') })
174
+ }
175
+ await keys()
176
+ const agent = startSshAgent({ socketPath, vault: () => vault, log: console.error, refresh: keys })
177
+ console.error('export SSH_AUTH_SOCK=%s', agent.socketPath)
178
+ const stop = () => { agent.close(); process.exit(0) }
179
+ process.on('SIGINT', stop); process.on('SIGTERM', stop)
180
+ await new Promise(() => {})
181
+ }
182
+
150
183
  const run = async () => {
151
184
  switch (argv[0]) {
152
185
  case 'enroll': return cmdEnroll()
153
186
  case 'status': return cmdStatus()
154
187
  case 'check': return cmdCheck()
155
188
  case 'run': return cmdRun()
189
+ case 'ssh-agent': return cmdSshAgent()
156
190
  case undefined:
157
191
  case 'help':
158
192
  case '--help':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -46,7 +46,7 @@
46
46
  "pairing"
47
47
  ],
48
48
  "peerDependencies": {
49
- "@dotrino/identity": ">=0.56.0",
49
+ "@dotrino/identity": "^0.58.0",
50
50
  "@dotrino/proxy-client": ">=0.9.0"
51
51
  },
52
52
  "license": "MIT",
package/src/enroll.js CHANGED
@@ -341,8 +341,11 @@ export function createEnrollDesk ({
341
341
  let record = null
342
342
  try {
343
343
  if (typeof identity.admitMember === 'function') {
344
+ // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
345
+ // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
346
+ // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
344
347
  const cn = scopeToCn(pend.scope)
345
- const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
348
+ const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
346
349
  if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
347
350
  }
348
351
  record = (await identity.profileActa?.())?.acta || null
package/src/env.js CHANGED
@@ -98,12 +98,12 @@ let lastApplied = null
98
98
  * `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
99
99
  * el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
100
100
  */
101
- export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry } = {}) {
101
+ export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
102
102
  if (override === undefined) override = overrideByDefault()
103
103
  ns = resolveNs(ns)
104
104
  dir = dir || serviceDir(ns)
105
105
  const load = wait ? waitForSecrets : fetchSecrets
106
- const secrets = await load({ dir, ns, onRetry })
106
+ const secrets = await load({ dir, ns, onRetry, onPending })
107
107
 
108
108
  const missing = required.filter((k) => !(k in secrets))
109
109
  if (missing.length) {
package/src/protocol.js CHANGED
@@ -97,7 +97,8 @@ export const SCOPE = Object.freeze({
97
97
  // Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
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
- ADMIN: 'vault:admin'
100
+ ADMIN: 'vault:admin',
101
+ APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
101
102
  })
102
103
 
103
104
  /**
package/src/service.js CHANGED
@@ -161,6 +161,9 @@ async function identifyAsService (client, device) {
161
161
  await client.identify({ data, signature })
162
162
  }
163
163
 
164
+ /** Cuánto espera un agente a que alguien apruebe (el pedido vive 5 min en la bóveda). */
165
+ export const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 + 10 * 1000
166
+
164
167
  function waitForMsg (client, predicate, timeoutMs = 30000) {
165
168
  return new Promise((resolve, reject) => {
166
169
  const off = client.on('message', (_from, payload) => {
@@ -407,7 +410,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
407
410
  * Renueva el cert automáticamente si está por vencer (best-effort).
408
411
  * @returns {Promise<Record<string,string>>} secretos KEY→valor
409
412
  */
410
- export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000 } = {}) {
413
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
411
414
  let saved = null
412
415
  if (dir) saved = readServiceIdentity(dir)
413
416
  // Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
@@ -448,9 +451,22 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
448
451
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
449
452
  const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
450
453
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
451
- const res = await pending
454
+ let res = await pending
452
455
  if (res.type === MSG.ERROR) throw new Error(res.error)
453
456
 
457
+ // CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
458
+ // verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
459
+ // sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
460
+ if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
461
+ const okPending = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
462
+ if (!okPending) throw new Error('invalid master signature on the pending reply')
463
+ try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
464
+ const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
465
+ res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
466
+ .catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
467
+ if (res.type === MSG.ERROR) throw new Error(res.error)
468
+ }
469
+
454
470
  // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
455
471
  const body = res.body
456
472
  if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
@@ -475,6 +491,54 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
475
491
  } finally { client.close() }
476
492
  }
477
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
+
478
542
  /**
479
543
  * Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
480
544
  * descifra con ella las variables privadas. Las públicas vienen en claro.
@@ -882,15 +946,16 @@ function fingerprintOf (secrets) {
882
946
  * servicio no opera hasta que esto resuelva — esa es la regla.
883
947
  * @returns {Promise<Record<string,string>>}
884
948
  */
885
- export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
949
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending } = {}) {
886
950
  let delay = retryMs
887
951
  for (;;) {
888
952
  try {
889
- return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
953
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending })
890
954
  } catch (e) {
891
955
  // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
892
956
  // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
893
- if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
957
+ // Que te lo DENIEGUEN tampoco se reintenta: fue una decisión, no un tropiezo.
958
+ if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e.message)) throw e
894
959
  try { onRetry?.(e, delay) } catch (_) {}
895
960
  await new Promise((r) => setTimeout(r, delay))
896
961
  delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
@@ -0,0 +1,100 @@
1
+ /**
2
+ * AGENTE SSH del daemon: un socket Unix con el protocolo de `ssh-agent` (draft-miller-
3
+ * ssh-agent), para que el `ssh` del usuario firme con la llave que vive en su TELÉFONO.
4
+ *
5
+ * Lo que hace es poco a propósito: listar las llaves públicas registradas y, en cada
6
+ * `SIGN_REQUEST`, pedirle la firma al aparato que aprueba (`vault.requestSshSign`) y
7
+ * devolverla. No guarda llaves privadas, no acepta que le añadan (`ssh-add` de una llave
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
14
+ */
15
+ import fs from 'node:fs'
16
+ import net from 'node:net'
17
+ import path from 'node:path'
18
+ import { sshString, readStrings } from './sshKeys.js'
19
+
20
+ const AGENT_FAILURE = 5
21
+ const AGENT_SUCCESS = 6
22
+ const REQUEST_IDENTITIES = 11
23
+ const IDENTITIES_ANSWER = 12
24
+ const SIGN_REQUEST = 13
25
+ const SIGN_RESPONSE = 14
26
+
27
+ /** Dónde va el socket: en el directorio de ejecución del usuario si lo hay (se limpia solo). */
28
+ export function defaultSocketPath (dir) {
29
+ const run = process.env.XDG_RUNTIME_DIR
30
+ return run ? path.join(run, 'dotrino-vault', 'ssh-agent.sock') : path.join(dir, 'ssh-agent.sock')
31
+ }
32
+
33
+ const frame = (type, payload = Buffer.alloc(0)) => {
34
+ const body = Buffer.concat([Buffer.from([type]), payload])
35
+ const len = Buffer.alloc(4); len.writeUInt32BE(body.length)
36
+ return Buffer.concat([len, body])
37
+ }
38
+
39
+ /**
40
+ * @param {{ socketPath: string, vault: () => { sshKeys: () => any[], requestSshSign: (a: { keyId: string, data: Buffer }) => Promise<Buffer> }, log?: Function }} opts
41
+ */
42
+ export function startSshAgent ({ socketPath, vault, log = () => {}, refresh = null }) {
43
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 })
44
+ try { fs.unlinkSync(socketPath) } catch (_) {}
45
+
46
+ async function handle (type, payload) {
47
+ if (refresh) { try { await refresh() } catch (_) {} }
48
+ const v = vault()
49
+ if (type === REQUEST_IDENTITIES) {
50
+ const keys = v.sshKeys()
51
+ const n = Buffer.alloc(4); n.writeUInt32BE(keys.length)
52
+ const parts = keys.map((k) => Buffer.concat([sshString(Buffer.from(k.blob, 'base64')), sshString(Buffer.from(k.comment || ''))]))
53
+ return frame(IDENTITIES_ANSWER, Buffer.concat([n, ...parts]))
54
+ }
55
+ if (type === SIGN_REQUEST) {
56
+ const [blob, data] = readStrings(payload, 2)
57
+ const key = v.sshKeys().find((k) => k.blob === blob.toString('base64'))
58
+ if (!key) return frame(AGENT_FAILURE)
59
+ try {
60
+ const sig = await v.requestSshSign({ keyId: key.id, data })
61
+ return frame(SIGN_RESPONSE, sshString(sig))
62
+ } catch (e) {
63
+ log('[vault] ssh-agent: not signed: ' + e.message)
64
+ return frame(AGENT_FAILURE)
65
+ }
66
+ }
67
+ // Añadir/quitar llaves, candado, extensiones: nada de eso vive aquí.
68
+ return frame(AGENT_FAILURE)
69
+ }
70
+
71
+ const server = net.createServer((sock) => {
72
+ let buf = Buffer.alloc(0)
73
+ let busy = Promise.resolve()
74
+ sock.on('data', (chunk) => {
75
+ buf = Buffer.concat([buf, chunk])
76
+ while (buf.length >= 4) {
77
+ const len = buf.readUInt32BE(0)
78
+ if (len < 1 || len > 256 * 1024) { sock.destroy(); return }
79
+ if (buf.length < 4 + len) break
80
+ const type = buf[4]; const payload = buf.subarray(5, 4 + len)
81
+ buf = buf.subarray(4 + len)
82
+ // En orden: el protocolo es petición-respuesta, y un cliente que encadena dos
83
+ // firmas espera las respuestas en el mismo orden.
84
+ busy = busy.then(() => handle(type, payload)).then((out) => { if (!sock.destroyed) sock.write(out) }).catch(() => { if (!sock.destroyed) sock.write(frame(AGENT_FAILURE)) })
85
+ }
86
+ })
87
+ sock.on('error', () => {})
88
+ })
89
+ server.on('error', (e) => log('[vault] ssh-agent: ' + e.message))
90
+ server.listen(socketPath, () => {
91
+ try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
92
+ log(`[vault] ssh-agent listening at ${socketPath} (export SSH_AUTH_SOCK=${socketPath})`)
93
+ })
94
+ return {
95
+ socketPath,
96
+ close () { try { server.close() } catch (_) {} try { fs.unlinkSync(socketPath) } catch (_) {} }
97
+ }
98
+ }
99
+
100
+ export default { startSshAgent, defaultSocketPath, AGENT_SUCCESS }
package/src/sshKeys.js ADDED
@@ -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 }