Skip to content

Commit a21c30e

Browse files
author
SmartHardware
committed
Decode current HomeBase media streams
1 parent bc2af35 commit a21c30e

10 files changed

Lines changed: 215 additions & 21 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ jobs:
104104
- name: Install exact dependencies
105105
run: |
106106
npm ci
107-
npm install --no-save /tmp/eufy-security-client-4.1.1-mega.40.tgz
107+
npm install --no-save /tmp/eufy-security-client-4.1.1-mega.41.tgz
108108
109109
- name: Run unit tests
110110
run: npm test -- --runInBand
@@ -180,4 +180,4 @@ jobs:
180180
- name: Verify runtime client
181181
run: |
182182
docker run --rm --entrypoint node eufy-mega-ws:test --input-type=module -e \
183-
"import client from '/usr/src/app/node_modules/eufy-security-ws/node_modules/eufy-security-client/package.json' with { type: 'json' }; const contract = await import('/usr/src/app/node_modules/eufy-security-ws/dist/lib/const.js'); if (client.version !== '4.1.1-mega.40' || contract.maxSchemaVersion !== 21) process.exit(1); await import('/usr/src/app/node_modules/eufy-security-ws/dist/lib/index.js')"
183+
"import client from '/usr/src/app/node_modules/eufy-security-ws/node_modules/eufy-security-client/package.json' with { type: 'json' }; const contract = await import('/usr/src/app/node_modules/eufy-security-ws/dist/lib/const.js'); if (client.version !== '4.1.1-mega.41' || contract.maxSchemaVersion !== 21) process.exit(1); await import('/usr/src/app/node_modules/eufy-security-ws/dist/lib/index.js')"

‎eufy-mega-ws/CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 1.6.59 — 2026-11-01
4+
5+
- Backport the current eufy SDK's authenticated P-256 media-key exchange for cameras attached to a
6+
HomeBase 3, while retaining the mature RSA and plaintext decoders for every other stream.
7+
- Decode HomeBase keyframes with verified ECIES/HMAC and AES-256-GCM before accepting their video,
8+
fixing current app streams that cannot be opened by the legacy RSA-only path.
9+
- Keep live video strictly explicit: this change improves user-requested snapshot/live captures but
10+
does not start cameras automatically.
11+
312
## 1.6.58 — 2026-10-31
413

514
- Remove optional fifteen-year, per-camera archive backfills from the synchronous HomeBase Pro

‎eufy-mega-ws/Dockerfile‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ RUN \
1313
&& npm ci \
1414
&& npm run build \
1515
&& npm pack --ignore-scripts --pack-destination /tmp \
16-
&& test -s /tmp/eufy-security-client-4.1.1-mega.40.tgz
16+
&& test -s /tmp/eufy-security-client-4.1.1-mega.41.tgz
1717

1818
FROM ghcr.io/home-assistant/base:3.23 AS server-builder
1919

2020
RUN apk add --no-cache nodejs npm
2121

2222
WORKDIR /build/eufy-security-ws
2323
COPY vendor/eufy-security-ws/ ./
24-
COPY --from=client-builder /tmp/eufy-security-client-4.1.1-mega.40.tgz /tmp/eufy-security-client-4.1.1-mega.40.tgz
24+
COPY --from=client-builder /tmp/eufy-security-client-4.1.1-mega.41.tgz /tmp/eufy-security-client-4.1.1-mega.41.tgz
2525

2626
# Compile the exact schema-21 server source and retain only its locked runtime dependencies.
2727
RUN \
@@ -31,7 +31,7 @@ RUN \
3131
&& npm ci \
3232
&& rm -rf node_modules/eufy-security-client \
3333
&& mkdir -p node_modules/eufy-security-client \
34-
&& tar -xzf /tmp/eufy-security-client-4.1.1-mega.40.tgz \
34+
&& tar -xzf /tmp/eufy-security-client-4.1.1-mega.41.tgz \
3535
--strip-components=1 \
3636
-C node_modules/eufy-security-client \
3737
&& npm run build \
@@ -62,30 +62,30 @@ COPY --from=server-builder /build/eufy-security-ws/node_modules ./node_modules
6262
COPY --from=server-builder /build/eufy-security-ws/package.json ./package.json
6363
COPY --from=server-builder /build/eufy-security-ws/LICENSE ./LICENSE
6464

65-
COPY --from=client-builder /tmp/eufy-security-client-4.1.1-mega.40.tgz /tmp/eufy-security-client-4.1.1-mega.40.tgz
65+
COPY --from=client-builder /tmp/eufy-security-client-4.1.1-mega.41.tgz /tmp/eufy-security-client-4.1.1-mega.41.tgz
6666

6767
# eufy-security-ws 3.1.0 pins client 4.1.0 exactly. Replace that package with the compatible
68-
# 4.1.1-mega.40 source build carrying privacy-safe E10 OTA diagnostics,
68+
# 4.1.1-mega.41 source build carrying authenticated HomeBase 3 media decode,
6969
# evidence-ranked native catalogs, safe value profiles, T9000, and snapshot fixes.
7070
RUN \
7171
set -eux \
7272
&& node --version \
7373
&& case "$(node --version)" in v24.*) ;; *) echo "Expected Node 24" >&2; exit 1 ;; esac \
7474
&& rm -rf node_modules/eufy-security-client \
7575
&& mkdir -p node_modules/eufy-security-client \
76-
&& tar -xzf /tmp/eufy-security-client-4.1.1-mega.40.tgz \
76+
&& tar -xzf /tmp/eufy-security-client-4.1.1-mega.41.tgz \
7777
--strip-components=1 \
7878
-C node_modules/eufy-security-client \
7979
&& package_tmp="$(mktemp)" \
80-
&& jq '.dependencies["eufy-security-client"] = "4.1.1-mega.40"' \
80+
&& jq '.dependencies["eufy-security-client"] = "4.1.1-mega.41"' \
8181
package.json > "${package_tmp}" \
8282
&& mv "${package_tmp}" package.json \
83-
&& test "$(node -p "require('./node_modules/eufy-security-client/package.json').version")" = "4.1.1-mega.40" \
83+
&& test "$(node -p "require('./node_modules/eufy-security-client/package.json').version")" = "4.1.1-mega.41" \
8484
&& grep -q "retrying request after fresh key exchange" \
8585
node_modules/eufy-security-client/build/http/megaApi.js \
8686
&& grep -q "snapshotCache" node_modules/eufy-security-client/build/eufysecurity.js \
8787
&& grep -q "or_and" node_modules/eufy-security-client/build/http/station.js \
88-
&& rm /tmp/eufy-security-client-4.1.1-mega.40.tgz
88+
&& rm /tmp/eufy-security-client-4.1.1-mega.41.tgz
8989

9090
COPY dashboard/ /opt/baiamonte-eufy-dashboard/
9191
COPY pro-events/ /opt/baiamonte-pro-events/

‎eufy-mega-ws/config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Baiamonte eufy Bridge
22
description: >-
33
Baiamonte-branded Schema-21 bridge and dashboard for eufy devices, using Mega
44
authentication and discovery with safe compatibility coverage and native P2P control.
5-
version: "1.6.58"
5+
version: "1.6.59"
66
slug: baiamonte_eufy_bridge
77
url: https://github.com/drahamin/baiamonte-eufy-bridge
88
init: false

‎eufy-mega-ws/vendor/eufy-security-client/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎eufy-mega-ws/vendor/eufy-security-client/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "eufy-security-client",
3-
"version": "4.1.1-mega.40",
3+
"version": "4.1.1-mega.41",
44
"description": "Client to communicate with Eufy-Security devices",
55
"author": {
66
"name": "bropat",

‎eufy-mega-ws/vendor/eufy-security-client/src/http/station.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7766,7 +7766,38 @@ export class Station extends TypedEmitter<StationEvents> {
77667766
});
77677767
const rsa_key = this.p2pSession.getRSAPrivateKey();
77687768

7769-
if (device.isSmartDrop()) {
7769+
if (this.isStationHomeBase3()) {
7770+
const mediaKey = this.p2pSession.getAttachedMediaStartKey(device.getChannel());
7771+
rootHTTPLogger.debug(`Station start livestream - using HomeBase 3 attached media key`, {
7772+
stationSN: this.getSerial(),
7773+
deviceSN: device.getSerial(),
7774+
videoCodec: videoCodec,
7775+
keyType: mediaKey.kind,
7776+
});
7777+
this.p2pSession.sendCommandWithStringPayload(
7778+
{
7779+
commandType: CommandType.CMD_SET_PAYLOAD,
7780+
value: JSON.stringify({
7781+
account_id: this.rawStation.member.admin_user_id,
7782+
cmd: CommandType.CMD_START_REALTIME_MEDIA,
7783+
mChannel: device.getChannel(),
7784+
mValue3: CommandType.CMD_START_REALTIME_MEDIA,
7785+
payload: {
7786+
ClientOS: mediaKey.kind === "ecc" ? "IOS" : "Android",
7787+
accountId: this.rawStation.member.admin_user_id,
7788+
camera_type: 0,
7789+
entrytype: 0,
7790+
key: mediaKey.key,
7791+
streamtype: videoCodec === VideoCodec.H264 ? 1 : 2,
7792+
},
7793+
}),
7794+
channel: device.getChannel(),
7795+
},
7796+
{
7797+
command: commandData,
7798+
}
7799+
);
7800+
} else if (device.isSmartDrop()) {
77707801
rootHTTPLogger.debug(`Station start livestream - sending command (smart drop)`, {
77717802
stationSN: this.getSerial(),
77727803
deviceSN: device.getSerial(),

‎eufy-mega-ws/vendor/eufy-security-client/src/p2p/__test__/utils.test.tsx‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ import {
3737
eufyKDF,
3838
decryptP2PKeyECDH,
3939
getLockV12Key,
40+
EufyMediaFrameDecoder,
4041
} from "../utils";
4142
import { VideoCodec } from "../types";
42-
import { createECDH } from "crypto";
43+
import { createCipheriv, createECDH, createHmac } from "crypto";
4344

4445
jest.mock("../../logging", () => ({
4546
rootP2PLogger: { error: jest.fn(), debug: jest.fn(), info: jest.fn() },
@@ -693,4 +694,36 @@ describe("p2p/utils", () => {
693694
expect(decryptP2PKeyECDH(pub, eccPrivateKey)).toEqual(decryptP2PKeyECDH(pub, eccPrivateKey));
694695
});
695696
});
697+
698+
describe("EufyMediaFrameDecoder", () => {
699+
it("authenticates the ECIES media key and decrypts the GCM frame body", () => {
700+
const receiver = createECDH("prime256v1");
701+
receiver.generateKeys();
702+
const sender = createECDH("prime256v1");
703+
sender.generateKeys();
704+
const mediaKey = Buffer.alloc(32, 0x2a);
705+
const wrapIv = Buffer.alloc(16, 0x18);
706+
const derivedKey = eufyKDF(sender.computeSecret(receiver.getPublicKey()));
707+
const wrapper = createCipheriv("aes-128-cbc", derivedKey.subarray(0, 16), wrapIv);
708+
const wrappedKey = Buffer.concat([wrapper.update(mediaKey), wrapper.final()]);
709+
const envelopeBody = Buffer.concat([wrapIv, wrappedKey]);
710+
const envelopeTag = createHmac("sha256", derivedKey.subarray(16, 48)).update(envelopeBody).digest();
711+
const envelope = Buffer.concat([sender.getPublicKey(undefined, "compressed"), envelopeBody, envelopeTag]);
712+
713+
const plaintext = Buffer.from("000000016742001f", "hex");
714+
const nonce = Buffer.alloc(12, 0x07);
715+
const cipher = createCipheriv("aes-256-gcm", mediaKey, nonce);
716+
cipher.setAAD(Buffer.from("eufy security"));
717+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
718+
const frame = Buffer.alloc(22);
719+
frame.writeUInt32LE(ciphertext.length, 0);
720+
frame.writeUInt8(1, 4);
721+
const payload = Buffer.concat([frame, envelope, cipher.getAuthTag(), nonce, ciphertext]);
722+
723+
const decoder = new EufyMediaFrameDecoder(receiver.getPrivateKey());
724+
expect(decoder.decode(payload)).toEqual(plaintext);
725+
payload[payload.length - 1] ^= 0xff;
726+
expect(decoder.decode(payload)).toBeUndefined();
727+
});
728+
});
696729
});

‎eufy-mega-ws/vendor/eufy-security-client/src/p2p/session.ts‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createSocket, Socket, RemoteInfo } from "dgram";
2+
import { createECDH } from "crypto";
23
import { TypedEmitter } from "tiny-typed-emitter";
34
import * as NodeRSA from "node-rsa";
45
import { Readable } from "stream";
@@ -67,6 +68,7 @@ import {
6768
generateSmartLockAESKey,
6869
readNullTerminatedBuffer,
6970
decryptP2PKeyECDH,
71+
EufyMediaFrameDecoder,
7072
} from "./utils";
7173
import {
7274
RequestMessageType,
@@ -493,6 +495,10 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> {
493495
private encryption: EncryptionType = EncryptionType.NONE;
494496
private p2pKey?: Buffer;
495497
private enableEmbeddedPKCS1Support = false;
498+
private mediaEccPrivateKey?: Buffer;
499+
private mediaEccPublicKey?: string;
500+
private mediaFrameDecoder?: EufyMediaFrameDecoder;
501+
private mediaStartKeys: Map<number, { kind: "ecc" | "rsa"; answered: boolean }> = new Map();
496502

497503
constructor(
498504
rawStation: StationListResponse,
@@ -576,6 +582,10 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> {
576582
this.messageStates.clear();
577583
this.messageVideoStates.clear();
578584
this.p2pSeqMapping.clear();
585+
this.mediaEccPrivateKey = undefined;
586+
this.mediaEccPublicKey = undefined;
587+
this.mediaFrameDecoder = undefined;
588+
this.mediaStartKeys.clear();
579589

580590
for (let datatype = 0; datatype < 4; datatype++) {
581591
this.expectedSeqNo[datatype] = 0;
@@ -2497,12 +2507,21 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> {
24972507
videoMetaData.videoTimestamp = message.data.subarray(14, 20).readUIntLE(0, 6);
24982508

24992509
let payloadStart = 22;
2500-
if (message.signCode > 0 && data_length >= 128) {
2510+
let video_data = this.mediaFrameDecoder?.decode(message.data);
2511+
if (video_data !== undefined) {
2512+
this.mediaStartKeys.set(message.channel, { kind: "ecc", answered: true });
2513+
rootP2PLogger.debug("Decoded authenticated P-256 HomeBase media frame", {
2514+
stationSN: this.rawStation.station_sn,
2515+
channel: message.channel,
2516+
isKeyFrame,
2517+
});
2518+
} else if (message.signCode > 0 && data_length >= 128) {
25012519
const key = message.data.subarray(22, 150);
25022520
const rsaKey = this.currentMessageState[message.dataType].rsaKey;
25032521
if (rsaKey) {
25042522
try {
25052523
videoMetaData.aesKey = rsaKey.decrypt(key).toString("hex");
2524+
this.mediaStartKeys.set(message.channel, { kind: "rsa", answered: true });
25062525
rootP2PLogger.trace(`Handle DATA ${P2PDataType[message.dataType]} - Decrypted AES key`, {
25072526
stationSN: this.rawStation.station_sn,
25082527
key: videoMetaData.aesKey,
@@ -2542,8 +2561,9 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> {
25422561
payloadStart = 151;
25432562
}
25442563

2545-
let video_data: Buffer;
2546-
if (videoMetaData.aesKey !== "") {
2564+
if (video_data !== undefined) {
2565+
// The P-256 decoder already returned the complete Annex-B payload.
2566+
} else if (videoMetaData.aesKey !== "") {
25472567
const encrypted_data = message.data.subarray(payloadStart, payloadStart + 128);
25482568
const unencrypted_data = message.data.subarray(
25492569
payloadStart + 128,
@@ -4580,6 +4600,37 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> {
45804600
return this.currentMessageState[P2PDataType.VIDEO].rsaKey;
45814601
}
45824602

4603+
/**
4604+
* Return the uncompressed P-256 point (x || y) offered by the current app for
4605+
* HomeBase 3 attached-camera media. The matching decoder remains private to
4606+
* this P2P session and is reset whenever the connection is reinitialised.
4607+
*/
4608+
private getMediaEccPublicKey(): string {
4609+
if (this.mediaEccPrivateKey === undefined || this.mediaEccPublicKey === undefined) {
4610+
const ecdh = createECDH("prime256v1");
4611+
ecdh.generateKeys();
4612+
this.mediaEccPrivateKey = Buffer.from(ecdh.getPrivateKey("hex").padStart(64, "0"), "hex");
4613+
this.mediaEccPublicKey = ecdh.getPublicKey().subarray(1).toString("hex");
4614+
this.mediaFrameDecoder = new EufyMediaFrameDecoder(this.mediaEccPrivateKey);
4615+
}
4616+
return this.mediaEccPublicKey;
4617+
}
4618+
4619+
/**
4620+
* Choose the media key for an attached-camera start. The first start on a
4621+
* channel offers the current app's P-256 point. If no authenticated frame
4622+
* answers before the caller retries, later starts fall back to the mature RSA
4623+
* exchange. Once either format answers, that format remains selected.
4624+
*/
4625+
public getAttachedMediaStartKey(channel: number): { kind: "ecc" | "rsa"; key: string } {
4626+
const previous = this.mediaStartKeys.get(channel);
4627+
const kind = previous?.answered ? previous.kind : previous === undefined ? "ecc" : "rsa";
4628+
this.mediaStartKeys.set(channel, { kind, answered: previous?.answered ?? false });
4629+
if (kind === "ecc") return { kind, key: this.getMediaEccPublicKey() };
4630+
const rsaKey = this.getRSAPrivateKey();
4631+
return { kind, key: rsaKey?.exportKey("components-public").n.subarray(1).toString("hex") ?? "" };
4632+
}
4633+
45834634
private initializeStream(datatype: P2PDataType): void {
45844635
this.currentMessageState[datatype].videoStream?.destroy();
45854636
this.currentMessageState[datatype].audioStream?.destroy();

0 commit comments

Comments
 (0)