pairnet 0.1.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/.eslintrc +3 -0
- package/README.md +51 -0
- package/index.js +330 -0
- package/lib/background-tasks.js +19 -0
- package/lib/generate-keys.js +82 -0
- package/lib/get-fingerprint.js +5 -0
- package/lib/maybe-call.js +7 -0
- package/lib/protocol.js +191 -0
- package/lib/reply-tracker.js +42 -0
- package/lib/sender.js +52 -0
- package/lib/store.js +30 -0
- package/notes.txt +6 -0
- package/package.json +47 -0
- package/promisified/disconnect-socket.js +11 -0
- package/promisified/server-close.js +8 -0
- package/promisified/server-listen.js +10 -0
- package/storages/file.js +45 -0
- package/storages/memory.js +28 -0
- package/storages/symbols.js +6 -0
- package/streams/infinite-queue.js +37 -0
- package/test/index.js +99 -0
package/.eslintrc
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# pairnet - easy high-security paired connections
|
|
2
|
+
|
|
3
|
+
__Maintenance status:__ [Experimental](https://codeberg.org/joepie91/policy#maintenance-and-support). This project is AI-free and [bans all AI contributions](https://codeberg.org/joepie91/policy#ai-policy).
|
|
4
|
+
|
|
5
|
+
## ⚠️ WARNING
|
|
6
|
+
|
|
7
|
+
This is an early release of Pairnet. It is __not__ yet carefully reviewed, and documentation may be incomplete or even incorrect. You should not use Pairnet for your projects yet, unless you intend to actively work with me on any issues you encounter. You have been warned!
|
|
8
|
+
|
|
9
|
+
## Documentation continues below.
|
|
10
|
+
|
|
11
|
+
Pairnet is a (mostly) unopinionated networking library for establishing high-security connections between processes on different systems, in Node.js. It uses an explicit pairing process to mutually authenticate between those processes.
|
|
12
|
+
|
|
13
|
+
__What it's for:__
|
|
14
|
+
|
|
15
|
+
- Distributed clusters, eg. for storage or computation.
|
|
16
|
+
- Software that spans multiple systems, eg. a 'main server' and one or more 'worker nodes' for resource-intensive or specialized tasks.
|
|
17
|
+
- Really, anything else where you need authenticated long-term links between processes on different servers.
|
|
18
|
+
|
|
19
|
+
__What it gives you:__
|
|
20
|
+
|
|
21
|
+
- An encrypted message stream to a Node.js process on another system, that lets you send values across without needing to serialize them first.
|
|
22
|
+
- Authentication that ensures you really are talking to that process, not a man-in-the-middle.
|
|
23
|
+
- Stable and unique "peer IDs" that are directly tied to the authentication, and so are not falsifiable.
|
|
24
|
+
- The ability to re-establish that connection in the future, with the same guarantees.
|
|
25
|
+
- A bidirectional protocol negotiation mechanism, to ensure that two processes using pairnet actually use it for the same (compatible) purpose.
|
|
26
|
+
- A simple mechanism for specifying your own 'trust store' that keeps track of the trusted peers you have paired with, in whichever existing storage mechanism you already use.
|
|
27
|
+
- A simple pairing process for the initial setup process, that's simply exposed as a few functions, so you can integrate it in whichever way you need.
|
|
28
|
+
- Pairing tokens that can be directly given to and handled by users, eg. for interactive pairing processes in your own software designed for others to use.
|
|
29
|
+
|
|
30
|
+
__What it doesn't give you:__
|
|
31
|
+
|
|
32
|
+
- A discovery layer; it won't find services for you. But you can use pairnet with any discovery layer you prefer; all it requires is the ability to send a pairing token (string) in any one direction.
|
|
33
|
+
- A VPN; this establishes process-to-process communication only, it doesn't create a virtual network interface.
|
|
34
|
+
- NAT traversal; this library assumes direct TCP connectivity to be available. If you need NAT traversal, you will need to handle this on another layer.
|
|
35
|
+
- Key rotation (yet); this may be added at a later time.
|
|
36
|
+
- A raw byte stream; Pairnet uses a small CBOR-based protocol internally to negotiate the connection, and so the library exposes a message-oriented API rather than a bytestream-oriented one. CBOR supports binary data, so you can still just send binary blobs as your messages, of course.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
Pairnet establishes a mutually-authenticated TLS connection between two processes. This means that it uses TLS certificates as both server and client certificates, depending on the direction of the connection. The public fingerprint of the certificate serves as the node's "peer ID", making it non-falsifiable, as it is cryptographically derived from the specific certificate that a peer uses to authenticate itself.
|
|
41
|
+
|
|
42
|
+
The pairing process is relatively simple; one peer creates a random 128-bit token, and encodes this pairing token plus its own public fingerprint and additional metadata (eg. IP and port) into a single string called a "wrapped pairing token". The other peer is given this token, internally unpacks it back into a separate fingerprint and a pairing token, and connects to the first peer in pairing mode. It verifies that the fingerprint matches what it expects, then sends the token over. The first peer verifies it against its list of previously-generated tokens, and if found, removes it from the list and tells the second peer that the pairing was successful. The second peer now registers the fingerprint from the wrapped pairing token as the correct fingerprint for the first peer; and the first peer registers whichever fingerprint the second peer connected with as *its *correct fingerprint.
|
|
43
|
+
|
|
44
|
+
## Security considerations
|
|
45
|
+
|
|
46
|
+
__Keep your Node.js installation up-to-date.__ On most systems, Node.js uses a bundled version of OpenSSL for all of its TLS and other cryptography features, and publishes security releases tracking those of OpenSSL. For this same reason, you _must_ only use Node.js version [that are still supported](https://endoflife.date/nodejs). This warning applies to all uses of built-in cryptography in Node.js, not just to pairnet.
|
|
47
|
+
|
|
48
|
+
The following security-relevant features are currently missing, and will most likely be added to pairnet later:
|
|
49
|
+
|
|
50
|
+
- Key rotation
|
|
51
|
+
- Pairing token expiry
|
package/index.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const tls = require("tls");
|
|
5
|
+
const debug = require("debug")("pairnet");
|
|
6
|
+
const matchValue = require("match-value");
|
|
7
|
+
const pipe = require("@promistream/pipe");
|
|
8
|
+
const simpleSink = require("@promistream/simple-sink");
|
|
9
|
+
|
|
10
|
+
const createMemoryStorage = require("./storages/memory");
|
|
11
|
+
const createFileStorage = require("./storages/file");
|
|
12
|
+
const { NotSet } = require("./storages/symbols");
|
|
13
|
+
|
|
14
|
+
const generateKeys = require("./lib/generate-keys");
|
|
15
|
+
const createBackgroundTaskManager = require("./lib/background-tasks");
|
|
16
|
+
const createStore = require("./lib/store");
|
|
17
|
+
const getFingerprint = require("./lib/get-fingerprint");
|
|
18
|
+
const maybeCall = require("./lib/maybe-call");
|
|
19
|
+
const handleProtocol = require("./lib/protocol");
|
|
20
|
+
const serverListen = require("./promisified/server-listen");
|
|
21
|
+
const serverClose = require("./promisified/server-close");
|
|
22
|
+
|
|
23
|
+
const { validateOptions } = require("@validatem/core");
|
|
24
|
+
const required = require("@validatem/required");
|
|
25
|
+
const isFunction = require("@validatem/is-function");
|
|
26
|
+
const ValidationError = require("@validatem/error");
|
|
27
|
+
const isInteger = require("@validatem/is-integer");
|
|
28
|
+
const isNonEmptyString = require("@validatem/is-non-empty-string");
|
|
29
|
+
|
|
30
|
+
/* TODO:
|
|
31
|
+
- Verify that errors don't get eaten anywhere
|
|
32
|
+
- Redesign query error responses so that it doesn't interfere with force disconnection
|
|
33
|
+
- Auto-reconnection
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
function createQueryError(message) {
|
|
37
|
+
let error = new Error("Query resulted in an error");
|
|
38
|
+
error.code = "QUERY_ERROR_RESPONSE";
|
|
39
|
+
error.messageData = message;
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function makeClientSocket({ hostname, port, options, onQuery, protocolConfiguration }) {
|
|
44
|
+
let socket = tls.connect({
|
|
45
|
+
host: hostname,
|
|
46
|
+
port: port,
|
|
47
|
+
rejectUnauthorized: false,
|
|
48
|
+
// We manually pin the certificate, so we don't have to care about the subject name at all; that's why checkServerIdentity always passes
|
|
49
|
+
checkServerIdentity: () => {},
|
|
50
|
+
cert: protocolConfiguration.identity.certificate,
|
|
51
|
+
key: protocolConfiguration.identity.privateKey,
|
|
52
|
+
... options ?? {}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let api = await handleProtocol(socket, {
|
|
56
|
+
protocolConfiguration: protocolConfiguration,
|
|
57
|
+
onQuery: onQuery
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
peerFingerprint: getFingerprint(socket),
|
|
62
|
+
eventStream: api.eventStream,
|
|
63
|
+
send: api.sender.sendEvent,
|
|
64
|
+
query: api.sender.sendQuery,
|
|
65
|
+
disconnect: api.disconnect
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isStorageProvider(value) {
|
|
70
|
+
if (value._pairnetStorage !== true) {
|
|
71
|
+
throw new ValidationError(`Must be a pairnet storage`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
createQueryError: createQueryError,
|
|
77
|
+
createMemoryStorage: createMemoryStorage,
|
|
78
|
+
createFileStorage: createFileStorage,
|
|
79
|
+
createPeer: async function createPeer(_options) {
|
|
80
|
+
let { storage, generateClientHello, onClientHello, onServerHello } = validateOptions(arguments, {
|
|
81
|
+
storage: [ required, isStorageProvider ],
|
|
82
|
+
generateClientHello: [ required, isFunction ],
|
|
83
|
+
onClientHello: [ required, isFunction ],
|
|
84
|
+
onServerHello: [ required, isFunction ]
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// FIXME: Why do we still have this fragile 'background task' mechanism?
|
|
88
|
+
let backgroundTasks = createBackgroundTaskManager();
|
|
89
|
+
let store = createStore(storage);
|
|
90
|
+
|
|
91
|
+
let identity = await store.getIdentity();
|
|
92
|
+
|
|
93
|
+
if (identity === NotSet) {
|
|
94
|
+
debug(`[createPeer] Generating identity...`);
|
|
95
|
+
identity = await generateKeys("peer");
|
|
96
|
+
await store.setIdentity(identity);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
debug(`[createPeer] Peer initialized with fingerprint ${identity.fingerprint}`);
|
|
100
|
+
|
|
101
|
+
let protocolConfiguration = {
|
|
102
|
+
generateClientHello: generateClientHello,
|
|
103
|
+
onClientHello: onClientHello,
|
|
104
|
+
onServerHello: onServerHello,
|
|
105
|
+
isClient: (generateClientHello != null && onServerHello != null),
|
|
106
|
+
isServer: (onClientHello != null),
|
|
107
|
+
storage: storage,
|
|
108
|
+
identity: identity
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function makeProtocolPeer(socket, backgroundTasks) {
|
|
112
|
+
return {
|
|
113
|
+
fingerprint: socket.peerFingerprint,
|
|
114
|
+
eventStream: socket.eventStream,
|
|
115
|
+
query: function (message) {
|
|
116
|
+
return socket.query({ type: "MSG", m: message });
|
|
117
|
+
},
|
|
118
|
+
send: function (message) {
|
|
119
|
+
return socket.send(message);
|
|
120
|
+
},
|
|
121
|
+
disconnect: function () {
|
|
122
|
+
return Promise.all([
|
|
123
|
+
socket.disconnect(),
|
|
124
|
+
(backgroundTasks != null) ? backgroundTasks.awaitCompletion() : null
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
fingerprint: identity.fingerprint,
|
|
132
|
+
generatePairingToken: async function () {
|
|
133
|
+
let token = crypto.randomBytes(16).toString("hex");
|
|
134
|
+
// TODO: Expiry
|
|
135
|
+
await store.setValidPairingToken(token);
|
|
136
|
+
debug(`[generatePairingToken] Generated pairing token ${token}`);
|
|
137
|
+
return token;
|
|
138
|
+
},
|
|
139
|
+
pair: async function (_options) {
|
|
140
|
+
let { hostname, port, fingerprint, pairingToken } = validateOptions(arguments, {
|
|
141
|
+
hostname: [ required, isNonEmptyString ],
|
|
142
|
+
port: [ required, isInteger ],
|
|
143
|
+
fingerprint: [ required, isNonEmptyString ],
|
|
144
|
+
pairingToken: [ required, isNonEmptyString ]
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (protocolConfiguration.isClient) {
|
|
148
|
+
debug(`[pair] Initiating pairing connection to ${hostname}:${port}...`);
|
|
149
|
+
|
|
150
|
+
let socket = await makeClientSocket({
|
|
151
|
+
hostname, port, protocolConfiguration,
|
|
152
|
+
// We don't care about anything other than pairing messages in this mode, which are in the form of a query reply
|
|
153
|
+
onQuery: () => {}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
debug(`[pair] Expected fingerprint: ${fingerprint}`);
|
|
157
|
+
debug(`[pair] Actual fingerprint: ${socket.peerFingerprint}`);
|
|
158
|
+
|
|
159
|
+
if (socket.peerFingerprint !== fingerprint) {
|
|
160
|
+
await Promise.all([
|
|
161
|
+
socket.disconnect(),
|
|
162
|
+
backgroundTasks.awaitCompletion()
|
|
163
|
+
]);
|
|
164
|
+
throw new Error(`Fingerprint of server certificate does not match expected fingerprint`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// We need to drive the read loop, even though we don't care about the events
|
|
168
|
+
let reader = pipe([
|
|
169
|
+
socket.eventStream,
|
|
170
|
+
simpleSink(() => {
|
|
171
|
+
debug("[pair] Ignored event message in pairing mode");
|
|
172
|
+
})
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
await Promise.all([
|
|
176
|
+
// FIXME: Await completion of background tasks?
|
|
177
|
+
reader.read(),
|
|
178
|
+
(async () => {
|
|
179
|
+
try {
|
|
180
|
+
// Let any pairing errors throw
|
|
181
|
+
await socket.query({ type: "PAIR", token: pairingToken });
|
|
182
|
+
await store.trustPeer(fingerprint);
|
|
183
|
+
} finally {
|
|
184
|
+
await socket.disconnect();
|
|
185
|
+
}
|
|
186
|
+
})()
|
|
187
|
+
]);
|
|
188
|
+
} else {
|
|
189
|
+
throw new Error(`This peer is not configured as a client`);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
connect: async function (_options) {
|
|
193
|
+
let { hostname, port, onQuery } = validateOptions(arguments, {
|
|
194
|
+
hostname: [ required, isNonEmptyString ],
|
|
195
|
+
port: [ required, isInteger ],
|
|
196
|
+
onQuery: [ required, isFunction ],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (protocolConfiguration.isClient) {
|
|
200
|
+
debug(`[connect] Initiating client connection to ${hostname}:${port}...`);
|
|
201
|
+
|
|
202
|
+
// connect with client certificate made from own identity, and validate server pubkey
|
|
203
|
+
let socket = await makeClientSocket({
|
|
204
|
+
hostname, port, protocolConfiguration,
|
|
205
|
+
onQuery: onQuery
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let isPaired = await store.isPeerTrusted(socket.peerFingerprint);
|
|
209
|
+
|
|
210
|
+
debug(`[connect] Peer fingerprint: ${socket.peerFingerprint}`);
|
|
211
|
+
debug(`[connect] Is trusted: ${isPaired}`);
|
|
212
|
+
|
|
213
|
+
if (isPaired !== true) {
|
|
214
|
+
await socket.disconnect();
|
|
215
|
+
throw new Error(`Not paired to specified peer`);
|
|
216
|
+
} else {
|
|
217
|
+
return makeProtocolPeer(socket, backgroundTasks);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
throw new Error(`This peer is not configured as a client`);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
createServer: async function (_option) {
|
|
224
|
+
let { hostname, port, onConnect, onDisconnect, onQuery } = validateOptions(arguments, {
|
|
225
|
+
hostname: [ required, isNonEmptyString ],
|
|
226
|
+
port: [ required, isInteger ],
|
|
227
|
+
onConnect: [ required, isFunction ],
|
|
228
|
+
onDisconnect: [ isFunction ],
|
|
229
|
+
onQuery: [ required, isFunction ]
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (protocolConfiguration.isServer) {
|
|
233
|
+
// FIXME: Reject connections without certificate
|
|
234
|
+
let server = tls.createServer({
|
|
235
|
+
cert: identity.certificate,
|
|
236
|
+
key: identity.privateKey,
|
|
237
|
+
requestCert: true,
|
|
238
|
+
rejectUnauthorized: false
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
async function handleClient(socket) {
|
|
242
|
+
let clientFingerprint = getFingerprint(socket);
|
|
243
|
+
|
|
244
|
+
if (clientFingerprint == null) {
|
|
245
|
+
socket.destroy();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let isPaired = await store.isPeerTrusted(clientFingerprint);
|
|
250
|
+
|
|
251
|
+
debug(`[server] Peer fingerprint: ${clientFingerprint}`);
|
|
252
|
+
debug(`[server] Is trusted: ${isPaired}`);
|
|
253
|
+
|
|
254
|
+
let api = await handleProtocol(socket, {
|
|
255
|
+
protocolConfiguration,
|
|
256
|
+
onQuery: async (query) => {
|
|
257
|
+
return matchValue(query.type, {
|
|
258
|
+
PAIR: async () => {
|
|
259
|
+
if (isPaired) {
|
|
260
|
+
throw new Error(`Already paired`);
|
|
261
|
+
} else {
|
|
262
|
+
let { token } = query;
|
|
263
|
+
|
|
264
|
+
if (await store.isValidPairingToken(token) === true) {
|
|
265
|
+
await store.trustPeer(clientFingerprint);
|
|
266
|
+
await store.removeValidPairingToken(token);
|
|
267
|
+
return true;
|
|
268
|
+
} else {
|
|
269
|
+
// TODO: Force disconnect?
|
|
270
|
+
throw createQueryError("Invalid pairing token");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
MSG: async () => {
|
|
275
|
+
if (isPaired) {
|
|
276
|
+
return onQuery(clientFingerprint, query.m);
|
|
277
|
+
} else {
|
|
278
|
+
// FIXME: Force disconnect?
|
|
279
|
+
throw new Error(`Client unauthorized`);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
_: async () => {
|
|
283
|
+
// Invalid message type
|
|
284
|
+
return protocolPeer.disconnect();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
let protocolPeer = makeProtocolPeer({
|
|
291
|
+
peerFingerprint: getFingerprint(socket),
|
|
292
|
+
eventStream: api.eventStream,
|
|
293
|
+
send: api.sender.sendEvent,
|
|
294
|
+
query: api.sender.sendQuery,
|
|
295
|
+
disconnect: api.disconnect
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// This is where the read stream should be attached to
|
|
299
|
+
await onConnect(protocolPeer);
|
|
300
|
+
|
|
301
|
+
socket.on("close", () => {
|
|
302
|
+
maybeCall(onDisconnect, protocolPeer);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
server.on("secureConnection", async (socket) => {
|
|
307
|
+
backgroundTasks.register(handleClient(socket));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await serverListen(server, port, hostname);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
stop: async () => {
|
|
314
|
+
return Promise.all([
|
|
315
|
+
serverClose(server),
|
|
316
|
+
backgroundTasks.awaitCompletion()
|
|
317
|
+
]);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
} else {
|
|
321
|
+
throw new Error(`This peer is not configured as a server`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
module.exports = function createBackgroundTaskManager() {
|
|
4
|
+
let promises = [];
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
_promises: promises,
|
|
8
|
+
register: function (promise) {
|
|
9
|
+
let num = Math.trunc(Math.random() * 800);
|
|
10
|
+
// console.log(`started stream ${num} ${(new Error("trace")).stack}`);
|
|
11
|
+
promises.push(promise.catch((error) => {
|
|
12
|
+
console.error(`Error in background task: ${error.stack}`);
|
|
13
|
+
}).then(() => { /* console.log(`finished stream ${num}`) */ }));
|
|
14
|
+
},
|
|
15
|
+
awaitCompletion: function () {
|
|
16
|
+
return Promise.all(promises);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const util = require("util");
|
|
4
|
+
const childProcess = require("child_process");
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const fs = require("fs/promises");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const nanoid = require("nanoid");
|
|
9
|
+
|
|
10
|
+
async function withTemporaryFile(callback) {
|
|
11
|
+
// TODO: Improve cleanup; eg. on process exit?
|
|
12
|
+
let id = nanoid.nanoid();
|
|
13
|
+
let temporaryPath = path.join("/tmp/pairnet", id);
|
|
14
|
+
let pendingError, result;
|
|
15
|
+
|
|
16
|
+
await fs.mkdir("/tmp/pairnet", { recursive: true });
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
result = await callback(temporaryPath);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
pendingError = error;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await fs.unlink(temporaryPath);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code === "ENOENT") {
|
|
28
|
+
// file was never created, this is fine
|
|
29
|
+
} else {
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (pendingError != null) {
|
|
35
|
+
throw pendingError;
|
|
36
|
+
} else {
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = async function generateKeys(subjectName) {
|
|
42
|
+
// TODO: Make broader, only forbidding (unencoded?) special characters
|
|
43
|
+
assert(/^[a-zA-Z0-9._ -]+$/.test(subjectName));
|
|
44
|
+
|
|
45
|
+
return await withTemporaryFile(async (keyPath) => {
|
|
46
|
+
return withTemporaryFile(async (csrPath) => {
|
|
47
|
+
await util.promisify(childProcess.execFile)("openssl", [
|
|
48
|
+
"req", "-newkey", "rsa:4096",
|
|
49
|
+
"-keyout", keyPath,
|
|
50
|
+
"-out", csrPath,
|
|
51
|
+
"-nodes",
|
|
52
|
+
"-subj", `/CN=${subjectName}`
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
return withTemporaryFile(async (certificatePath) => {
|
|
56
|
+
await util.promisify(childProcess.execFile)("openssl", [
|
|
57
|
+
"x509", "-req",
|
|
58
|
+
"-in", csrPath,
|
|
59
|
+
"-signkey", keyPath, // self-signed
|
|
60
|
+
"-out", certificatePath,
|
|
61
|
+
"-days", "7000"
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// Node.js does not seem to expose any methods for getting a fingerprint from a certificate...
|
|
65
|
+
let fingerprintOutput = await util.promisify(childProcess.execFile)("openssl", [
|
|
66
|
+
"x509", "-fingerprint", "-sha256",
|
|
67
|
+
"-in", certificatePath,
|
|
68
|
+
"-noout"
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
let match = /^\s*sha256 Fingerprint=(.+)\s*$/.exec(fingerprintOutput.stdout);
|
|
72
|
+
assert(match != null && match[1].length > 0);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
privateKey: (await fs.readFile(keyPath)).toString(),
|
|
76
|
+
certificate: (await fs.readFile(certificatePath)).toString(),
|
|
77
|
+
fingerprint: match[1]
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
};
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const encodeCBOR = require("@promistream/encode-cbor");
|
|
4
|
+
const decodeCBOR = require("@promistream/decode-cbor");
|
|
5
|
+
const pipe = require("@promistream/pipe");
|
|
6
|
+
const fromNodeStream = require("@promistream/from-node-stream");
|
|
7
|
+
const map = require("@promistream/map");
|
|
8
|
+
const infiniteQueue = require("../streams/infinite-queue");
|
|
9
|
+
const simpleSink = require("@promistream/simple-sink");
|
|
10
|
+
const createDebugStream = require("@promistream/debug");
|
|
11
|
+
const objid = require("@joepie91/objid");
|
|
12
|
+
|
|
13
|
+
const debug = require("debug")("pairnet:protocol");
|
|
14
|
+
|
|
15
|
+
const createSender = require("./sender");
|
|
16
|
+
const createReplyTracker = require("./reply-tracker");
|
|
17
|
+
const disconnectSocket = require("../promisified/disconnect-socket");
|
|
18
|
+
const filter = require("@promistream/filter");
|
|
19
|
+
|
|
20
|
+
let NORMAL_QUERY_TYPE = "q";
|
|
21
|
+
let NEGOTIATION_QUERY_TYPE = "nq";
|
|
22
|
+
let EVENT_TYPE = "e";
|
|
23
|
+
// negotiation query, negotiation reply, negotiation reply error
|
|
24
|
+
let NEGOTIATION_TYPES = new Set([ "nq", "n", "N" ]);
|
|
25
|
+
// reply, reply error, negotiation reply, negotiation reply error
|
|
26
|
+
let REPLY_TYPES = new Set([ "r", "R", "n", "N" ]);
|
|
27
|
+
let SUCCESS_REPLIES = new Set([ "r", "n" ]);
|
|
28
|
+
let ERROR_REPLIES = new Set([ "R", "N" ]);
|
|
29
|
+
|
|
30
|
+
async function evaluateQuery(query, handler) {
|
|
31
|
+
try {
|
|
32
|
+
return {
|
|
33
|
+
success: true,
|
|
34
|
+
message: await handler(query)
|
|
35
|
+
};
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === "QUERY_ERROR_RESPONSE") {
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
message: error.messageData
|
|
41
|
+
};
|
|
42
|
+
} else {
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = async function handleProtocol(socket, { onQuery, protocolConfiguration } = {}) {
|
|
49
|
+
debug("Attaching semantic protocol handler");
|
|
50
|
+
|
|
51
|
+
let writeQueue = infiniteQueue([]);
|
|
52
|
+
let replyTracker = createReplyTracker();
|
|
53
|
+
let negotiationCompleted = false;
|
|
54
|
+
|
|
55
|
+
let sender = createSender(replyTracker, (message) => {
|
|
56
|
+
writeQueue.push(message);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Send pipeline
|
|
60
|
+
pipe([
|
|
61
|
+
writeQueue.stream,
|
|
62
|
+
map((message) => {
|
|
63
|
+
debug("Sending message:", message);
|
|
64
|
+
|
|
65
|
+
let isNegotiationMessage = NEGOTIATION_TYPES.has(message.t);
|
|
66
|
+
|
|
67
|
+
if (negotiationCompleted !== true && !isNegotiationMessage) {
|
|
68
|
+
debug(`[handleProtocol] Prevented message send; not allowed type '${message.t}' during negotiation`);
|
|
69
|
+
throw new Error(`Cannot send messages in negotiation phase`);
|
|
70
|
+
} else {
|
|
71
|
+
return message;
|
|
72
|
+
}
|
|
73
|
+
}),
|
|
74
|
+
// createDebugStream(`pairnet:protocol:send-pipeline ${objid(socket)}`),
|
|
75
|
+
encodeCBOR(),
|
|
76
|
+
fromNodeStream.fromWritable(socket)
|
|
77
|
+
]).read().catch((error) => {
|
|
78
|
+
// These errors are also mirrored on the read side, so we don't need to handle them here
|
|
79
|
+
debug(`Error occurred on writable side of socket`, error);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
let incomingMessageStream = pipe([
|
|
83
|
+
fromNodeStream.fromReadable(socket),
|
|
84
|
+
decodeCBOR(),
|
|
85
|
+
// createDebugStream(`pairnet:protocol:receive-pipeline ${objid(socket)}`),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
let clientHelloQuery = (protocolConfiguration.isClient)
|
|
89
|
+
? sender.sendNegotiationQuery(await protocolConfiguration.generateClientHello())._queryID.toString("hex") // FIXME: Hacky
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
// We read messages for as long as negotiation has not been completed yet
|
|
93
|
+
await pipe([
|
|
94
|
+
incomingMessageStream,
|
|
95
|
+
simpleSink(async (message) => {
|
|
96
|
+
debug("Received message:", message);
|
|
97
|
+
|
|
98
|
+
let isNegotiationMessage = NEGOTIATION_TYPES.has(message.t);
|
|
99
|
+
let isReplyMessage = REPLY_TYPES.has(message.t);
|
|
100
|
+
|
|
101
|
+
if (!isNegotiationMessage) {
|
|
102
|
+
debug(`[readLoop] Ignored received message; not allowed type '${message.t}' during negotiation`);
|
|
103
|
+
} else if (isReplyMessage) {
|
|
104
|
+
if (message.i.toString("hex") === clientHelloQuery) {
|
|
105
|
+
let helloResponse = message.m;
|
|
106
|
+
let helloDecision = await protocolConfiguration.onServerHello(helloResponse);
|
|
107
|
+
|
|
108
|
+
if (helloDecision === false) {
|
|
109
|
+
// Connection rejected
|
|
110
|
+
return disconnectSocket(socket);
|
|
111
|
+
} else {
|
|
112
|
+
debug(`[readLoop] Negotiation completed on client side; detaching sink...`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
throw new Error(`Unrecognized negotiation query reply, id: ${message.i}`);
|
|
117
|
+
}
|
|
118
|
+
} else if (message.t === NEGOTIATION_QUERY_TYPE) {
|
|
119
|
+
let result = await evaluateQuery(message.m, protocolConfiguration.onClientHello);
|
|
120
|
+
|
|
121
|
+
if (result.success) {
|
|
122
|
+
await sender.sendNegotiationReply(message.i, result.message);
|
|
123
|
+
|
|
124
|
+
debug(`[readLoop] Negotiation completed on server side; detaching sink...`);
|
|
125
|
+
return null;
|
|
126
|
+
} else {
|
|
127
|
+
await sender.sendNegotiationReplyError(message.i, result.message);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
debug(`[readLoop] Processed negotiation query ID '${message.i.toString("hex")}'`);
|
|
131
|
+
} else {
|
|
132
|
+
debug(`[readLoop] Received message with unknown type '${message.t}'`);
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
]).read();
|
|
136
|
+
|
|
137
|
+
// Unlock the sender
|
|
138
|
+
negotiationCompleted = true;
|
|
139
|
+
|
|
140
|
+
let eventStream = pipe([
|
|
141
|
+
incomingMessageStream,
|
|
142
|
+
filter(async (message) => {
|
|
143
|
+
let isReplyMessage = REPLY_TYPES.has(message.t);
|
|
144
|
+
|
|
145
|
+
if (message.t === NORMAL_QUERY_TYPE) {
|
|
146
|
+
let result = await evaluateQuery(message.m, onQuery);
|
|
147
|
+
|
|
148
|
+
if (result.success) {
|
|
149
|
+
await sender.sendReply(message.i, result.message);
|
|
150
|
+
} else {
|
|
151
|
+
await sender.sendReplyError(message.i, result.message);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
debug(`[readLoop] Processed normal query ID '${message.i.toString("hex")}'`);
|
|
155
|
+
return false;
|
|
156
|
+
} else if (message.t === EVENT_TYPE) {
|
|
157
|
+
// This is the only type of message that we will pass through as-is
|
|
158
|
+
debug(`[readLoop] Passed through event message as-is`);
|
|
159
|
+
return true;
|
|
160
|
+
} else if (isReplyMessage) {
|
|
161
|
+
try {
|
|
162
|
+
if (SUCCESS_REPLIES.has(message.t)) {
|
|
163
|
+
replyTracker.registerReply(message.i).resolve(message.m);
|
|
164
|
+
} else if (ERROR_REPLIES.has(message.t)) {
|
|
165
|
+
let error = new Error(`Received error response from peer`);
|
|
166
|
+
error.code = "PEER_ERROR";
|
|
167
|
+
error.message = message.m;
|
|
168
|
+
|
|
169
|
+
replyTracker.registerReply(message.i).reject(error);
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error.code === "UNKNOWN_QUERY_ID") {
|
|
173
|
+
debug(`[readLoop] Received reply to unknown query ID '${message.i.toString("hex")}'`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
debug(`[readLoop] Received message with unknown type '${message.t}'`);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}),
|
|
181
|
+
map(async (message) => {
|
|
182
|
+
return message.m; // Unpack actual message content from event message
|
|
183
|
+
})
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
eventStream: eventStream,
|
|
188
|
+
sender: sender,
|
|
189
|
+
disconnect: () => disconnectSocket(socket)
|
|
190
|
+
};
|
|
191
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const pDefer = require("p-defer");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
const debug = require("debug")("pairnet:reply-tracker");
|
|
6
|
+
|
|
7
|
+
module.exports = function createReplyTracker() {
|
|
8
|
+
let map = new Map();
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
_map: map,
|
|
12
|
+
registerQuery: function () {
|
|
13
|
+
let defer = pDefer();
|
|
14
|
+
let id = crypto.randomBytes(6);
|
|
15
|
+
let hexID = id.toString("hex");
|
|
16
|
+
|
|
17
|
+
// Store under string key due to Map not behaving like we want for value-identical Buffers
|
|
18
|
+
debug(`Storing query ID '${hexID}'`);
|
|
19
|
+
map.set(hexID, defer);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
id: id,
|
|
23
|
+
promise: defer.promise
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
registerReply: function (id) {
|
|
27
|
+
let hexID = id.toString("hex");
|
|
28
|
+
let defer = map.get(hexID);
|
|
29
|
+
|
|
30
|
+
if (defer != null) {
|
|
31
|
+
debug(`Registering reply for query ID '${hexID}'`);
|
|
32
|
+
map.delete(hexID);
|
|
33
|
+
return defer;
|
|
34
|
+
} else {
|
|
35
|
+
debug(`Unknown query ID '${hexID}'`);
|
|
36
|
+
let error = Error(`Unknown ID '${hexID}'`);
|
|
37
|
+
error.code = "UNKNOWN_QUERY_ID";
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
};
|
package/lib/sender.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
module.exports = function createSender(replyTracker, send) {
|
|
4
|
+
let sender = {
|
|
5
|
+
sendRaw: function (message) {
|
|
6
|
+
send(message);
|
|
7
|
+
},
|
|
8
|
+
sendEvent: function sendEvent(message) {
|
|
9
|
+
send({
|
|
10
|
+
t: "e",
|
|
11
|
+
m: message
|
|
12
|
+
});
|
|
13
|
+
},
|
|
14
|
+
sendQuery: function sendQuery(message, type = "q") {
|
|
15
|
+
let { promise, id } = replyTracker.registerQuery();
|
|
16
|
+
|
|
17
|
+
send({
|
|
18
|
+
t: type,
|
|
19
|
+
m: message,
|
|
20
|
+
i: id
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
promise._queryID = id;
|
|
24
|
+
return promise;
|
|
25
|
+
},
|
|
26
|
+
sendNegotiationQuery: function sendNegotiationQuery(message) {
|
|
27
|
+
return sender.sendQuery(message, "nq");
|
|
28
|
+
},
|
|
29
|
+
sendReply: function sendReply(id, message, type = "r") {
|
|
30
|
+
send({
|
|
31
|
+
t: type,
|
|
32
|
+
m: message,
|
|
33
|
+
i: id
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
sendNegotiationReply: function sendNegotiationReply(id, message) {
|
|
37
|
+
return sender.sendReply(id, message, "n");
|
|
38
|
+
},
|
|
39
|
+
sendReplyError: function sendReplyError(id, message, type = "R") {
|
|
40
|
+
send({
|
|
41
|
+
t: type,
|
|
42
|
+
m: message,
|
|
43
|
+
i: id
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
sendNegotiationReplyError: function sendNegotiationReplyError(id, message) {
|
|
47
|
+
return sender.sendReplyError(id, message, "N");
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return sender;
|
|
52
|
+
};
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
module.exports = function (storage) {
|
|
4
|
+
return {
|
|
5
|
+
getIdentity: async function () {
|
|
6
|
+
return storage.get("pairnet_identity");
|
|
7
|
+
},
|
|
8
|
+
setIdentity: async function (identity) {
|
|
9
|
+
return storage.set("pairnet_identity", identity);
|
|
10
|
+
},
|
|
11
|
+
isValidPairingToken: async function (token) {
|
|
12
|
+
return (await storage.get(`pairnet_token_${token}`) === true);
|
|
13
|
+
},
|
|
14
|
+
setValidPairingToken: async function (token) {
|
|
15
|
+
return storage.set(`pairnet_token_${token}`, true);
|
|
16
|
+
},
|
|
17
|
+
removeValidPairingToken: async function (token) {
|
|
18
|
+
return storage.delete(`pairnet_token_${token}`);
|
|
19
|
+
},
|
|
20
|
+
trustPeer: async function (fingerprint) {
|
|
21
|
+
return storage.set(`pairnet_peer_${fingerprint}`, true);
|
|
22
|
+
},
|
|
23
|
+
untrustPeer: async function (fingerprint) {
|
|
24
|
+
return storage.delete(`pairnet_peer_${fingerprint}`);
|
|
25
|
+
},
|
|
26
|
+
isPeerTrusted: async function (fingerprint) {
|
|
27
|
+
return (await storage.get(`pairnet_peer_${fingerprint}`) === true);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
};
|
package/notes.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Connection procedure
|
|
2
|
+
- Client connects to server
|
|
3
|
+
- Client checks server pubkey
|
|
4
|
+
- Client sends clientHello (custom) including protocol negotiation information
|
|
5
|
+
- Server either breaks connection (optionally with an error) or responds with serverHello (custom) including protocol negotiation information
|
|
6
|
+
- At this point, client must either already be recognized by the server, or it will get locked into pairing-only mode
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pairnet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [],
|
|
7
|
+
"author": "Sven Slootweg <admin@cryto.net>",
|
|
8
|
+
"license": "WTFPL",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@joepie91/consumable": "^1.0.1",
|
|
11
|
+
"@joepie91/objid": "^1.0.1",
|
|
12
|
+
"@promistream/aborted": "^0.1.2",
|
|
13
|
+
"@promistream/decode-cbor": "^0.1.0",
|
|
14
|
+
"@promistream/encode-cbor": "^0.1.0",
|
|
15
|
+
"@promistream/filter": "^0.1.1",
|
|
16
|
+
"@promistream/from-node-stream": "^0.1.3",
|
|
17
|
+
"@promistream/map": "^0.1.1",
|
|
18
|
+
"@promistream/pipe": "^0.1.6",
|
|
19
|
+
"@promistream/simple-sink": "^0.3.0",
|
|
20
|
+
"@promistream/simple-source": "^0.1.4",
|
|
21
|
+
"@validatem/core": "^0.5.0",
|
|
22
|
+
"@validatem/error": "^1.1.0",
|
|
23
|
+
"@validatem/is-function": "^0.1.0",
|
|
24
|
+
"@validatem/is-integer": "^0.1.0",
|
|
25
|
+
"@validatem/is-non-empty-string": "^0.1.0",
|
|
26
|
+
"@validatem/is-positive": "^1.0.0",
|
|
27
|
+
"@validatem/required": "^0.1.1",
|
|
28
|
+
"cbor": "^9.0.2",
|
|
29
|
+
"colorette": "^2.0.20",
|
|
30
|
+
"debug": "^4.3.4",
|
|
31
|
+
"match-value": "^1.1.0",
|
|
32
|
+
"nanoid": "^3.3.7",
|
|
33
|
+
"p-defer": "^3",
|
|
34
|
+
"p-event": "^4",
|
|
35
|
+
"range": "^0.0.3",
|
|
36
|
+
"sanitize-filename": "^1.6.3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@joepie91/eslint-config": "^1.1.1",
|
|
40
|
+
"@promistream/debug": "^0.1.0",
|
|
41
|
+
"@promistream/from-iterable": "^0.1.0",
|
|
42
|
+
"@promistream/spy": "^0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node --test"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const debug = require("debug")("pairnet");
|
|
4
|
+
const pEvent = require("p-event");
|
|
5
|
+
|
|
6
|
+
module.exports = async function disconnect(socket) {
|
|
7
|
+
debug(`[disconnectSocket] Disconnecting...`);
|
|
8
|
+
socket.destroySoon();
|
|
9
|
+
await pEvent(socket, "close");
|
|
10
|
+
debug(`[disconnectSocket] Disconnected!`);
|
|
11
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const pEvent = require("p-event");
|
|
4
|
+
const debug = require("debug")("pairnet:server-listen");
|
|
5
|
+
|
|
6
|
+
module.exports = async function serverListen(server, port, hostname) {
|
|
7
|
+
server.listen(port, hostname);
|
|
8
|
+
await pEvent(server, "listening");
|
|
9
|
+
debug(`[createServer] Listening on ${hostname}:${port}...`);
|
|
10
|
+
};
|
package/storages/file.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const cbor = require("cbor");
|
|
6
|
+
const sanitizeFilename = require("sanitize-filename");
|
|
7
|
+
const { NotSet, AlreadySet } = require("./symbols");
|
|
8
|
+
|
|
9
|
+
function safeName(name) {
|
|
10
|
+
return sanitizeFilename(name, { replacement: "_" });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = async function createFileStorage(folder) {
|
|
14
|
+
// NOTE: Unsafe filenames may result in duplicates for keys that are technically not duplicated!
|
|
15
|
+
await fs.mkdir(folder, { recursive: true });
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
_pairnetStorage: true,
|
|
19
|
+
set: async function (key, value) {
|
|
20
|
+
try {
|
|
21
|
+
await fs.writeFile(path.join(folder, safeName(key)), cbor.encode(value), { flag: "wx" });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code === "EEXIST") {
|
|
24
|
+
return AlreadySet;
|
|
25
|
+
} else {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
get: async function (key) {
|
|
31
|
+
try {
|
|
32
|
+
return fs.readFile(path.join(folder, safeName(key)));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code === "ENOENT") {
|
|
35
|
+
return NotSet;
|
|
36
|
+
} else {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
delete: async function (key) {
|
|
42
|
+
return fs.unlink(path.join(folder, safeName(key)));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { NotSet, AlreadySet } = require("./symbols");
|
|
4
|
+
|
|
5
|
+
module.exports = async function createMemoryStorage() {
|
|
6
|
+
let storage = new Map();
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
_pairnetStorage: true,
|
|
10
|
+
set: async function (key, value) {
|
|
11
|
+
if (storage.has(key)) {
|
|
12
|
+
return AlreadySet;
|
|
13
|
+
} else {
|
|
14
|
+
storage.set(key, value);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
get: async function (key) {
|
|
18
|
+
if (storage.has(key)) {
|
|
19
|
+
return storage.get(key);
|
|
20
|
+
} else {
|
|
21
|
+
return NotSet;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
delete: async function (key) {
|
|
25
|
+
return storage.delete(key);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const simpleSource = require("@promistream/simple-source");
|
|
4
|
+
const Aborted = require("@promistream/aborted");
|
|
5
|
+
const pDefer = require("p-defer");
|
|
6
|
+
|
|
7
|
+
module.exports = function createInfiniteQueue(initialItems = []) {
|
|
8
|
+
let queue = initialItems.slice();
|
|
9
|
+
let defers = [];
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
push: function (value) {
|
|
13
|
+
if (defers.length > 0) {
|
|
14
|
+
defers.shift().resolve(value);
|
|
15
|
+
} else {
|
|
16
|
+
queue.push(value);
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
stream: simpleSource({
|
|
20
|
+
onRequest: () => {
|
|
21
|
+
if (queue.length > 0) {
|
|
22
|
+
return queue.shift();
|
|
23
|
+
} else {
|
|
24
|
+
let defer = pDefer();
|
|
25
|
+
defers.push(defer);
|
|
26
|
+
return defer.promise;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
onAbort: (reason) => {
|
|
30
|
+
for (let defer of defers) {
|
|
31
|
+
// TODO: Test properly
|
|
32
|
+
defer.reject(new Aborted("Stream was aborted"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
};
|
|
37
|
+
};
|
package/test/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("assert");
|
|
5
|
+
const pairnet = require("../");
|
|
6
|
+
const pipe = require("@promistream/pipe");
|
|
7
|
+
const simpleSink = require("@promistream/simple-sink");
|
|
8
|
+
|
|
9
|
+
function noop(value) {
|
|
10
|
+
// Do nothing
|
|
11
|
+
console.log(`noop:`, value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function yeetStream(stream) {
|
|
15
|
+
return await pipe([ stream, simpleSink(noop) ]).read();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function withYeetedStream(stream, callback) {
|
|
19
|
+
return Promise.all([
|
|
20
|
+
yeetStream(stream),
|
|
21
|
+
callback()
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function expectFailure(errorPattern, callback) {
|
|
26
|
+
try {
|
|
27
|
+
await assert.rejects(callback, errorPattern);
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function makePeer(overrides) {
|
|
35
|
+
return pairnet.createPeer({
|
|
36
|
+
storage: await pairnet.createMemoryStorage(),
|
|
37
|
+
generateClientHello: function () {
|
|
38
|
+
return {
|
|
39
|
+
PROTO: "testproto"
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
onClientHello: function (clientHello) {
|
|
43
|
+
if (clientHello.PROTO === "testproto") {
|
|
44
|
+
return { PROTO_RESP: "testproto" };
|
|
45
|
+
} else {
|
|
46
|
+
throw pairnet.createQueryError("protocol mismatch");
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
onServerHello: function (serverHello) {
|
|
50
|
+
if (serverHello.PROTO_RESP === "testproto") {
|
|
51
|
+
return true;
|
|
52
|
+
} else {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
... overrides
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("full procedure", async (t) => {
|
|
61
|
+
let peer1 = await makePeer();
|
|
62
|
+
let peer2 = await makePeer();
|
|
63
|
+
|
|
64
|
+
let server = await peer1.createServer({
|
|
65
|
+
hostname: "localhost", port: 4005,
|
|
66
|
+
onQuery: (fingerprint, query) => ({ number: query.number * 2, hasFingerprint: (fingerprint != null) }),
|
|
67
|
+
onConnect: async (peer) => {
|
|
68
|
+
yeetStream(peer.eventStream);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let token = await peer1.generatePairingToken();
|
|
73
|
+
|
|
74
|
+
await t.test("fails on wrong fingerprint", async (t) => {
|
|
75
|
+
return expectFailure({ message: "Fingerprint of server certificate does not match expected fingerprint" }, async () => {
|
|
76
|
+
await peer2.pair({ hostname: "localhost", port: 4005, fingerprint: "wrong", pairingToken: token });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await t.test("fails on wrong token", async (t) => {
|
|
81
|
+
return expectFailure({ message: "Invalid pairing token" }, async () => {
|
|
82
|
+
await peer2.pair({ hostname: "localhost", port: 4005, fingerprint: peer1.fingerprint, pairingToken: "wrong" });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await t.test("happy path", async (t) => {
|
|
87
|
+
await peer2.pair({ hostname: "localhost", port: 4005, fingerprint: peer1.fingerprint, pairingToken: token });
|
|
88
|
+
let peer = await peer2.connect({ hostname: "localhost", port: 4005, onQuery: noop });
|
|
89
|
+
|
|
90
|
+
await withYeetedStream(peer.eventStream, async () => {
|
|
91
|
+
let response = await peer.query({ number: 21 });
|
|
92
|
+
assert.deepStrictEqual(response, { number: 42, hasFingerprint: true });
|
|
93
|
+
await peer.disconnect();
|
|
94
|
+
// FIXME: Test query error reply
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await server.stop();
|
|
99
|
+
});
|