signalk-edge-link 2.1.0 → 2.2.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 Karl-Erik Gustafsson
3
+ Copyright (c) 2024-2026 Karl-Erik Gustafsson
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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
- - `schemas/config.schema.json` (machine-readable config schema)
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)
@@ -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
  }
@@ -235,6 +239,20 @@ function validateConnectionConfig(connection, prefix = "") {
235
239
  }
236
240
  }
237
241
  }
242
+ // Validate that primary and backup links are different
243
+ const primaryLink = bonding.primary;
244
+ const backupLink = bonding.backup;
245
+ if (primaryLink && backupLink) {
246
+ const sameAddress = primaryLink.address !== undefined &&
247
+ backupLink.address !== undefined &&
248
+ primaryLink.address === backupLink.address;
249
+ const samePort = primaryLink.port !== undefined &&
250
+ backupLink.port !== undefined &&
251
+ primaryLink.port === backupLink.port;
252
+ if (sameAddress && samePort) {
253
+ return `${p}bonding primary and backup links must use different address:port combinations`;
254
+ }
255
+ }
238
256
  if (bonding.failover !== undefined) {
239
257
  if (!bonding.failover ||
240
258
  typeof bonding.failover !== "object" ||
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 600_000, NIST SP 800-132)
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 = 600000) {
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 as-is (~208 bits effective entropy)
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
- * **Note on ASCII keys:** A 32-character ASCII string provides approximately
79
- * 208 bits of effective entropy (~6.5 bits/char). For human-chosen passwords
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
- // NOTE: ASCII keys provide ~6.5 bits/char 208 bits effective entropy.
110
- // Prefer hex or base64 keys for full 256-bit strength.
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
  }