Skip to content

Commit 0c8e850

Browse files
authored
Merge pull request #24 from athombv/fix/encryption
Fix/encryption
2 parents 106e7fc + 6f7944b commit 0c8e850

17 files changed

+873
-109
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
/node_modules
2-
/dist
2+
/dist
3+
/private

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
"prettier": "prettier . --write",
1919
"prettier:check": "prettier . --check",
2020
"tool:parse-telegram": "tsx ./tools/parse-telegram.ts",
21-
"tool:update-test-telegrams": "tsx ./tools/update-test-telegrams.ts"
21+
"tool:update-test-telegrams": "tsx ./tools/update-test-telegrams.ts",
22+
"tool:decrypt-telegram": "tsx ./tools/decrypt-telegram.ts",
23+
"tool:encrypt-telegram": "tsx ./tools/encrypt-telegram.ts"
2224
},
2325
"repository": {
2426
"type": "git",

src/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DSMRParser } from './parsers/dsmr.js';
22
import { getMbusDevice, MBUS_DEVICE_IDS } from './parsers/mbus.js';
33
import { createDSMRStreamParser, createDSMRStreamTransformer } from './parsers/stream.js';
4+
import { ENCRYPTION_DEFAULT_AAD } from './util/encryption.js';
45
import { DSMRFrameValid } from './util/frame-validation.js';
56

67
export type DSMRParserOptions =
@@ -11,14 +12,17 @@ export type DSMRParserOptions =
1112
newLineChars?: '\r\n' | '\n';
1213
/** Enable the encryption detection mechanism. Enabled by default */
1314
decryptionKey?: never;
15+
additionalAuthenticatedData?: never;
1416
encoding?: never;
1517
}
1618
| {
1719
/** Encrypted DSMR telegram */
1820
telegram: Buffer;
1921
/** Decryption key */
20-
decryptionKey?: string;
21-
/** Encoding of the data in the buffer, defaults to ascii */
22+
decryptionKey?: Buffer;
23+
/** AAD */
24+
additionalAuthenticatedData?: Buffer;
25+
/** Encoding of the data in the buffer, defaults to binary */
2226
encoding?: BufferEncoding;
2327
/** New line characters */
2428
newLineChars?: '\r\n' | '\n';
@@ -98,6 +102,8 @@ export type DSMRParserResult = {
98102
value: number;
99103
valid: boolean;
100104
};
105+
/** Only set when encryption is used */
106+
additionalAuthenticatedDataValid?: boolean;
101107
};
102108

103109
export * from './util/errors.js';
@@ -109,4 +115,5 @@ export const DSMR = {
109115
isValidFrame: DSMRFrameValid,
110116
MBUS_DEVICE_IDS,
111117
getMbusDevice,
118+
ENCRYPTION_DEFAULT_AAD,
112119
} as const;

src/parsers/dsmr.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,22 @@ export const DSMRParser = (options: DSMRParserOptions): DSMRParserResult => {
4646
options.newLineChars = options.newLineChars ?? '\r\n';
4747

4848
let telegram: string;
49+
let decryptError: Error | undefined;
4950

5051
if (typeof options.telegram === 'string') {
5152
telegram = options.telegram;
52-
} else if (typeof options.decryptionKey !== 'string') {
53+
} else if (!Buffer.isBuffer(options.decryptionKey)) {
5354
telegram = options.telegram.toString(options.encoding ?? DEFAULT_FRAME_ENCODING);
5455
} else {
55-
telegram = decryptFrame({
56+
const { content, error } = decryptFrame({
5657
data: options.telegram,
5758
key: options.decryptionKey,
59+
additionalAuthenticatedData: options.additionalAuthenticatedData,
5860
encoding: options.encoding ?? DEFAULT_FRAME_ENCODING,
5961
});
62+
63+
telegram = content;
64+
decryptError = error;
6065
}
6166

6267
const lines = telegram.split(options.newLineChars);
@@ -119,6 +124,12 @@ export const DSMRParser = (options: DSMRParserOptions): DSMRParserResult => {
119124
}
120125

121126
if (objectsParsed === 0) {
127+
// If we're unable to parse the data and we have a decryption error,
128+
// the error is probably in the decryption.
129+
if (decryptError) {
130+
throw decryptError;
131+
}
132+
122133
throw new DSMRParserError('Invalid telegram. No COSEM objects found.');
123134
}
124135

src/parsers/stream-encrypted.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,35 +99,52 @@ export class EncryptedDSMRStreamParser implements DSMRStreamParser {
9999
// Wait for more data to decode the header
100100
if (!this.header) return;
101101

102-
const totalLength = this.header.contentLength + ENCRYPTED_DSMR_GCM_TAG_LEN;
102+
const totalLength =
103+
ENCRYPTED_DSMR_HEADER_LEN + this.header.contentLength + ENCRYPTED_DSMR_GCM_TAG_LEN;
103104

104105
// Wait until full telegram is received
105106
if (this.telegram.length < totalLength) return;
106107

107108
clearTimeout(this.fullFrameRequiredTimeout);
108109

110+
let decryptError: Error | undefined;
111+
109112
try {
110-
const content = this.telegram.subarray(ENCRYPTED_DSMR_HEADER_LEN, this.header.contentLength);
113+
const encryptedContent = this.telegram.subarray(
114+
ENCRYPTED_DSMR_HEADER_LEN,
115+
ENCRYPTED_DSMR_HEADER_LEN + this.header.contentLength,
116+
);
111117
const footer = decodeFooter(this.telegram, this.header);
112-
const decrypted = decryptFrameContents({
113-
data: content,
118+
119+
const { content, error } = decryptFrameContents({
120+
data: encryptedContent,
114121
header: this.header,
115122
footer,
116-
key: this.options.decryptionKey ?? '',
123+
key: this.options.decryptionKey ?? Buffer.alloc(0),
124+
additionalAuthenticatedData: this.options.additionalAuthenticatedData,
117125
encoding: this.options.encoding ?? DEFAULT_FRAME_ENCODING,
118126
});
127+
128+
decryptError = error;
129+
119130
const result = DSMRParser({
120-
telegram: decrypted,
131+
telegram: content,
121132
newLineChars: this.options.newLineChars,
122133
});
123134

135+
result.additionalAuthenticatedDataValid = decryptError === undefined;
136+
124137
this.options.callback(null, result);
125138
} catch (error) {
126-
if (error instanceof DSMRError) {
127-
error.withRawTelegram(this.telegram);
139+
// If we had a decryption error that is the cause of the error.
140+
// So that should be returned to the listener.
141+
const realError = decryptError ?? error;
142+
143+
if (realError instanceof DSMRError) {
144+
realError.withRawTelegram(this.telegram);
128145
}
129146

130-
this.options.callback(error, undefined);
147+
this.options.callback(realError, undefined);
131148
}
132149

133150
const remainingData = this.telegram.subarray(totalLength, this.telegram.length);

src/util/encryption.ts

Lines changed: 106 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import { DSMRDecodeError, DSMRDecryptionError } from './errors.js';
55
* For now this is specific to the luxembourg's smart metering system. (E-Meter P1 Specification)
66
* They wrap a DSMR telegram in a custom frame with the following format:
77
*
8-
* | Byte | Description | Example |
9-
* | ------ | ------------------- | ----------------------------------- |
10-
* | 0 | SOF | DB (fixed) |
11-
* | 1 | System Title Length | 08 (fixed) |
12-
* | 2-9 | System Title | 00 11 22 33 44 55 66 77 |
13-
* | 10-11 | Length of the frame | 00 11 |
14-
* | 12 | SOF Frame Counter | 30 |
15-
* | 13-16 | Frame Counter | 00 11 22 33 |
16-
* | 17-n | Frame | <Encrypted DSMR frame> |
17-
* | n-n+12 | GCM Tag | 00 11 22 33 44 55 66 77 88 99 AA BB |
8+
* | Byte | Description | Example |
9+
* | ------ | -------------------- | ----------------------------------- |
10+
* | 0 | SOF | DB (fixed) |
11+
* | 1 | System Title Length | 08 (fixed) |
12+
* | 2-9 | System Title | 00 11 22 33 44 55 66 77 |
13+
* | 10 | Content Length Start | 82 (fixed) |
14+
* | 11-12 | Length of the frame | 00 11 |
15+
* | 13 | SOF Frame Counter | 30 (fixed) |
16+
* | 14-17 | Frame Counter | 00 11 22 33 |
17+
* | 18-n | Frame | <Encrypted DSMR frame> |
18+
* | n-n+12 | GCM Tag | 00 11 22 33 44 55 66 77 88 99 AA BB |
1819
*
1920
* The encrypted DSMR frame is encrypted using AES-128-GCM, and the user can request the encryption
2021
* key from the utility company. The IV is the concatenation of the system title and the frame
@@ -24,10 +25,13 @@ import { DSMRDecodeError, DSMRDecryptionError } from './errors.js';
2425
* excluded.
2526
*/
2627

27-
export const ENCRYPTED_DSMR_TELEGRAM_SOF = 0xdb;
28+
export const ENCRYPTED_DSMR_TELEGRAM_SOF = 0xdb; // DLMS_COMMAND_GENERAL_GLO_CIPHERING
29+
export const ENCRYPTED_DSMR_CONTENT_LENGTH_START = 0x82; // DLMS type for uint16_t (Big Endian)
30+
export const ENCRYPTED_DSMR_SECURITY_TYPE = 0x30; // DLMS_SECURITY_AUTHENTICATION_ENCRYPTION
2831
export const ENCRYPTED_DSMR_SYSTEM_TITLE_LEN = 8;
2932
export const ENCRYPTED_DSMR_GCM_TAG_LEN = 12;
30-
export const ENCRYPTED_DSMR_HEADER_LEN = 17;
33+
export const ENCRYPTED_DSMR_HEADER_LEN = 18;
34+
export const ENCRYPTION_DEFAULT_AAD = Buffer.from('00112233445566778899aabbccddeeff', 'hex');
3135

3236
/**
3337
* @param data A buffer that starts with the header (bytes 0-16) of the E-Meter P1 frame
@@ -40,29 +44,43 @@ export const decodeHeader = (data: Buffer) => {
4044

4145
let index = 0;
4246

43-
if (data[index++] !== ENCRYPTED_DSMR_TELEGRAM_SOF) {
44-
throw new DSMRDecodeError('Invalid telegram sof');
47+
const sof = data[index++];
48+
if (sof !== ENCRYPTED_DSMR_TELEGRAM_SOF) {
49+
throw new DSMRDecodeError(`Invalid telegram sof 0x${sof.toString(16)}`);
4550
}
4651

47-
if (data[index++] !== ENCRYPTED_DSMR_SYSTEM_TITLE_LEN) {
48-
throw new DSMRDecodeError('Invalid system title length');
52+
const systemTitleLen = data[index++];
53+
if (systemTitleLen !== ENCRYPTED_DSMR_SYSTEM_TITLE_LEN) {
54+
throw new DSMRDecodeError(`Invalid system title length 0x${systemTitleLen.toString(16)}`);
4955
}
5056

5157
const systemTitle = data.subarray(index, index + ENCRYPTED_DSMR_SYSTEM_TITLE_LEN);
5258
index += ENCRYPTED_DSMR_SYSTEM_TITLE_LEN;
53-
const contentLength = data.readUInt16LE(index);
59+
60+
const contentLengthStart = data[index++];
61+
if (contentLengthStart !== ENCRYPTED_DSMR_CONTENT_LENGTH_START) {
62+
throw new DSMRDecodeError(
63+
`Invalid content length start byte 0x${contentLengthStart.toString(16)}`,
64+
);
65+
}
66+
67+
// The entire header is 18 bytes long, but for some reason the content length uses 17 as
68+
// length for the header. Maybe they don't include the SOF byte?
69+
const contentLength = data.readUInt16BE(index) + 1 - ENCRYPTED_DSMR_HEADER_LEN;
5470
index += 2;
5571

56-
// According to the documentation, this should be 0x30, but it often is not.
57-
const sofFrameCounter = data[index++];
72+
const securityType = data[index++];
73+
if (securityType !== ENCRYPTED_DSMR_SECURITY_TYPE) {
74+
throw new DSMRDecodeError(`Invalid frame counter 0x${securityType.toString(16)}`);
75+
}
5876

5977
const frameCounter = data.subarray(index, index + 4);
6078
index += 4;
6179

6280
return {
6381
systemTitle,
6482
frameCounter,
65-
sofFrameCounter,
83+
securityType,
6684
contentLength,
6785
};
6886
};
@@ -77,7 +95,10 @@ export const decodeFooter = (data: Buffer, header: ReturnType<typeof decodeHeade
7795
}
7896

7997
return {
80-
gcmTag: data.subarray(header.contentLength, header.contentLength + ENCRYPTED_DSMR_GCM_TAG_LEN),
98+
gcmTag: data.subarray(
99+
ENCRYPTED_DSMR_HEADER_LEN + header.contentLength,
100+
ENCRYPTED_DSMR_HEADER_LEN + header.contentLength + ENCRYPTED_DSMR_GCM_TAG_LEN,
101+
),
81102
};
82103
};
83104

@@ -88,6 +109,7 @@ export const decryptFrameContents = ({
88109
footer,
89110
key,
90111
encoding,
112+
additionalAuthenticatedData,
91113
}: {
92114
/** The encrypted DSMR frame */
93115
data: Buffer;
@@ -96,42 +118,93 @@ export const decryptFrameContents = ({
96118
/** The decoded footer (use {@link decodeFooter}) */
97119
footer: ReturnType<typeof decodeFooter>;
98120
/** The encryption key */
99-
key: string;
121+
key: Buffer;
100122
encoding: BufferEncoding;
123+
/** Optional additional authenticated data (AAD) to be used in the decryption. */
124+
additionalAuthenticatedData?: Buffer;
101125
}) => {
102-
if (data.length !== header.contentLength - ENCRYPTED_DSMR_HEADER_LEN) {
103-
throw new Error(
104-
`Invalid frame length got ${data.length} expected ${header.contentLength - ENCRYPTED_DSMR_HEADER_LEN}`,
105-
);
126+
if (data.length !== header.contentLength) {
127+
throw new Error(`Invalid frame length got ${data.length} expected ${header.contentLength}`);
128+
}
129+
130+
if (additionalAuthenticatedData?.length == 16) {
131+
additionalAuthenticatedData = Buffer.concat([Buffer.from([0x30]), additionalAuthenticatedData]);
106132
}
107133

108134
const iv = Buffer.concat([header.systemTitle, header.frameCounter]);
135+
let cipher: crypto.DecipherGCM;
136+
let content = '';
109137

110-
// Wrap in try-catch to throw a DSMRDecryptionError instead of a generic error.
138+
// 1: decrypt the frame, this will only throw if the key, iv or AAD are not
139+
// correct due to their format. `cipher.update` will never throw, but if the key/iv/aad
140+
// are not valid it may return gibberish.
111141
try {
112-
const cipher = crypto.createDecipheriv('aes-128-gcm', key, iv, {
142+
cipher = crypto.createDecipheriv('aes-128-gcm', key, iv, {
113143
authTagLength: ENCRYPTED_DSMR_GCM_TAG_LEN,
114144
});
145+
cipher.setAutoPadding(false);
115146
cipher.setAuthTag(footer.gcmTag);
116147

117-
return cipher.update(data, undefined, encoding) + cipher.final(encoding);
148+
if (additionalAuthenticatedData) {
149+
cipher.setAAD(additionalAuthenticatedData);
150+
}
151+
152+
content += cipher.update(data, undefined, encoding);
118153
} catch (error) {
119-
throw new DSMRDecryptionError(error);
154+
return {
155+
content,
156+
error: new DSMRDecryptionError(error),
157+
};
120158
}
159+
160+
// 2: call final on the frame. This will check the AAD/iv/key.
161+
// When either of these are invalid, it will throw an "Unsupported state or unable to authenticate data" error.
162+
// If the AAD is invalid, but the key/iv are valid the content can still be a valid DSMR frame!
163+
try {
164+
content += cipher.final(encoding);
165+
} catch (error) {
166+
return {
167+
content,
168+
error: new DSMRDecryptionError(error),
169+
};
170+
}
171+
172+
return {
173+
content,
174+
};
121175
};
122176

123177
/** Decrypts a full encrypted DSMR frame */
124178
export const decryptFrame = ({
125179
data,
126180
key,
127181
encoding,
182+
additionalAuthenticatedData,
128183
}: {
129184
data: Buffer;
130-
key: string;
185+
key: Buffer;
186+
additionalAuthenticatedData?: Buffer;
131187
encoding: BufferEncoding;
132188
}) => {
133189
const header = decodeHeader(data);
134190
const footer = decodeFooter(data, header);
135-
const content = data.subarray(ENCRYPTED_DSMR_HEADER_LEN, header.contentLength);
136-
return decryptFrameContents({ data: content, header, footer, key, encoding });
191+
const encryptedContent = data.subarray(
192+
ENCRYPTED_DSMR_HEADER_LEN,
193+
ENCRYPTED_DSMR_HEADER_LEN + header.contentLength,
194+
);
195+
const { content, error } = decryptFrameContents({
196+
data: encryptedContent,
197+
header,
198+
footer,
199+
key,
200+
additionalAuthenticatedData,
201+
encoding,
202+
});
203+
204+
return {
205+
header,
206+
footer,
207+
content,
208+
error,
209+
};
137210
};

0 commit comments

Comments
 (0)