secure-tunnel
v1.0.6
Published
Defines credentials and creates a secure tunnel between a server and multiple clients. Communication channel agnostic.
Downloads
598
Maintainers
Readme
secure-tunnel
Defines credentials and creates a secure tunnel between a server and multiple clients. Communication channel agnostic.
Usage
Before a connection can be created between a client and server, the keys must first be created.
import { createKeyPair } from 'secure-tunnel'
const { id, clientKey, serverKey } = await createKeyPair({ id: 'MyId' })
// id = 'MyId'
// clientKey = 'MyIdaBcDeFghIjK1mN0P9RsTuVwXyZ'
// serverKey = 'l234567B9olll2l3l4l5l6l7lB'The generated keys are cryptographically random strings, and will only contain alphanumeric values (unless the id contains something else) The server key is always 26 characters long. The client key's length is 26 characters plus the length of the id, and starts with the public id. This should be called once to create the initial keys, and the client key should be sent and saved securely; this should not be called on every connection.
To create a server, call the createServer function. It may be implemented similarly to the following:
import { createServer, createKeyPair } from 'secure-tunnel'
const { id: connectionId, clientKey, serverKey } = await createKeyPair({ id: 'MyId' })
// ...after the client persists the clientKey... //
const { onSocketOpen, onSocketMessage, onSocketClose } = createServer({
onConnect ({ id, send, close, onMessage, onClose }) {
console.log('connected with id ', id)
onMessage(message => {
console.log('Server got a message: ', `${message}`)
})
send('Sent message from server')
onClose (() => {
console.log('closing!')
})
},
async getCredentials (id) {
if (connectionId === id) {
return serverKey
}
throw 'Unknown id'
}
})
// socketApi = { send, close }
// onSocketOpen(socketApi)
// onSocketMessage(socketApi, message)
// onSocketClose(socketApi)createServer returns methods to invoke when something akin to a websocket is opened, sends a message, or is closed. See e2e-bun or e2e-node for an example of how that might be implemented.
getCredentials is called when a client connects via websockets and sends its id as part of the auth handshake. This function should return the serverKey that was originally created via the createKeyPair function. This key might be saved in a local file (for cases where there is only one server/client connection), or multiple id/server key mappings may be saved in a database. It is up to you.
onConnect is called after the client and server connects and the connection is authenticated. The function's payload includes the id used to connect, a method to send (which calls the passed in socket api's send function), a callback function to handle messages, and a callback function to handle the socket being closed. Multiple clients can connect with the same id.
The client is created and started via the createClient function. Using ws as an example:
import { createClient, createKeyPair } from 'secure-tunnel'
const { id: connectionId, clientKey, serverKey } = await createKeyPair({ id: 'MyId' })
// ...after the client persists the clientKey... //
const client = createClient({
clientKey,
onConnect: ({ send, close, onMessage, onClose }) => {
console.log('connected!')
onMessage((message) => {
console.log('Client got a message: ', '' + message)
})
},
initializeSocket () {
return {
send () {},
close () {},
onOpen (callback) {},
onMessage (callback) {},
onClose (callback) {}
}
}
})clientKey is the key created via the createKeyPair function that was sent from the server. This should be saved as a local file that isn't tracked in source control.
initializeSocket is called immediately as well as whenever a connection needs to be reestablished. It should return an object that contains the above functions, but the exact implementation is up to you. See e2e-bun or e2e-node for an example of how that might be implemented.
onConnect is called after the client and server connects and the connection is authenticated. The function's payload includes a method to send and close (which calls the respective functions defined in initializeSocket), a callback function to handle messages, and a callback function to handle the socket being closed.
Usage with ws
secure-tunnel does not depend on the ws library to run, but the library can be used to handle connecting the client and the server.
On the client side, if createClient returns a "ws-like" websocket object (or an object that specifies on and removeListener functions but no onOpen, onMessage, onError, or onClose functions), secure-tunnel will automatically convert the ws into an object that can be used internally.
createClient({
clientKey,
onConnect: ({ send, close, onMessage, onClose }) => {
...
},
initializeSocket: () => new WebSocket(`wss://my-remote-server/connect`)
})On the server side, if wss is specified as the parameter in the createServer function, listeners adhering to the websocket server object created by new WebSocketServer will be used.
const wss = new WebSocketServer({ port: 443 })
createServer({
onConnect ({ id, send, close, onMessage, onClose }) {
// ...
},
async getCredentials (_id) {
// ...
},
wss
})See tests/e2e-node.js for an example of how to utilize these functions.
Usage with Bun
The server-end of secure-tunnel can use Bun's web server. The object returned from createServer includes a createBunWebsocketApi function that converts the api into a websocket object that Bun.serve can utilize:
const peerTunnel = createServer({
onConnect ({ id, send, close, onMessage, onClose }) {
// ...
},
async getCredentials (_id) {
// ...
}
})
const server = Bun.serve({
// ...
websocket: peerTunnel.createBunWebsocketApi()
})See tests/e2e-bun.js for an example of how to utilize this function.
How it works
Persisted asymmetrical keys (strings) are created beforehand for both the client and server. The client's key contains an id, an 18 character randomly generated password (≈1.8E32 possible passwords), and a hash of the server's password. The server's key contains an 8 character randomly generated password (≈2.2E14 possible passwords), and a hash of the client password. Both keys consist of only alphanumeric characters, assuming the id is also alphanumeric.
How the connection is esablished is dependent on the implementation, but the code is built around the assumption that websockets are used. Once the connection between the client and the server is established, the server will start by sending a public 512-byte RSA key to the client. The RSA key pair is generated via Node's createKeyPair:
createKeyPair('rsa', {
modulusLength: (512 + 42 + 15) * 8, // 4552
publicKeyEncoding: {
type: 'spki',
format: 'der'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'der'
}
}Note: Due to differences in the internal implementation, Node and Bun will generate RSA keys with slightly different lengths. While somewhat unexpected, this does not affect the ability for Node and Bun clients/servers to connect.
The client's very first expected message will be the public RSA key. It uses this RSA key to encrypt the following:
- The length of the public id in bytes
- The public id
- Client password
- The current time, in seconds
- A 30-byte nonce
The client also creates its own RSA key pair using the same algorithm above, and sends its public RSA key as well as the encrypted payload above back to the server.
The server decrypts the encrypted message sent from the client. It calls the overridden asynchronous function getCredentials with the client-specified id to get the server key. Assuming a key is returned, the server then ensures the client password matches the server's hashed client password. It also checks to make sure less than 10 seconds have elapsed since the message was sent. If everything is good, the server then sends the following, encrypted by the public RSA key the client sent:
- Server password
- 48 byte AES key
- The current time, in seconds
- The nonce sent from the client
The next message the client expects is the server's payload above encrypted with the public RSA key the client created. The client decrypts the message, checks to make sure the time is within 10 seconds of the original handshake request, the nonce the client sent is the same, and that the sent server password matches the client's hashed server password. If everything is good, the client saves the AES key the server sent.
Once the client and server are authenticated, all future messages sent between them will be encrypted with the AES key.
Development
This is a Bun project.
To install dependencies:
bun installTo run unit tests (can take awhile):
bun run testTo build:
bun run buildSome proof-of-concept E2E tests also exist. These are mostly for reference but can also be run:
bun run test-bun
bun run test-bun-rest
bun run test-node