Skip to content

Commit 25d9dbd

Browse files
authored
fix(e2ee): verify HMAC in decryptByKeyMaterial (tampered media was silently accepted) (#224)
Co-authored-by: nezumi0627 <nezumi0627@users.noreply.github.com>
1 parent a3d78bc commit 25d9dbd

2 files changed

Lines changed: 95 additions & 4 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { assert, assertEquals, assertRejects } from "@std/assert";
2+
import { Buffer } from "node:buffer";
3+
import { E2EE } from "./mod.ts";
4+
import { InternalError } from "../core/utils/error.ts";
5+
6+
// `encryptByKeyMaterial` appends an HMAC-SHA256 of the ciphertext, but
7+
// `decryptByKeyMaterial` silently discarded those 32 bytes without ever
8+
// comparing them — tampered/corrupted media decrypted to garbage without
9+
// error.
10+
function makeE2EE() {
11+
return new E2EE({
12+
profile: { mid: "u-self" },
13+
storage: {
14+
get() {
15+
return Promise.resolve(null);
16+
},
17+
set() {
18+
return Promise.resolve();
19+
},
20+
},
21+
getToType() {
22+
return 0;
23+
},
24+
log() {},
25+
} as never);
26+
}
27+
28+
Deno.test("decryptByKeyMaterial round-trips and verifies the HMAC", async () => {
29+
const e2ee = makeE2EE();
30+
const raw = Buffer.from("secret media payload");
31+
32+
const { keyMaterial, encryptedData } = await e2ee.encryptByKeyMaterial(raw);
33+
assertEquals(encryptedData.length, raw.length + 32);
34+
35+
const decrypted = await e2ee.decryptByKeyMaterial(
36+
encryptedData,
37+
keyMaterial,
38+
);
39+
assertEquals(decrypted.toString(), "secret media payload");
40+
});
41+
42+
Deno.test("decryptByKeyMaterial rejects a tampered ciphertext", async () => {
43+
const e2ee = makeE2EE();
44+
const { keyMaterial, encryptedData } = await e2ee.encryptByKeyMaterial(
45+
Buffer.from("secret media payload"),
46+
);
47+
48+
const tampered = Buffer.from(encryptedData);
49+
tampered[0] ^= 0x01;
50+
await assertRejects(
51+
() => e2ee.decryptByKeyMaterial(tampered, keyMaterial),
52+
InternalError as never,
53+
"HMAC verification failed",
54+
);
55+
});
56+
57+
Deno.test("decryptByKeyMaterial rejects a wrong keyMaterial", async () => {
58+
const e2ee = makeE2EE();
59+
const { encryptedData } = await e2ee.encryptByKeyMaterial(
60+
Buffer.from("secret media payload"),
61+
);
62+
63+
await assertRejects(
64+
() => e2ee.decryptByKeyMaterial(encryptedData, Buffer.alloc(32, 7)),
65+
InternalError as never,
66+
"HMAC verification failed",
67+
);
68+
});
69+
70+
Deno.test("decryptByKeyMaterial rejects truncated input", async () => {
71+
const e2ee = makeE2EE();
72+
await assertRejects(
73+
() => e2ee.decryptByKeyMaterial(Buffer.alloc(10), Buffer.alloc(32)),
74+
InternalError as never,
75+
"too short",
76+
);
77+
assert(true);
78+
});

‎packages/linejs/base/e2ee/mod.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,11 +1346,24 @@ export class E2EE {
13461346
keyMaterial = Buffer.from(keyMaterial, "base64");
13471347
}
13481348
const keys = await this.deriveKeyMaterial(keyMaterial);
1349-
return (await this.___decryptAESCTR(keys.encKey, keys.nonce, rawData))
1350-
.slice(
1351-
0,
1352-
-32,
1349+
if (rawData.length < 32) {
1350+
throw new InternalError(
1351+
"E2EEMediaError",
1352+
`encrypted data too short (${rawData.length} bytes) to contain HMAC`,
13531353
);
1354+
}
1355+
const encData = rawData.subarray(0, -32);
1356+
const sign = rawData.subarray(-32);
1357+
const expected = this.signData(encData, keys.macKey);
1358+
if (
1359+
!crypto.timingSafeEqual(sign, expected)
1360+
) {
1361+
throw new InternalError(
1362+
"E2EEMediaError",
1363+
"HMAC verification failed: ciphertext was tampered with or keyMaterial is wrong",
1364+
);
1365+
}
1366+
return await this.___decryptAESCTR(keys.encKey, keys.nonce, encData);
13541367
}
13551368

13561369
/**

0 commit comments

Comments
 (0)