|
1 | 1 | import { createSocket, Socket, RemoteInfo } from "dgram"; |
| 2 | +import { createECDH } from "crypto"; |
2 | 3 | import { TypedEmitter } from "tiny-typed-emitter"; |
3 | 4 | import * as NodeRSA from "node-rsa"; |
4 | 5 | import { Readable } from "stream"; |
@@ -67,6 +68,7 @@ import { |
67 | 68 | generateSmartLockAESKey, |
68 | 69 | readNullTerminatedBuffer, |
69 | 70 | decryptP2PKeyECDH, |
| 71 | + EufyMediaFrameDecoder, |
70 | 72 | } from "./utils"; |
71 | 73 | import { |
72 | 74 | RequestMessageType, |
@@ -493,6 +495,10 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> { |
493 | 495 | private encryption: EncryptionType = EncryptionType.NONE; |
494 | 496 | private p2pKey?: Buffer; |
495 | 497 | 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(); |
496 | 502 |
|
497 | 503 | constructor( |
498 | 504 | rawStation: StationListResponse, |
@@ -576,6 +582,10 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> { |
576 | 582 | this.messageStates.clear(); |
577 | 583 | this.messageVideoStates.clear(); |
578 | 584 | this.p2pSeqMapping.clear(); |
| 585 | + this.mediaEccPrivateKey = undefined; |
| 586 | + this.mediaEccPublicKey = undefined; |
| 587 | + this.mediaFrameDecoder = undefined; |
| 588 | + this.mediaStartKeys.clear(); |
579 | 589 |
|
580 | 590 | for (let datatype = 0; datatype < 4; datatype++) { |
581 | 591 | this.expectedSeqNo[datatype] = 0; |
@@ -2497,12 +2507,21 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> { |
2497 | 2507 | videoMetaData.videoTimestamp = message.data.subarray(14, 20).readUIntLE(0, 6); |
2498 | 2508 |
|
2499 | 2509 | 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) { |
2501 | 2519 | const key = message.data.subarray(22, 150); |
2502 | 2520 | const rsaKey = this.currentMessageState[message.dataType].rsaKey; |
2503 | 2521 | if (rsaKey) { |
2504 | 2522 | try { |
2505 | 2523 | videoMetaData.aesKey = rsaKey.decrypt(key).toString("hex"); |
| 2524 | + this.mediaStartKeys.set(message.channel, { kind: "rsa", answered: true }); |
2506 | 2525 | rootP2PLogger.trace(`Handle DATA ${P2PDataType[message.dataType]} - Decrypted AES key`, { |
2507 | 2526 | stationSN: this.rawStation.station_sn, |
2508 | 2527 | key: videoMetaData.aesKey, |
@@ -2542,8 +2561,9 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> { |
2542 | 2561 | payloadStart = 151; |
2543 | 2562 | } |
2544 | 2563 |
|
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 !== "") { |
2547 | 2567 | const encrypted_data = message.data.subarray(payloadStart, payloadStart + 128); |
2548 | 2568 | const unencrypted_data = message.data.subarray( |
2549 | 2569 | payloadStart + 128, |
@@ -4580,6 +4600,37 @@ export class P2PClientProtocol extends TypedEmitter<P2PClientProtocolEvents> { |
4580 | 4600 | return this.currentMessageState[P2PDataType.VIDEO].rsaKey; |
4581 | 4601 | } |
4582 | 4602 |
|
| 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 | + |
4583 | 4634 | private initializeStream(datatype: P2PDataType): void { |
4584 | 4635 | this.currentMessageState[datatype].videoStream?.destroy(); |
4585 | 4636 | this.currentMessageState[datatype].audioStream?.destroy(); |
|
0 commit comments