wire-mesh-core 0.0.0 → 1.0.2
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 +32 -0
- package/dist/adapters/frame-codec.cjs +37 -0
- package/dist/adapters/frame-codec.d.cts +12 -0
- package/dist/adapters/frame-codec.d.mts +12 -0
- package/dist/adapters/frame-codec.mjs +33 -0
- package/dist/adapters/memory-storage.cjs +14 -0
- package/dist/adapters/memory-storage.d.cts +5 -0
- package/dist/adapters/memory-storage.d.mts +5 -0
- package/dist/adapters/memory-storage.mjs +13 -0
- package/dist/adapters/node-identity.cjs +55 -0
- package/dist/adapters/node-identity.d.cts +11 -0
- package/dist/adapters/node-identity.d.mts +11 -0
- package/dist/adapters/node-identity.mjs +52 -0
- package/dist/adapters/system-clock.cjs +8 -0
- package/dist/adapters/system-clock.d.cts +5 -0
- package/dist/adapters/system-clock.d.mts +5 -0
- package/dist/adapters/system-clock.mjs +7 -0
- package/dist/adapters/tcp-transport.cjs +154 -0
- package/dist/adapters/tcp-transport.d.cts +5 -0
- package/dist/adapters/tcp-transport.d.mts +5 -0
- package/dist/adapters/tcp-transport.mjs +153 -0
- package/dist/adapters/tls-transport.cjs +178 -0
- package/dist/adapters/tls-transport.d.cts +9 -0
- package/dist/adapters/tls-transport.d.mts +9 -0
- package/dist/adapters/tls-transport.mjs +177 -0
- package/dist/clock-DiSx-WKM.d.cts +7 -0
- package/dist/clock-DiSx-WKM.d.mts +7 -0
- package/dist/domain/device-id.cjs +27 -0
- package/dist/domain/device-id.d.cts +9 -0
- package/dist/domain/device-id.d.mts +9 -0
- package/dist/domain/device-id.mjs +24 -0
- package/dist/domain/handshake.cjs +23 -0
- package/dist/domain/handshake.d.cts +16 -0
- package/dist/domain/handshake.d.mts +16 -0
- package/dist/domain/handshake.mjs +21 -0
- package/dist/domain/mesh-session.cjs +439 -0
- package/dist/domain/mesh-session.d.cts +101 -0
- package/dist/domain/mesh-session.d.mts +101 -0
- package/dist/domain/mesh-session.mjs +436 -0
- package/dist/domain/relay-hub.cjs +99 -0
- package/dist/domain/relay-hub.d.cts +10 -0
- package/dist/domain/relay-hub.d.mts +10 -0
- package/dist/domain/relay-hub.mjs +98 -0
- package/dist/domain/revocation-view.cjs +22 -0
- package/dist/domain/revocation-view.d.cts +12 -0
- package/dist/domain/revocation-view.d.mts +12 -0
- package/dist/domain/revocation-view.mjs +21 -0
- package/dist/domain/tokens.cjs +300 -0
- package/dist/domain/tokens.d.cts +86 -0
- package/dist/domain/tokens.d.mts +86 -0
- package/dist/domain/tokens.mjs +296 -0
- package/dist/generated/protocol.cjs +466 -0
- package/dist/generated/protocol.d.cts +2 -0
- package/dist/generated/protocol.d.mts +2 -0
- package/dist/generated/protocol.mjs +382 -0
- package/dist/generated/runtime.cjs +8 -0
- package/dist/generated/runtime.d.cts +2 -0
- package/dist/generated/runtime.d.mts +2 -0
- package/dist/generated/runtime.mjs +2 -0
- package/dist/identity-BRLEUfVY.d.cts +17 -0
- package/dist/identity-qNkmGytv.d.mts +17 -0
- package/dist/ports/clock.cjs +0 -0
- package/dist/ports/clock.d.cts +2 -0
- package/dist/ports/clock.d.mts +2 -0
- package/dist/ports/clock.mjs +1 -0
- package/dist/ports/identity.cjs +0 -0
- package/dist/ports/identity.d.cts +2 -0
- package/dist/ports/identity.d.mts +2 -0
- package/dist/ports/identity.mjs +1 -0
- package/dist/ports/storage.cjs +0 -0
- package/dist/ports/storage.d.cts +9 -0
- package/dist/ports/storage.d.mts +9 -0
- package/dist/ports/storage.mjs +1 -0
- package/dist/ports/transport.cjs +0 -0
- package/dist/ports/transport.d.cts +29 -0
- package/dist/ports/transport.d.mts +29 -0
- package/dist/ports/transport.mjs +1 -0
- package/dist/protocol-B26-5VX7.d.cts +1112 -0
- package/dist/protocol-B26-5VX7.d.mts +1112 -0
- package/package.json +130 -2
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { frameSchema } from "../generated/protocol.mjs";
|
|
2
|
+
import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
|
|
3
|
+
import { connect, createServer } from "node:net";
|
|
4
|
+
//#region src/adapters/tcp-transport.ts
|
|
5
|
+
const LENGTH_PREFIX_BYTES = 4;
|
|
6
|
+
function parseAddress(address) {
|
|
7
|
+
const lastColon = address.lastIndexOf(":");
|
|
8
|
+
if (lastColon === -1) throw new Error(`expected "host:port", got "${address}"`);
|
|
9
|
+
return {
|
|
10
|
+
host: address.slice(0, lastColon),
|
|
11
|
+
port: Number(address.slice(lastColon + 1))
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function writeFrame(socket, frame) {
|
|
15
|
+
const body = encode(frame, cdeEncodeOptions);
|
|
16
|
+
const header = Buffer.alloc(LENGTH_PREFIX_BYTES);
|
|
17
|
+
header.writeUInt32BE(body.length, 0);
|
|
18
|
+
socket.write(header);
|
|
19
|
+
socket.write(body);
|
|
20
|
+
}
|
|
21
|
+
/** Reassembles length-prefixed CBOR frames from a byte stream, validating each against frameSchema before handing it to a consumer. A body that doesn't even decode as CBOR rejects the receive() iteration and destroys the connection -- hostile wire input is a connection-level failure, surfaced through the Transport port rather than crashing the process or being silently swallowed. */
|
|
22
|
+
function frameReader(socket) {
|
|
23
|
+
let buffer = Buffer.alloc(0);
|
|
24
|
+
const pending = [];
|
|
25
|
+
const waiters = [];
|
|
26
|
+
let ended = false;
|
|
27
|
+
let failure = null;
|
|
28
|
+
function tryDrain() {
|
|
29
|
+
while (buffer.length >= LENGTH_PREFIX_BYTES) {
|
|
30
|
+
const bodyLength = buffer.readUInt32BE(0);
|
|
31
|
+
if (buffer.length < LENGTH_PREFIX_BYTES + bodyLength) break;
|
|
32
|
+
const body = buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + bodyLength);
|
|
33
|
+
buffer = buffer.subarray(LENGTH_PREFIX_BYTES + bodyLength);
|
|
34
|
+
let decoded;
|
|
35
|
+
try {
|
|
36
|
+
decoded = decode(body, cdeDecodeOptions);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const connectionError = error instanceof Error ? error : /* @__PURE__ */ new Error(`frame body failed to decode: ${String(error)}`);
|
|
39
|
+
failAll(connectionError);
|
|
40
|
+
socket.destroy(connectionError);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const result = frameSchema.safeParse(decoded);
|
|
44
|
+
if (result.success) {
|
|
45
|
+
const waiter = waiters.shift();
|
|
46
|
+
if (waiter) waiter.resolve({
|
|
47
|
+
value: result.data,
|
|
48
|
+
done: false
|
|
49
|
+
});
|
|
50
|
+
else pending.push(result.data);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function endAll() {
|
|
55
|
+
ended = true;
|
|
56
|
+
for (const waiter of waiters.splice(0)) waiter.resolve({
|
|
57
|
+
value: void 0,
|
|
58
|
+
done: true
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Ends the iteration with the connection-level error: pending and future next() calls reject, so a consumer iterating receive() sees the failure where it consumed the stream. */
|
|
62
|
+
function failAll(error) {
|
|
63
|
+
failure = error;
|
|
64
|
+
ended = true;
|
|
65
|
+
for (const waiter of waiters.splice(0)) waiter.reject(error);
|
|
66
|
+
}
|
|
67
|
+
socket.on("data", (chunk) => {
|
|
68
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
69
|
+
tryDrain();
|
|
70
|
+
});
|
|
71
|
+
socket.on("end", endAll);
|
|
72
|
+
socket.on("close", endAll);
|
|
73
|
+
socket.on("error", () => void 0);
|
|
74
|
+
return { [Symbol.asyncIterator]() {
|
|
75
|
+
return { async next() {
|
|
76
|
+
const next = pending.shift();
|
|
77
|
+
if (next !== void 0) return Promise.resolve({
|
|
78
|
+
value: next,
|
|
79
|
+
done: false
|
|
80
|
+
});
|
|
81
|
+
if (failure !== null) return Promise.reject(failure);
|
|
82
|
+
if (ended) return Promise.resolve({
|
|
83
|
+
value: void 0,
|
|
84
|
+
done: true
|
|
85
|
+
});
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
waiters.push({
|
|
88
|
+
resolve,
|
|
89
|
+
reject
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
} };
|
|
93
|
+
} };
|
|
94
|
+
}
|
|
95
|
+
function wrapSocket(socket) {
|
|
96
|
+
const frames = frameReader(socket);
|
|
97
|
+
return {
|
|
98
|
+
send: async (frame) => {
|
|
99
|
+
writeFrame(socket, frame);
|
|
100
|
+
return Promise.resolve();
|
|
101
|
+
},
|
|
102
|
+
receive: () => frames,
|
|
103
|
+
close: async () => new Promise((resolve) => {
|
|
104
|
+
socket.end(() => {
|
|
105
|
+
resolve();
|
|
106
|
+
});
|
|
107
|
+
})
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** A Node net.Socket-based Transport: length-prefixed, CBOR-encoded frames over plain TCP -- matching Cascade's own transport shape, since interop with Cascade nodes is wire-mesh's stated goal. Framing (not TLS) is this adapter's own concern; a TLS-terminated variant is a separate adapter behind the same Transport contract. */
|
|
111
|
+
function createTcpTransport() {
|
|
112
|
+
return {
|
|
113
|
+
async connect(address) {
|
|
114
|
+
const { host, port } = parseAddress(address);
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const socket = connect({
|
|
117
|
+
host,
|
|
118
|
+
port
|
|
119
|
+
});
|
|
120
|
+
socket.once("connect", () => {
|
|
121
|
+
resolve(wrapSocket(socket));
|
|
122
|
+
});
|
|
123
|
+
socket.once("error", reject);
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
async listen(address, onConnection) {
|
|
127
|
+
const { host, port } = parseAddress(address);
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const server = createServer((socket) => {
|
|
130
|
+
onConnection(wrapSocket(socket));
|
|
131
|
+
});
|
|
132
|
+
server.once("error", reject);
|
|
133
|
+
server.listen(port, host, () => {
|
|
134
|
+
const bound = server.address();
|
|
135
|
+
if (bound === null || typeof bound === "string") {
|
|
136
|
+
reject(/* @__PURE__ */ new Error("listener did not report a bound address"));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
resolve({
|
|
140
|
+
close: async () => new Promise((resolveClose) => {
|
|
141
|
+
server.close(() => {
|
|
142
|
+
resolveClose();
|
|
143
|
+
});
|
|
144
|
+
}),
|
|
145
|
+
address: `${bound.address}:${String(bound.port)}`
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
export { createTcpTransport };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_generated_protocol = require("../generated/protocol.cjs");
|
|
3
|
+
const require_adapters_node_identity = require("./node-identity.cjs");
|
|
4
|
+
let cbor2 = require("cbor2");
|
|
5
|
+
let node_tls = require("node:tls");
|
|
6
|
+
//#region src/adapters/tls-transport.ts
|
|
7
|
+
const LENGTH_PREFIX_BYTES = 4;
|
|
8
|
+
function parseAddress(address) {
|
|
9
|
+
const lastColon = address.lastIndexOf(":");
|
|
10
|
+
if (lastColon === -1) throw new Error(`expected "host:port", got "${address}"`);
|
|
11
|
+
return {
|
|
12
|
+
host: address.slice(0, lastColon),
|
|
13
|
+
port: Number(address.slice(lastColon + 1))
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function writeFrame(socket, frame) {
|
|
17
|
+
const body = (0, cbor2.encode)(frame, cbor2.cdeEncodeOptions);
|
|
18
|
+
const header = Buffer.alloc(LENGTH_PREFIX_BYTES);
|
|
19
|
+
header.writeUInt32BE(body.length, 0);
|
|
20
|
+
socket.write(header);
|
|
21
|
+
socket.write(body);
|
|
22
|
+
}
|
|
23
|
+
/** Reassembles length-prefixed CBOR frames from a byte stream, validating each against frameSchema before handing it to a consumer -- identical framing to createTcpTransport's own frameReader, since TLS terminates before framing and the wire shape above it is unchanged. A body that doesn't even decode as CBOR rejects the receive() iteration and destroys the connection. */
|
|
24
|
+
function frameReader(socket) {
|
|
25
|
+
let buffer = Buffer.alloc(0);
|
|
26
|
+
const pending = [];
|
|
27
|
+
const waiters = [];
|
|
28
|
+
let ended = false;
|
|
29
|
+
let failure = null;
|
|
30
|
+
function tryDrain() {
|
|
31
|
+
while (buffer.length >= LENGTH_PREFIX_BYTES) {
|
|
32
|
+
const bodyLength = buffer.readUInt32BE(0);
|
|
33
|
+
if (buffer.length < LENGTH_PREFIX_BYTES + bodyLength) break;
|
|
34
|
+
const body = buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + bodyLength);
|
|
35
|
+
buffer = buffer.subarray(LENGTH_PREFIX_BYTES + bodyLength);
|
|
36
|
+
let decoded;
|
|
37
|
+
try {
|
|
38
|
+
decoded = (0, cbor2.decode)(body, cbor2.cdeDecodeOptions);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const connectionError = error instanceof Error ? error : /* @__PURE__ */ new Error(`frame body failed to decode: ${String(error)}`);
|
|
41
|
+
failAll(connectionError);
|
|
42
|
+
socket.destroy(connectionError);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const result = require_generated_protocol.frameSchema.safeParse(decoded);
|
|
46
|
+
if (result.success) {
|
|
47
|
+
const waiter = waiters.shift();
|
|
48
|
+
if (waiter) waiter.resolve({
|
|
49
|
+
value: result.data,
|
|
50
|
+
done: false
|
|
51
|
+
});
|
|
52
|
+
else pending.push(result.data);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function endAll() {
|
|
57
|
+
ended = true;
|
|
58
|
+
for (const waiter of waiters.splice(0)) waiter.resolve({
|
|
59
|
+
value: void 0,
|
|
60
|
+
done: true
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function failAll(error) {
|
|
64
|
+
failure = error;
|
|
65
|
+
ended = true;
|
|
66
|
+
for (const waiter of waiters.splice(0)) waiter.reject(error);
|
|
67
|
+
}
|
|
68
|
+
socket.on("data", (chunk) => {
|
|
69
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
70
|
+
tryDrain();
|
|
71
|
+
});
|
|
72
|
+
socket.on("end", endAll);
|
|
73
|
+
socket.on("close", endAll);
|
|
74
|
+
socket.on("error", () => void 0);
|
|
75
|
+
return { [Symbol.asyncIterator]() {
|
|
76
|
+
return { async next() {
|
|
77
|
+
const next = pending.shift();
|
|
78
|
+
if (next !== void 0) return Promise.resolve({
|
|
79
|
+
value: next,
|
|
80
|
+
done: false
|
|
81
|
+
});
|
|
82
|
+
if (failure !== null) return Promise.reject(failure);
|
|
83
|
+
if (ended) return Promise.resolve({
|
|
84
|
+
value: void 0,
|
|
85
|
+
done: true
|
|
86
|
+
});
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
waiters.push({
|
|
89
|
+
resolve,
|
|
90
|
+
reject
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
} };
|
|
94
|
+
} };
|
|
95
|
+
}
|
|
96
|
+
/** Authenticates the socket's peer, if it presented a certificate: derives a device-id from the certificate's own raw public-key bytes -- never from anything the peer merely claims -- and returns undefined only when no certificate was presented at all (never on a mismatch; there is nothing to mismatch against at this layer, only a fact to report). Node's TLS layer has already verified the peer actually holds the matching private key by the time a socket reaches this point, so this device-id is cryptographically backed, not self-asserted. */
|
|
97
|
+
async function authenticatedPeerDeviceId(socket) {
|
|
98
|
+
const cert = socket.getPeerCertificate();
|
|
99
|
+
if (Object.keys(cert).length === 0 || !("pubkey" in cert)) return;
|
|
100
|
+
return require_adapters_node_identity.deriveDeviceId(cert.pubkey);
|
|
101
|
+
}
|
|
102
|
+
function wrapSocket(socket, peerDeviceId) {
|
|
103
|
+
const frames = frameReader(socket);
|
|
104
|
+
return {
|
|
105
|
+
send: async (frame) => {
|
|
106
|
+
writeFrame(socket, frame);
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
},
|
|
109
|
+
receive: () => frames,
|
|
110
|
+
close: async () => new Promise((resolve) => {
|
|
111
|
+
socket.end(() => {
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
114
|
+
}),
|
|
115
|
+
...peerDeviceId !== void 0 ? { peerDeviceId } : {},
|
|
116
|
+
unref: () => {
|
|
117
|
+
socket.unref();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** A Node tls.TLSSocket-based Transport: the identical length-prefixed CBOR framing createTcpTransport uses, but over mutually-authenticated TLS -- both sides always present the certificate from the identity passed in, and rejectUnauthorized stays false deliberately (this is certificate-pinning by device-id, the Syncthing trust model, not a CA hierarchy) while Connection.peerDeviceId carries the one thing that actually matters: what the presented certificate cryptographically proves the peer holds the key for. */
|
|
122
|
+
function createTlsTransport(identity) {
|
|
123
|
+
const tlsOptions = {
|
|
124
|
+
key: identity.privateKeyPem,
|
|
125
|
+
cert: identity.certificatePem,
|
|
126
|
+
rejectUnauthorized: false
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
async connect(address) {
|
|
130
|
+
const { host, port } = parseAddress(address);
|
|
131
|
+
const socket = await new Promise((resolve, reject) => {
|
|
132
|
+
const s = (0, node_tls.connect)({
|
|
133
|
+
...tlsOptions,
|
|
134
|
+
host,
|
|
135
|
+
port
|
|
136
|
+
}, () => {
|
|
137
|
+
resolve(s);
|
|
138
|
+
});
|
|
139
|
+
s.once("error", reject);
|
|
140
|
+
});
|
|
141
|
+
return wrapSocket(socket, await authenticatedPeerDeviceId(socket));
|
|
142
|
+
},
|
|
143
|
+
async listen(address, onConnection) {
|
|
144
|
+
const { host, port } = parseAddress(address);
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const server = (0, node_tls.createServer)({
|
|
147
|
+
...tlsOptions,
|
|
148
|
+
requestCert: true
|
|
149
|
+
}, (socket) => {
|
|
150
|
+
authenticatedPeerDeviceId(socket).then((peerDeviceId) => {
|
|
151
|
+
onConnection(wrapSocket(socket, peerDeviceId));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
server.once("error", reject);
|
|
155
|
+
server.listen(port, host, () => {
|
|
156
|
+
const bound = server.address();
|
|
157
|
+
if (bound === null || typeof bound === "string") {
|
|
158
|
+
reject(/* @__PURE__ */ new Error("listener did not report a bound address"));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
resolve({
|
|
162
|
+
close: async () => new Promise((resolveClose) => {
|
|
163
|
+
server.close(() => {
|
|
164
|
+
resolveClose();
|
|
165
|
+
});
|
|
166
|
+
}),
|
|
167
|
+
address: `${bound.address}:${String(bound.port)}`,
|
|
168
|
+
unref: () => {
|
|
169
|
+
server.unref();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
exports.createTlsTransport = createTlsTransport;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Transport } from "../ports/transport.cjs";
|
|
2
|
+
//#region src/adapters/tls-transport.d.ts
|
|
3
|
+
export interface TlsIdentity {
|
|
4
|
+
certificatePem: string;
|
|
5
|
+
privateKeyPem: string;
|
|
6
|
+
}
|
|
7
|
+
/** A Node tls.TLSSocket-based Transport: the identical length-prefixed CBOR framing createTcpTransport uses, but over mutually-authenticated TLS -- both sides always present the certificate from the identity passed in, and rejectUnauthorized stays false deliberately (this is certificate-pinning by device-id, the Syncthing trust model, not a CA hierarchy) while Connection.peerDeviceId carries the one thing that actually matters: what the presented certificate cryptographically proves the peer holds the key for. */
|
|
8
|
+
export declare function createTlsTransport(identity: Readonly<TlsIdentity>): Transport;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Transport } from "../ports/transport.mjs";
|
|
2
|
+
//#region src/adapters/tls-transport.d.ts
|
|
3
|
+
export interface TlsIdentity {
|
|
4
|
+
certificatePem: string;
|
|
5
|
+
privateKeyPem: string;
|
|
6
|
+
}
|
|
7
|
+
/** A Node tls.TLSSocket-based Transport: the identical length-prefixed CBOR framing createTcpTransport uses, but over mutually-authenticated TLS -- both sides always present the certificate from the identity passed in, and rejectUnauthorized stays false deliberately (this is certificate-pinning by device-id, the Syncthing trust model, not a CA hierarchy) while Connection.peerDeviceId carries the one thing that actually matters: what the presented certificate cryptographically proves the peer holds the key for. */
|
|
8
|
+
export declare function createTlsTransport(identity: Readonly<TlsIdentity>): Transport;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { frameSchema } from "../generated/protocol.mjs";
|
|
2
|
+
import { deriveDeviceId } from "./node-identity.mjs";
|
|
3
|
+
import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
|
|
4
|
+
import { connect, createServer } from "node:tls";
|
|
5
|
+
//#region src/adapters/tls-transport.ts
|
|
6
|
+
const LENGTH_PREFIX_BYTES = 4;
|
|
7
|
+
function parseAddress(address) {
|
|
8
|
+
const lastColon = address.lastIndexOf(":");
|
|
9
|
+
if (lastColon === -1) throw new Error(`expected "host:port", got "${address}"`);
|
|
10
|
+
return {
|
|
11
|
+
host: address.slice(0, lastColon),
|
|
12
|
+
port: Number(address.slice(lastColon + 1))
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function writeFrame(socket, frame) {
|
|
16
|
+
const body = encode(frame, cdeEncodeOptions);
|
|
17
|
+
const header = Buffer.alloc(LENGTH_PREFIX_BYTES);
|
|
18
|
+
header.writeUInt32BE(body.length, 0);
|
|
19
|
+
socket.write(header);
|
|
20
|
+
socket.write(body);
|
|
21
|
+
}
|
|
22
|
+
/** Reassembles length-prefixed CBOR frames from a byte stream, validating each against frameSchema before handing it to a consumer -- identical framing to createTcpTransport's own frameReader, since TLS terminates before framing and the wire shape above it is unchanged. A body that doesn't even decode as CBOR rejects the receive() iteration and destroys the connection. */
|
|
23
|
+
function frameReader(socket) {
|
|
24
|
+
let buffer = Buffer.alloc(0);
|
|
25
|
+
const pending = [];
|
|
26
|
+
const waiters = [];
|
|
27
|
+
let ended = false;
|
|
28
|
+
let failure = null;
|
|
29
|
+
function tryDrain() {
|
|
30
|
+
while (buffer.length >= LENGTH_PREFIX_BYTES) {
|
|
31
|
+
const bodyLength = buffer.readUInt32BE(0);
|
|
32
|
+
if (buffer.length < LENGTH_PREFIX_BYTES + bodyLength) break;
|
|
33
|
+
const body = buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + bodyLength);
|
|
34
|
+
buffer = buffer.subarray(LENGTH_PREFIX_BYTES + bodyLength);
|
|
35
|
+
let decoded;
|
|
36
|
+
try {
|
|
37
|
+
decoded = decode(body, cdeDecodeOptions);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const connectionError = error instanceof Error ? error : /* @__PURE__ */ new Error(`frame body failed to decode: ${String(error)}`);
|
|
40
|
+
failAll(connectionError);
|
|
41
|
+
socket.destroy(connectionError);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const result = frameSchema.safeParse(decoded);
|
|
45
|
+
if (result.success) {
|
|
46
|
+
const waiter = waiters.shift();
|
|
47
|
+
if (waiter) waiter.resolve({
|
|
48
|
+
value: result.data,
|
|
49
|
+
done: false
|
|
50
|
+
});
|
|
51
|
+
else pending.push(result.data);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function endAll() {
|
|
56
|
+
ended = true;
|
|
57
|
+
for (const waiter of waiters.splice(0)) waiter.resolve({
|
|
58
|
+
value: void 0,
|
|
59
|
+
done: true
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function failAll(error) {
|
|
63
|
+
failure = error;
|
|
64
|
+
ended = true;
|
|
65
|
+
for (const waiter of waiters.splice(0)) waiter.reject(error);
|
|
66
|
+
}
|
|
67
|
+
socket.on("data", (chunk) => {
|
|
68
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
69
|
+
tryDrain();
|
|
70
|
+
});
|
|
71
|
+
socket.on("end", endAll);
|
|
72
|
+
socket.on("close", endAll);
|
|
73
|
+
socket.on("error", () => void 0);
|
|
74
|
+
return { [Symbol.asyncIterator]() {
|
|
75
|
+
return { async next() {
|
|
76
|
+
const next = pending.shift();
|
|
77
|
+
if (next !== void 0) return Promise.resolve({
|
|
78
|
+
value: next,
|
|
79
|
+
done: false
|
|
80
|
+
});
|
|
81
|
+
if (failure !== null) return Promise.reject(failure);
|
|
82
|
+
if (ended) return Promise.resolve({
|
|
83
|
+
value: void 0,
|
|
84
|
+
done: true
|
|
85
|
+
});
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
waiters.push({
|
|
88
|
+
resolve,
|
|
89
|
+
reject
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
} };
|
|
93
|
+
} };
|
|
94
|
+
}
|
|
95
|
+
/** Authenticates the socket's peer, if it presented a certificate: derives a device-id from the certificate's own raw public-key bytes -- never from anything the peer merely claims -- and returns undefined only when no certificate was presented at all (never on a mismatch; there is nothing to mismatch against at this layer, only a fact to report). Node's TLS layer has already verified the peer actually holds the matching private key by the time a socket reaches this point, so this device-id is cryptographically backed, not self-asserted. */
|
|
96
|
+
async function authenticatedPeerDeviceId(socket) {
|
|
97
|
+
const cert = socket.getPeerCertificate();
|
|
98
|
+
if (Object.keys(cert).length === 0 || !("pubkey" in cert)) return;
|
|
99
|
+
return deriveDeviceId(cert.pubkey);
|
|
100
|
+
}
|
|
101
|
+
function wrapSocket(socket, peerDeviceId) {
|
|
102
|
+
const frames = frameReader(socket);
|
|
103
|
+
return {
|
|
104
|
+
send: async (frame) => {
|
|
105
|
+
writeFrame(socket, frame);
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
},
|
|
108
|
+
receive: () => frames,
|
|
109
|
+
close: async () => new Promise((resolve) => {
|
|
110
|
+
socket.end(() => {
|
|
111
|
+
resolve();
|
|
112
|
+
});
|
|
113
|
+
}),
|
|
114
|
+
...peerDeviceId !== void 0 ? { peerDeviceId } : {},
|
|
115
|
+
unref: () => {
|
|
116
|
+
socket.unref();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** A Node tls.TLSSocket-based Transport: the identical length-prefixed CBOR framing createTcpTransport uses, but over mutually-authenticated TLS -- both sides always present the certificate from the identity passed in, and rejectUnauthorized stays false deliberately (this is certificate-pinning by device-id, the Syncthing trust model, not a CA hierarchy) while Connection.peerDeviceId carries the one thing that actually matters: what the presented certificate cryptographically proves the peer holds the key for. */
|
|
121
|
+
function createTlsTransport(identity) {
|
|
122
|
+
const tlsOptions = {
|
|
123
|
+
key: identity.privateKeyPem,
|
|
124
|
+
cert: identity.certificatePem,
|
|
125
|
+
rejectUnauthorized: false
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
async connect(address) {
|
|
129
|
+
const { host, port } = parseAddress(address);
|
|
130
|
+
const socket = await new Promise((resolve, reject) => {
|
|
131
|
+
const s = connect({
|
|
132
|
+
...tlsOptions,
|
|
133
|
+
host,
|
|
134
|
+
port
|
|
135
|
+
}, () => {
|
|
136
|
+
resolve(s);
|
|
137
|
+
});
|
|
138
|
+
s.once("error", reject);
|
|
139
|
+
});
|
|
140
|
+
return wrapSocket(socket, await authenticatedPeerDeviceId(socket));
|
|
141
|
+
},
|
|
142
|
+
async listen(address, onConnection) {
|
|
143
|
+
const { host, port } = parseAddress(address);
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const server = createServer({
|
|
146
|
+
...tlsOptions,
|
|
147
|
+
requestCert: true
|
|
148
|
+
}, (socket) => {
|
|
149
|
+
authenticatedPeerDeviceId(socket).then((peerDeviceId) => {
|
|
150
|
+
onConnection(wrapSocket(socket, peerDeviceId));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
server.once("error", reject);
|
|
154
|
+
server.listen(port, host, () => {
|
|
155
|
+
const bound = server.address();
|
|
156
|
+
if (bound === null || typeof bound === "string") {
|
|
157
|
+
reject(/* @__PURE__ */ new Error("listener did not report a bound address"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
resolve({
|
|
161
|
+
close: async () => new Promise((resolveClose) => {
|
|
162
|
+
server.close(() => {
|
|
163
|
+
resolveClose();
|
|
164
|
+
});
|
|
165
|
+
}),
|
|
166
|
+
address: `${bound.address}:${String(bound.port)}`,
|
|
167
|
+
unref: () => {
|
|
168
|
+
server.unref();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
export { createTlsTransport };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/ports/clock.d.ts
|
|
2
|
+
interface Clock {
|
|
3
|
+
/** Current time as Unix milliseconds, matching every uint timestamp field in the protocol (token-claims.expires, revocation-entry.revoked-at, handshake params, etc.). */
|
|
4
|
+
now: () => number;
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
export { Clock as t };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/ports/clock.d.ts
|
|
2
|
+
interface Clock {
|
|
3
|
+
/** Current time as Unix milliseconds, matching every uint timestamp field in the protocol (token-claims.expires, revocation-entry.revoked-at, handshake params, etc.). */
|
|
4
|
+
now: () => number;
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
export { Clock as t };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_generated_protocol = require("../generated/protocol.cjs");
|
|
3
|
+
//#region src/domain/device-id.ts
|
|
4
|
+
const HEX_RADIX = 16;
|
|
5
|
+
const HEX_BYTE_WIDTH = 2;
|
|
6
|
+
const DEVICE_ID_HEX_LENGTH = 64;
|
|
7
|
+
/** Lowercase, byte-exact hex for an arbitrary-length byte string -- the same encoding convention deviceIdToHex uses for the fixed-length device-id case, extracted so any other byte string needing a stable, displayable, map-keyable text form (e.g. a token-id, which tokens.cddl defines as an arbitrary-length bstr rather than a 32-byte device-id) can use the identical convention without going through a device-id-shaped function. */
|
|
8
|
+
function bytesToHex(bytes) {
|
|
9
|
+
let hex = "";
|
|
10
|
+
for (const byte of bytes) hex += byte.toString(HEX_RADIX).padStart(HEX_BYTE_WIDTH, "0");
|
|
11
|
+
return hex;
|
|
12
|
+
}
|
|
13
|
+
/** Lowercase, byte-exact hex -- the same encoding room.cddl's device-id-hex regex and the conformance vectors' synthetic device-ids already use. */
|
|
14
|
+
function deviceIdToHex(device) {
|
|
15
|
+
return bytesToHex(device);
|
|
16
|
+
}
|
|
17
|
+
/** Parses a lowercase, 64-character device-id-hex string back into the 32-byte DeviceId it encodes. Throws on anything that isn't exactly that shape, rather than silently truncating or zero-padding a malformed input. */
|
|
18
|
+
function deviceIdFromHex(hex) {
|
|
19
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error(`expected a 64-character lowercase hex string, got ${JSON.stringify(hex)}`);
|
|
20
|
+
const bytes = new Uint8Array(DEVICE_ID_HEX_LENGTH / HEX_BYTE_WIDTH);
|
|
21
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(hex.slice(i * HEX_BYTE_WIDTH, (i + 1) * HEX_BYTE_WIDTH), HEX_RADIX);
|
|
22
|
+
return require_generated_protocol.deviceIdSchema.parse(bytes);
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
exports.bytesToHex = bytesToHex;
|
|
26
|
+
exports.deviceIdFromHex = deviceIdFromHex;
|
|
27
|
+
exports.deviceIdToHex = deviceIdToHex;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { v as DeviceId } from "../protocol-B26-5VX7.cjs";
|
|
2
|
+
//#region src/domain/device-id.d.ts
|
|
3
|
+
/** Lowercase, byte-exact hex for an arbitrary-length byte string -- the same encoding convention deviceIdToHex uses for the fixed-length device-id case, extracted so any other byte string needing a stable, displayable, map-keyable text form (e.g. a token-id, which tokens.cddl defines as an arbitrary-length bstr rather than a 32-byte device-id) can use the identical convention without going through a device-id-shaped function. */
|
|
4
|
+
export declare function bytesToHex(bytes: Uint8Array): string;
|
|
5
|
+
/** Lowercase, byte-exact hex -- the same encoding room.cddl's device-id-hex regex and the conformance vectors' synthetic device-ids already use. */
|
|
6
|
+
export declare function deviceIdToHex(device: DeviceId): string;
|
|
7
|
+
/** Parses a lowercase, 64-character device-id-hex string back into the 32-byte DeviceId it encodes. Throws on anything that isn't exactly that shape, rather than silently truncating or zero-padding a malformed input. */
|
|
8
|
+
export declare function deviceIdFromHex(hex: string): DeviceId;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { v as DeviceId } from "../protocol-B26-5VX7.mjs";
|
|
2
|
+
//#region src/domain/device-id.d.ts
|
|
3
|
+
/** Lowercase, byte-exact hex for an arbitrary-length byte string -- the same encoding convention deviceIdToHex uses for the fixed-length device-id case, extracted so any other byte string needing a stable, displayable, map-keyable text form (e.g. a token-id, which tokens.cddl defines as an arbitrary-length bstr rather than a 32-byte device-id) can use the identical convention without going through a device-id-shaped function. */
|
|
4
|
+
export declare function bytesToHex(bytes: Uint8Array): string;
|
|
5
|
+
/** Lowercase, byte-exact hex -- the same encoding room.cddl's device-id-hex regex and the conformance vectors' synthetic device-ids already use. */
|
|
6
|
+
export declare function deviceIdToHex(device: DeviceId): string;
|
|
7
|
+
/** Parses a lowercase, 64-character device-id-hex string back into the 32-byte DeviceId it encodes. Throws on anything that isn't exactly that shape, rather than silently truncating or zero-padding a malformed input. */
|
|
8
|
+
export declare function deviceIdFromHex(hex: string): DeviceId;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { deviceIdSchema } from "../generated/protocol.mjs";
|
|
2
|
+
//#region src/domain/device-id.ts
|
|
3
|
+
const HEX_RADIX = 16;
|
|
4
|
+
const HEX_BYTE_WIDTH = 2;
|
|
5
|
+
const DEVICE_ID_HEX_LENGTH = 64;
|
|
6
|
+
/** Lowercase, byte-exact hex for an arbitrary-length byte string -- the same encoding convention deviceIdToHex uses for the fixed-length device-id case, extracted so any other byte string needing a stable, displayable, map-keyable text form (e.g. a token-id, which tokens.cddl defines as an arbitrary-length bstr rather than a 32-byte device-id) can use the identical convention without going through a device-id-shaped function. */
|
|
7
|
+
function bytesToHex(bytes) {
|
|
8
|
+
let hex = "";
|
|
9
|
+
for (const byte of bytes) hex += byte.toString(HEX_RADIX).padStart(HEX_BYTE_WIDTH, "0");
|
|
10
|
+
return hex;
|
|
11
|
+
}
|
|
12
|
+
/** Lowercase, byte-exact hex -- the same encoding room.cddl's device-id-hex regex and the conformance vectors' synthetic device-ids already use. */
|
|
13
|
+
function deviceIdToHex(device) {
|
|
14
|
+
return bytesToHex(device);
|
|
15
|
+
}
|
|
16
|
+
/** Parses a lowercase, 64-character device-id-hex string back into the 32-byte DeviceId it encodes. Throws on anything that isn't exactly that shape, rather than silently truncating or zero-padding a malformed input. */
|
|
17
|
+
function deviceIdFromHex(hex) {
|
|
18
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error(`expected a 64-character lowercase hex string, got ${JSON.stringify(hex)}`);
|
|
19
|
+
const bytes = new Uint8Array(DEVICE_ID_HEX_LENGTH / HEX_BYTE_WIDTH);
|
|
20
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(hex.slice(i * HEX_BYTE_WIDTH, (i + 1) * HEX_BYTE_WIDTH), HEX_RADIX);
|
|
21
|
+
return deviceIdSchema.parse(bytes);
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { bytesToHex, deviceIdFromHex, deviceIdToHex };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/domain/handshake.ts
|
|
3
|
+
/** The highest protocol version this build of core understands. */
|
|
4
|
+
const SUPPORTED_PROTOCOL_VERSION = 1;
|
|
5
|
+
/**
|
|
6
|
+
* Domain names that must never be negotiated, per handshake.cddl: core/federation is retired (its string stays reserved, never reused) and "a peer must never advertise or negotiate it". Excluded here even if both peers advertise it -- two buggy advertisers must not end up speaking a dead domain.
|
|
7
|
+
*/
|
|
8
|
+
const RETIRED_DOMAINS = ["core/federation"];
|
|
9
|
+
/**
|
|
10
|
+
* Negotiates protocol version and capability domains between a local and remote handshake -- the mechanism wire-mesh's handshake exists to provide, and agent-comms issue #31's fix: a mixed fleet of old and new peers negotiates down to what they both actually support, rather than one side silently misinterpreting frames the other can't produce yet.
|
|
11
|
+
*/
|
|
12
|
+
function negotiate(local, remote) {
|
|
13
|
+
const version = Math.min(local.version, remote.version);
|
|
14
|
+
const sharedDomains = local.domains.filter((domain) => !RETIRED_DOMAINS.includes(domain) && remote.domains.includes(domain));
|
|
15
|
+
return {
|
|
16
|
+
ok: version >= 1 && sharedDomains.length > 0,
|
|
17
|
+
version,
|
|
18
|
+
sharedDomains
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
exports.SUPPORTED_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSION;
|
|
23
|
+
exports.negotiate = negotiate;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Z as ProtocolVersion, k as HandshakeFrame, x as DomainId } from "../protocol-B26-5VX7.cjs";
|
|
2
|
+
//#region src/domain/handshake.d.ts
|
|
3
|
+
/** The highest protocol version this build of core understands. */
|
|
4
|
+
export declare const SUPPORTED_PROTOCOL_VERSION: ProtocolVersion;
|
|
5
|
+
export interface NegotiationResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
/** The version both peers will speak for the rest of the session -- the lower of the two offered versions, so a peer never has to understand a frame shape it didn't advertise. */
|
|
8
|
+
version: ProtocolVersion;
|
|
9
|
+
/** Domains both peers advertised and that are not retired -- the only ones either side may address for the rest of the session. */
|
|
10
|
+
sharedDomains: DomainId[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Negotiates protocol version and capability domains between a local and remote handshake -- the mechanism wire-mesh's handshake exists to provide, and agent-comms issue #31's fix: a mixed fleet of old and new peers negotiates down to what they both actually support, rather than one side silently misinterpreting frames the other can't produce yet.
|
|
14
|
+
*/
|
|
15
|
+
export declare function negotiate(local: HandshakeFrame, remote: HandshakeFrame): NegotiationResult;
|
|
16
|
+
//#endregion
|