signalk-edge-link 2.1.1 → 2.2.1
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 +3 -2
- package/lib/bonding.js +4 -1
- package/lib/connection-config.js +4 -0
- package/lib/crypto.js +49 -20
- package/lib/index.js +6 -454
- package/lib/instance.js +2 -1
- package/lib/packet.js +14 -8
- package/lib/pipeline-v2-client.js +8 -3
- package/lib/pipeline-v2-server.js +27 -3
- package/lib/pipeline.js +6 -2
- package/lib/shared/connection-schema.js +539 -0
- package/lib/shared/crypto-constants.js +18 -0
- package/package.json +164 -165
- package/public/{277.509a7bfc11fac344f433.js → 277.99e19dcb5b778c964ace.js} +4 -4
- package/public/277.99e19dcb5b778c964ace.js.map +1 -0
- package/public/982.63949a2b2f6c5854e034.js +2 -0
- package/public/982.63949a2b2f6c5854e034.js.map +1 -0
- package/public/index.html +1 -1
- package/public/{main.3576323fe7e587bd7c5e.js → main.f1780db6593b0c07a48c.js} +2 -2
- package/public/{main.3576323fe7e587bd7c5e.js.map → main.f1780db6593b0c07a48c.js.map} +1 -1
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/11.413e152fbef6e7dfd2f8.js +0 -2
- package/public/11.413e152fbef6e7dfd2f8.js.map +0 -1
- package/public/277.509a7bfc11fac344f433.js.map +0 -1
- package/schemas/config.schema.json +0 -104
- /package/public/{277.509a7bfc11fac344f433.js.LICENSE.txt → 277.99e19dcb5b778c964ace.js.LICENSE.txt} +0 -0
package/README.md
CHANGED
|
@@ -173,7 +173,9 @@ For complete setting definitions and ranges, use `docs/configuration-reference.m
|
|
|
173
173
|
|
|
174
174
|
Schema and migration helpers:
|
|
175
175
|
|
|
176
|
-
- `
|
|
176
|
+
- The runtime schema is defined inline as `plugin.schema` in `src/index.ts`
|
|
177
|
+
and served to the Signal K admin UI. `docs/configuration-reference.md` is
|
|
178
|
+
the authoritative human-readable reference.
|
|
177
179
|
- `src/scripts/migrate-config.ts` (convert legacy flat config to `connections[]`)
|
|
178
180
|
- `npm run migrate:config -- <input.json> [output.json]`
|
|
179
181
|
|
|
@@ -273,7 +275,6 @@ window.__EDGE_LINK_AUTH__ = {
|
|
|
273
275
|
- `docs/performance-tuning.md` (deployment tuning recommendations by hardware profile)
|
|
274
276
|
- `samples/` (example JSON configurations for minimal/dev/v2-bonding setups)
|
|
275
277
|
- `grafana/dashboards/edge-link.json` (starter Grafana dashboard)
|
|
276
|
-
- `schemas/config.schema.json` (unified plugin config schema)
|
|
277
278
|
- `src/scripts/migrate-config.ts` (legacy config migration utility)
|
|
278
279
|
- `src/bin/edge-link-cli.ts` (CLI wrapper for migration and instance/bonding management)
|
|
279
280
|
|
package/lib/bonding.js
CHANGED
|
@@ -135,6 +135,7 @@ class BondingManager {
|
|
|
135
135
|
: "signalk-edge-link";
|
|
136
136
|
this.notificationsEnabled = config.notificationsEnabled === true;
|
|
137
137
|
this.secretKey = config.secretKey || null;
|
|
138
|
+
this.stretchAsciiKey = !!config.stretchAsciiKey;
|
|
138
139
|
// Link definitions
|
|
139
140
|
this.links = {
|
|
140
141
|
primary: _createLinkState("primary", config.primary),
|
|
@@ -247,7 +248,9 @@ class BondingManager {
|
|
|
247
248
|
if (!this.secretKey) {
|
|
248
249
|
throw new Error("BondingManager: secretKey is required for HMAC authentication");
|
|
249
250
|
}
|
|
250
|
-
const keyBuffer = (0, crypto_1.normalizeKey)(this.secretKey
|
|
251
|
+
const keyBuffer = (0, crypto_1.normalizeKey)(this.secretKey, {
|
|
252
|
+
stretchAsciiKey: this.stretchAsciiKey
|
|
253
|
+
});
|
|
251
254
|
return crypto
|
|
252
255
|
.createHmac("sha256", keyBuffer)
|
|
253
256
|
.update(header)
|
package/lib/connection-config.js
CHANGED
|
@@ -14,6 +14,7 @@ exports.VALID_CONNECTION_KEYS = [
|
|
|
14
14
|
"serverType",
|
|
15
15
|
"udpPort",
|
|
16
16
|
"secretKey",
|
|
17
|
+
"stretchAsciiKey",
|
|
17
18
|
"useMsgpack",
|
|
18
19
|
"usePathDictionary",
|
|
19
20
|
"enableNotifications",
|
|
@@ -96,6 +97,9 @@ function validateConnectionConfig(connection, prefix = "") {
|
|
|
96
97
|
if (conn.usePathDictionary !== undefined && typeof conn.usePathDictionary !== "boolean") {
|
|
97
98
|
return `${p}usePathDictionary must be a boolean`;
|
|
98
99
|
}
|
|
100
|
+
if (conn.stretchAsciiKey !== undefined && typeof conn.stretchAsciiKey !== "boolean") {
|
|
101
|
+
return `${p}stretchAsciiKey must be a boolean`;
|
|
102
|
+
}
|
|
99
103
|
if (conn.enableNotifications !== undefined && typeof conn.enableNotifications !== "boolean") {
|
|
100
104
|
return `${p}enableNotifications must be a boolean`;
|
|
101
105
|
}
|
package/lib/crypto.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.validateSecretKey = validateSecretKey;
|
|
|
40
40
|
exports.createControlPacketAuthTag = createControlPacketAuthTag;
|
|
41
41
|
exports.verifyControlPacketAuthTag = verifyControlPacketAuthTag;
|
|
42
42
|
const crypto = __importStar(require("crypto"));
|
|
43
|
+
const crypto_constants_1 = require("./shared/crypto-constants");
|
|
43
44
|
// Use AES-256-GCM for authenticated encryption (encryption + authentication in one)
|
|
44
45
|
const ALGORITHM = "aes-256-gcm";
|
|
45
46
|
exports.IV_LENGTH = 12; // GCM standard IV length
|
|
@@ -58,10 +59,10 @@ exports.CONTROL_AUTH_TAG_LENGTH = 16; // Truncated HMAC-SHA256 tag for v3 contro
|
|
|
58
59
|
*
|
|
59
60
|
* @param passphrase - Human-chosen password of any length
|
|
60
61
|
* @param salt - Application-specific salt (defaults to "signalk-edge-link-v1")
|
|
61
|
-
* @param iterations - PBKDF2 iteration count (defaults to
|
|
62
|
+
* @param iterations - PBKDF2 iteration count (defaults to `PBKDF2_ITERATIONS`, NIST SP 800-132)
|
|
62
63
|
* @returns 32-byte derived key buffer
|
|
63
64
|
*/
|
|
64
|
-
function deriveKeyFromPassphrase(passphrase, salt = "signalk-edge-link-v1", iterations =
|
|
65
|
+
function deriveKeyFromPassphrase(passphrase, salt = "signalk-edge-link-v1", iterations = crypto_constants_1.PBKDF2_ITERATIONS) {
|
|
65
66
|
if (!passphrase || typeof passphrase !== "string") {
|
|
66
67
|
throw new Error("Passphrase must be a non-empty string");
|
|
67
68
|
}
|
|
@@ -73,19 +74,21 @@ function deriveKeyFromPassphrase(passphrase, salt = "signalk-edge-link-v1", iter
|
|
|
73
74
|
* Accepts three formats:
|
|
74
75
|
* - 64-character hex string → decoded to 32 bytes (full 256-bit entropy)
|
|
75
76
|
* - 44-character base64 string → decoded to 32 bytes (full 256-bit entropy)
|
|
76
|
-
* - 32-character ASCII string → used
|
|
77
|
+
* - 32-character ASCII string → used raw by default, OR stretched via
|
|
78
|
+
* PBKDF2-SHA256 (see {@link PBKDF2_ITERATIONS}, salt "signalk-edge-link-v1")
|
|
79
|
+
* when `options.stretchAsciiKey` is `true`. PBKDF2 lifts the ~208-bit
|
|
80
|
+
* effective entropy of a human-typeable 32-char key to a full 256-bit AES
|
|
81
|
+
* key.
|
|
77
82
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* use `deriveKeyFromPassphrase()` instead, which runs PBKDF2 to achieve full
|
|
81
|
-
* 256-bit security. For new deployments prefer randomly generated 64-char hex
|
|
82
|
-
* or 44-char base64 keys which carry full entropy without key derivation.
|
|
83
|
+
* Both ends of a connection must use the same key string and the same
|
|
84
|
+
* `stretchAsciiKey` setting for the derived 32-byte buffers to match.
|
|
83
85
|
*
|
|
84
86
|
* @param secretKey - Secret key in any supported format
|
|
87
|
+
* @param options - Key normalization options (optional)
|
|
85
88
|
* @returns 32-byte key buffer
|
|
86
89
|
* @throws Error if key cannot be normalized to exactly 32 bytes
|
|
87
90
|
*/
|
|
88
|
-
function normalizeKey(secretKey) {
|
|
91
|
+
function normalizeKey(secretKey, options = {}) {
|
|
89
92
|
if (!secretKey || typeof secretKey !== "string") {
|
|
90
93
|
throw new Error("Secret key must be a non-empty string");
|
|
91
94
|
}
|
|
@@ -105,25 +108,46 @@ function normalizeKey(secretKey) {
|
|
|
105
108
|
throw new Error(`Base64 key decoded to ${buf.length} bytes; expected 32. ` +
|
|
106
109
|
"Ensure the key is a standard base64 string encoding exactly 32 bytes.");
|
|
107
110
|
}
|
|
108
|
-
// Fallback: raw ASCII — must be exactly 32 bytes
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
+
// Fallback: raw ASCII — must be exactly 32 bytes. Default is to use the
|
|
112
|
+
// bytes as-is for backwards compatibility; callers that set
|
|
113
|
+
// `stretchAsciiKey: true` get PBKDF2-SHA256 stretching so a human-typed
|
|
114
|
+
// 32-char key (~6.5 bits/char ≈ 208 bits) reaches the full 256-bit AES
|
|
115
|
+
// strength. Both ends must use the same setting.
|
|
111
116
|
if (Buffer.byteLength(secretKey) === 32) {
|
|
117
|
+
if (options.stretchAsciiKey) {
|
|
118
|
+
return getOrDeriveAsciiKey(secretKey);
|
|
119
|
+
}
|
|
112
120
|
return Buffer.from(secretKey);
|
|
113
121
|
}
|
|
114
122
|
throw new Error("Secret key must be exactly 32 bytes: use a 32-character ASCII string, " +
|
|
115
123
|
"64-character hex string, or 44-character base64 string");
|
|
116
124
|
}
|
|
125
|
+
// Per-process PBKDF2 cache. Without this cache every encryption /
|
|
126
|
+
// decryption call would re-run the full iteration count, which would
|
|
127
|
+
// dominate the per-packet cost. The cache key is the raw ASCII string so
|
|
128
|
+
// it is effectively bounded by the number of distinct configured keys
|
|
129
|
+
// (typically one or two per Signal K instance).
|
|
130
|
+
const asciiKeyCache = new Map();
|
|
131
|
+
function getOrDeriveAsciiKey(asciiKey) {
|
|
132
|
+
const cached = asciiKeyCache.get(asciiKey);
|
|
133
|
+
if (cached) {
|
|
134
|
+
return cached;
|
|
135
|
+
}
|
|
136
|
+
const derived = deriveKeyFromPassphrase(asciiKey);
|
|
137
|
+
asciiKeyCache.set(asciiKey, derived);
|
|
138
|
+
return derived;
|
|
139
|
+
}
|
|
117
140
|
/**
|
|
118
141
|
* Encrypts data using AES-256-GCM with binary output
|
|
119
142
|
* Binary format: [IV (12 bytes)][Encrypted Data][Auth Tag (16 bytes)]
|
|
120
143
|
* @param data - Data to encrypt
|
|
121
144
|
* @param secretKey - Secret key (32-char ASCII, 64-char hex, or 44-char base64)
|
|
145
|
+
* @param options - Key normalization options (e.g. stretchAsciiKey)
|
|
122
146
|
* @returns Binary packet with IV, encrypted data, and auth tag
|
|
123
147
|
* @throws Error if secretKey is invalid or data is empty
|
|
124
148
|
*/
|
|
125
|
-
const encryptBinary = (data, secretKey) => {
|
|
126
|
-
const keyBuffer = normalizeKey(secretKey);
|
|
149
|
+
const encryptBinary = (data, secretKey, options = {}) => {
|
|
150
|
+
const keyBuffer = normalizeKey(secretKey, options);
|
|
127
151
|
if (!data || (Buffer.isBuffer(data) && data.length === 0)) {
|
|
128
152
|
throw new Error("Data to encrypt cannot be empty");
|
|
129
153
|
}
|
|
@@ -142,11 +166,13 @@ exports.encryptBinary = encryptBinary;
|
|
|
142
166
|
* Decrypts data encrypted with AES-256-GCM
|
|
143
167
|
* @param packet - Binary packet with IV, encrypted data, and auth tag
|
|
144
168
|
* @param secretKey - Secret key (32-char ASCII, 64-char hex, or 44-char base64)
|
|
169
|
+
* @param options - Key normalization options (e.g. stretchAsciiKey); must
|
|
170
|
+
* match what the sender used or authentication will fail
|
|
145
171
|
* @returns Decrypted data as Buffer
|
|
146
172
|
* @throws Error if secretKey or packet is invalid, or authentication fails
|
|
147
173
|
*/
|
|
148
|
-
const decryptBinary = (packet, secretKey) => {
|
|
149
|
-
const keyBuffer = normalizeKey(secretKey);
|
|
174
|
+
const decryptBinary = (packet, secretKey, options = {}) => {
|
|
175
|
+
const keyBuffer = normalizeKey(secretKey, options);
|
|
150
176
|
if (!Buffer.isBuffer(packet) || packet.length <= exports.IV_LENGTH + exports.AUTH_TAG_LENGTH) {
|
|
151
177
|
throw new Error("Invalid packet size");
|
|
152
178
|
}
|
|
@@ -235,13 +261,14 @@ function validateSecretKey(key) {
|
|
|
235
261
|
* @param headerData - Header bytes 0..12
|
|
236
262
|
* @param payload - Control payload without trailing auth tag
|
|
237
263
|
* @param secretKey - Secret key in any supported format
|
|
264
|
+
* @param options - Key normalization options (e.g. stretchAsciiKey)
|
|
238
265
|
* @returns Truncated HMAC tag
|
|
239
266
|
*/
|
|
240
|
-
function createControlPacketAuthTag(headerData, payload, secretKey) {
|
|
267
|
+
function createControlPacketAuthTag(headerData, payload, secretKey, options = {}) {
|
|
241
268
|
if (!Buffer.isBuffer(headerData)) {
|
|
242
269
|
throw new Error("Control packet header must be a Buffer");
|
|
243
270
|
}
|
|
244
|
-
const keyBuffer = normalizeKey(secretKey);
|
|
271
|
+
const keyBuffer = normalizeKey(secretKey, options);
|
|
245
272
|
const payloadBuffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || "");
|
|
246
273
|
const hmac = crypto.createHmac("sha256", keyBuffer);
|
|
247
274
|
hmac.update(headerData);
|
|
@@ -257,13 +284,15 @@ function createControlPacketAuthTag(headerData, payload, secretKey) {
|
|
|
257
284
|
* @param payload - Control payload without trailing auth tag
|
|
258
285
|
* @param authTag - Trailing auth tag from the packet
|
|
259
286
|
* @param secretKey - Secret key in any supported format
|
|
287
|
+
* @param options - Key normalization options (e.g. stretchAsciiKey); must
|
|
288
|
+
* match what the sender used or verification will fail
|
|
260
289
|
* @returns True when authentication succeeds
|
|
261
290
|
*/
|
|
262
|
-
function verifyControlPacketAuthTag(headerData, payload, authTag, secretKey) {
|
|
291
|
+
function verifyControlPacketAuthTag(headerData, payload, authTag, secretKey, options = {}) {
|
|
263
292
|
if (!Buffer.isBuffer(authTag) || authTag.length !== exports.CONTROL_AUTH_TAG_LENGTH) {
|
|
264
293
|
throw new Error("Control packet authentication tag missing");
|
|
265
294
|
}
|
|
266
|
-
const expected = createControlPacketAuthTag(headerData, payload, secretKey);
|
|
295
|
+
const expected = createControlPacketAuthTag(headerData, payload, secretKey, options);
|
|
267
296
|
if (!crypto.timingSafeEqual(expected, authTag)) {
|
|
268
297
|
throw new Error("Control packet authentication failed");
|
|
269
298
|
}
|