Created
March 25, 2021 10:49
-
-
Save dhensby/f01c42dc794a3710c0901e723fcaeb56 to your computer and use it in GitHub Desktop.
Example of publickey authentication with ssh2-streams and azure key vault
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const { CryptographyClient, KeyClient } = require('@azure/keyvault-keys'); | |
const { AzureCliCredential } = require('@azure/identity'); | |
const { SSH2Stream, utils, constants: { ALGORITHMS: { SUPPORTED_SERVER_HOST_KEY } } } = require('ssh2-streams'); | |
const { Socket } = require('net'); | |
const { asn1, pki, md, ssh, jsbn: { BigInteger } } = require('node-forge'); | |
const VAULT_URI = process.env.VAULT_URI; | |
// this is lifted from node-forge (https://github.com/digitalbazaar/forge/blob/0.10.0/lib/rsa.js#L284-L313) | |
// unfortunately it's not exported | |
const emsaPkcs1v15encode = function (messagedigest) { | |
// get the oid for the algorithm | |
let oid; | |
if (messagedigest.algorithm in pki.oids) { | |
oid = pki.oids[messagedigest.algorithm]; | |
} else { | |
const error = new Error('Unknown message digest algorithm.'); | |
error.algorithm = messagedigest.algorithm; | |
throw error; | |
} | |
const oidBytes = asn1.oidToDer(oid).getBytes(); | |
// create the digest info | |
const digestInfo = asn1.create( | |
asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, []); | |
const digestAlgorithm = asn1.create( | |
asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, []); | |
digestAlgorithm.value.push(asn1.create( | |
asn1.Class.UNIVERSAL, asn1.Type.OID, false, oidBytes)); | |
digestAlgorithm.value.push(asn1.create( | |
asn1.Class.UNIVERSAL, asn1.Type.NULL, false, '')); | |
const digest = asn1.create( | |
asn1.Class.UNIVERSAL, asn1.Type.OCTETSTRING, | |
false, messagedigest.digest().getBytes()); | |
digestInfo.value.push(digestAlgorithm); | |
digestInfo.value.push(digest); | |
// encode digest info | |
return Buffer.from(asn1.toDer(digestInfo).getBytes(), 'binary'); | |
}; | |
// load the azureKey into a key that SSH2Stream understands | |
async function getAzureKey() { | |
const azCred = new AzureCliCredential(); | |
const keyClient = new KeyClient(VAULT_URI, azCred); | |
const azKey = await keyClient.getKey('test'); | |
// we need the key in openssh format for the parseKey utility | |
const publicKey = pki.rsa.setPublicKey(new BigInteger(azKey.key.n), new BigInteger(azKey.key.e)); | |
const sshKey = ssh.publicKeyToOpenSSH(publicKey); | |
const key = utils.parseKey(sshKey); | |
// monkey patch the "sign" method to get the Azure KV key to sign the message | |
key.sign = async (data) => { | |
const cryptoClient = new CryptographyClient(azKey, azCred); | |
const message = md.sha1.create(); | |
message.update(data.toString('binary')); | |
// there is no sha1 signing in Azure KV, so we have to encode the asn1 message and send it | |
// to azure with 'RSNULL' to get it signed | |
const digest = emsaPkcs1v15encode(message); | |
const { result } = await cryptoClient.sign('RSNULL', digest); | |
return result; | |
}; | |
return key; | |
} | |
// host keys of the server to connect to | |
const hostKeys = { | |
'ecdsa-sha2-nistp256': Buffer.from('AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLH4RhqRIVFAxDidCPbZ2pQV6ZiPpN04xNnXT3O94xjKIobUN7rfQU9cs8B5zhreSJ2JIqHcyVvJpiuSQM5nDzs=', 'base64'), | |
'ssh-ed25519': Buffer.from('AAAAC3NzaC1lZDI1NTE5AAAAIOV3RJ8FpTf5J2j8yR1VSgq+XRyS4KDL2f65U8hB4ngE', 'base64'), | |
'ssh-rsa': Buffer.from('AAAAB3NzaC1yc2EAAAADAQABAAABgQDdLTzufyLWbX2iVB7t+JfCXfwRBKQC9Ty6QC511aj2ByxD1Fu2gGo93RjXNB7S9pWKnHQpJ8SinplttIDl5zUlrpRVcM7+mJNjcYw46kOLfxf0BldYRth5KeQ5C5o+U9FyvJ14RsZuuxptti3mt1VRVQlWkiLqUO+Sv83vIykIsba/F0DruxJsKbWeGryD6xea7GSm/sk5wgKBfwV7fmSxaQT4h2DGLzC5jCJX8EsGhhOioekCOo045705LLunxTVV2qEWUB8f6n/e0zgeF8hXhqM8aPvf1HkJUCp70qJA2xr7FAFoHZZjAnPvRwLo2y9IXUr62m0qc15a2cOk4guczydLAvZnRZpbqV5kf3LqsvbfljnccAJlRZq4dUkR26Eers9AyCOWlbZ5BxW14YkmL9kpr+s8aeUTfC0BXSq4jqNjVZ5wFhgB5hL5BAUGJsbZDlq8ZfRjzP0v18rzUE3bK1OZmGhGeKxngTEv91fHjFBNdjk+wJVnMJabqWlsgZE=', 'base64'), | |
'ssh-dss': Buffer.from('AAAAB3NzaC1kc3MAAACBAPDTltsn+HhW98ZC5q9rZ/FuXptXxEqJET4MqzqVlyZx0qbsJbGe4tcwqbQPQoKsF3us5IevbpDI2nBLtJNgV6VUU1n47URq8Mt2obIMSXNRNlXVhuEFs5ydr3fvV3+h7WH+Xt6cSbnNL1zcqb8GwZJ2kS/o5FNw8RUmo1Zs4T8/AAAAFQDdO2n28t93mJM9nfSjfthUlocqswAAAIBgWyljjSuA+q4iq6Qf6w7iN+zEKwZCXyvXT1eIPfQAFcFrQzx85TFaRLK/Or+Ctn/od7j6YkljG3RBjjxa+TZrUGmQvTCxiO1h7z8ow7itWpMAXn2ntm7wqkhFOFmCNXsJr43l6004Vks7gcbmLINGhz0xqP/VPtGt4SXB2bPTcwAAAIEA52hagD4Jr/5LTlKPxBkodFCK41peVSkkWL763tfEiXBZaL6Xt8LsIF/3s0Yub4X0B7DLqZgc/RQvhoh2bptkrTflCCnAPX99xOwi41GlUfIIjY0ESvd10e0KcYi9PtzcfHXUIDhdm5bmXiZuxi2xkzEWIjbMaYjIQtJs2s7Eze4=', 'base64'), | |
}; | |
// creates a socket to pass to the SSH2Stream | |
function connectSocket() { | |
const sock = new Socket(); | |
sock.connect({ | |
host: '', // pick your server | |
port: 22, | |
}); | |
sock.on('connect', () => { | |
console.log('connected'); | |
}); | |
sock.setNoDelay(true); | |
sock.setMaxListeners(0); | |
// sock.setTimeout(typeof cfg.timeout === 'number' ? cfg.timeout : 0); | |
return sock; | |
} | |
// create the stream | |
const stream = new SSH2Stream({ | |
// debug: console.log, | |
algorithms: { | |
serverHostKey: Object.keys(hostKeys).filter((alg) => SUPPORTED_SERVER_HOST_KEY.includes(alg)), | |
}, | |
}); | |
stream.on('header', (headerInfo) => { | |
console.log(headerInfo); | |
}); | |
// this is where we verify the host key | |
stream.on('fingerprint', (hostKey, cb) => { | |
console.log('Fingerprint'); | |
cb(Object.entries(hostKeys).some(([alg, known]) => { | |
return hostKey.equals(known) && (console.log('matched', alg) || true); | |
})); | |
}); | |
stream.on('USERAUTH_BANNER', (msg) => { | |
console.log(msg); | |
}); | |
stream.on('USERAUTH_INFO_REQUEST', (name, instructions, lang, prompts) => { | |
console.log('info request', { | |
name, | |
instructions, | |
lang, | |
prompts, | |
}); | |
}); | |
stream.on('USERAUTH_SUCCESS', () => { | |
console.log('user auth successful'); | |
}); | |
stream.on('USERAUTH_PK_OK', () => { | |
console.log('user pk ok'); | |
}); | |
stream.on('end', () => { | |
console.log('connection closed'); | |
}); | |
stream.once('ready', () => { | |
stream.service('ssh-userauth'); | |
stream.once('SERVICE_ACCEPT', async (svcName) => { | |
if (svcName === 'ssh-userauth') { | |
try { | |
const key = await getAzureKey(); | |
// parseKey can return an error or key | |
if (key instanceof Error) { | |
throw key; | |
} | |
// key auth can happen in 1 or 2 steps | |
// first check the public key is allowed - you can skip this if you only have one | |
// candidate key and you just want to attempt authentication | |
// await new Promise((resolve, reject) => { | |
// const acceptor = () => { | |
// stream.removeAllListeners('USERAUTH_FAILURE'); | |
// console.log('key accepted'); | |
// resolve(); | |
// }; | |
// const rejector = () => { | |
// stream.removeAllListeners('USERAUTH_PK_OK'); | |
// reject(new Error('Key rejected')); | |
// }; | |
// stream.authPK('droptest', key); | |
// stream.once('USERAUTH_PK_OK', acceptor); | |
// stream.once('USERAUTH_FAILURE', rejector); | |
// }); | |
// second step is use the allowed key to sign some data as proof of possession of | |
// the private key | |
await new Promise((resolve, reject) => { | |
stream.authPK('droptest', key, async (data, cb) => { | |
key.sign(data).then(cb); | |
}); | |
stream.once('USERAUTH_SUCCESS', () => { | |
resolve(); | |
stream.removeAllListeners('USERAUTH_FAILURE'); | |
}); | |
stream.once('USERAUTH_FAILURE', () => { | |
// reject(new Error('no auth')); | |
reject(new Error('Unable to auth')); | |
stream.removeAllListeners('USERAUTH_SUCCESS'); | |
}); | |
}); | |
} catch (err) { | |
console.error(err); | |
} finally { | |
stream.disconnect(); | |
} | |
} | |
}); | |
}); | |
const socket = connectSocket(); | |
socket.pipe(stream).pipe(socket); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment