@dotrino/vault 0.31.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 +7 -0
- package/bin/dotrino-env.js +35 -1
- package/package.json +1 -1
- package/src/service.js +48 -0
- package/src/sshAgent.js +100 -0
- package/src/sshKeys.js +76 -0
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 })`.
|
package/bin/dotrino-env.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|
package/src/service.js
CHANGED
|
@@ -491,6 +491,54 @@ 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
|
+
|
|
494
542
|
/**
|
|
495
543
|
* Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
|
|
496
544
|
* descifra con ella las variables privadas. Las públicas vienen en claro.
|
package/src/sshAgent.js
ADDED
|
@@ -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 }
|