1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-06-24 21:25:23 +00:00

Added encryption.

This commit is contained in:
Koen 2023-12-30 10:55:30 +01:00
parent b8bd78d90d
commit 9599c1931e
29 changed files with 1016 additions and 1069 deletions

View file

@ -103,6 +103,22 @@ dependencies = [
"os_str_bytes", "os_str_bytes",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.11" version = "0.2.11"
@ -129,7 +145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbcf33c2a618cbe41ee43ae6e9f2e48368cd9f9db2896f10167d8d762679f639" checksum = "bbcf33c2a618cbe41ee43ae6e9f2e48368cd9f9db2896f10167d8d762679f639"
dependencies = [ dependencies = [
"nix", "nix",
"windows-sys", "windows-sys 0.45.0",
] ]
[[package]] [[package]]
@ -148,12 +164,29 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "errno"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "fastrand"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5"
[[package]] [[package]]
name = "fcast" name = "fcast"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"ctrlc", "ctrlc",
"native-tls",
"openssl", "openssl",
"serde", "serde",
"serde_json", "serde_json",
@ -277,18 +310,48 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.150" version = "0.2.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c"
[[package]]
name = "linux-raw-sys"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.20" version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.26.2" version = "0.26.2"
@ -333,6 +396,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.97" version = "0.9.97"
@ -417,12 +486,66 @@ dependencies = [
"getrandom", "getrandom",
] ]
[[package]]
name = "redox_syscall"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "rustix"
version = "0.38.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316"
dependencies = [
"bitflags 2.4.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.13" version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
[[package]]
name = "schannel"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "security-framework"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.193" version = "1.0.193"
@ -488,6 +611,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tempfile"
version = "3.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5"
dependencies = [
"cfg-if",
"fastrand",
"redox_syscall",
"rustix",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "termcolor" name = "termcolor"
version = "1.2.0" version = "1.2.0"
@ -562,6 +698,7 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"log", "log",
"native-tls",
"rand", "rand",
"sha1", "sha1",
"thiserror", "thiserror",
@ -668,7 +805,25 @@ version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.0",
] ]
[[package]] [[package]]
@ -677,13 +832,43 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.42.2",
"windows_i686_gnu", "windows_i686_gnu 0.42.2",
"windows_i686_msvc", "windows_i686_msvc 0.42.2",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd"
dependencies = [
"windows_aarch64_gnullvm 0.52.0",
"windows_aarch64_msvc 0.52.0",
"windows_i686_gnu 0.52.0",
"windows_i686_msvc 0.52.0",
"windows_x86_64_gnu 0.52.0",
"windows_x86_64_gnullvm 0.52.0",
"windows_x86_64_msvc 0.52.0",
] ]
[[package]] [[package]]
@ -692,38 +877,122 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"

View file

@ -10,7 +10,8 @@ clap = "3"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
ctrlc = "3.1.9" ctrlc = "3.1.9"
tungstenite = "0.21.0" tungstenite = { version = "0.21.0", features = ["native-tls"] }
url = "2.5.0" url = "2.5.0"
tiny_http = "0.12.0" tiny_http = "0.12.0"
openssl = "0.10.61" openssl = "0.10.61"
native-tls = "0.2.11"

View file

@ -1,7 +1,6 @@
use std::{sync::{atomic::{AtomicBool, Ordering}, Arc}, collections::VecDeque}; use std::sync::{atomic::{AtomicBool, Ordering}, Arc};
use crate::{models::{PlaybackUpdateMessage, VolumeUpdateMessage, PlaybackErrorMessage, VersionMessage, KeyExchangeMessage, EncryptedMessage, DecryptedMessage}, transport::Transport}; use crate::{models::{PlaybackUpdateMessage, VolumeUpdateMessage, PlaybackErrorMessage, VersionMessage}, transport::Transport};
use openssl::{dh::Dh, base64, pkey::{Private, PKey}, symm::{Cipher, Crypter, Mode}, bn::BigNum};
use serde::Serialize; use serde::Serialize;
#[derive(Debug)] #[derive(Debug)]
@ -26,11 +25,8 @@ pub enum Opcode {
PlaybackError = 9, PlaybackError = 9,
SetSpeed = 10, SetSpeed = 10,
Version = 11, Version = 11,
KeyExchange = 12, Ping = 12,
Encrypted = 13, Pong = 13
Ping = 14,
Pong = 15,
StartEncryption = 16
} }
impl Opcode { impl Opcode {
@ -48,11 +44,8 @@ impl Opcode {
9 => Opcode::PlaybackError, 9 => Opcode::PlaybackError,
10 => Opcode::SetSpeed, 10 => Opcode::SetSpeed,
11 => Opcode::Version, 11 => Opcode::Version,
12 => Opcode::KeyExchange, 12 => Opcode::Ping,
13 => Opcode::Encrypted, 13 => Opcode::Pong,
14 => Opcode::Ping,
15 => Opcode::Pong,
16 => Opcode::StartEncryption,
_ => panic!("Unknown value: {}", value), _ => panic!("Unknown value: {}", value),
} }
} }
@ -66,60 +59,24 @@ pub struct FCastSession<'a> {
bytes_read: usize, bytes_read: usize,
packet_length: usize, packet_length: usize,
stream: Box<dyn Transport + 'a>, stream: Box<dyn Transport + 'a>,
state: SessionState, state: SessionState
dh: Option<Dh<Private>>,
public_key: Option<String>,
aes_key: Option<Vec<u8>>,
decrypted_messages_queue: VecDeque<DecryptedMessage>,
encrypted_messages_queue: VecDeque<EncryptedMessage>,
encryption_started: bool,
wait_for_encryption: bool
} }
impl<'a> FCastSession<'a> { impl<'a> FCastSession<'a> {
pub fn new<T: Transport + 'a>(stream: T, encrypted: bool) -> Result<Self, Box<dyn std::error::Error>> { pub fn new<T: Transport + 'a>(stream: T) -> Self {
let (dh, public_key) = if encrypted { return FCastSession {
println!("Initialized DH.");
generate_key_pair()?
} else {
(None, None)
};
Ok(FCastSession {
buffer: vec![0; MAXIMUM_PACKET_LENGTH], buffer: vec![0; MAXIMUM_PACKET_LENGTH],
bytes_read: 0, bytes_read: 0,
packet_length: 0, packet_length: 0,
stream: Box::new(stream), stream: Box::new(stream),
state: SessionState::Idle, state: SessionState::Idle
wait_for_encryption: dh.is_some(), }
dh,
public_key,
aes_key: None,
decrypted_messages_queue: VecDeque::new(),
encrypted_messages_queue: VecDeque::new(),
encryption_started: false
})
} }
} }
impl FCastSession<'_> { impl FCastSession<'_> {
pub fn send_message<T: Serialize>(&mut self, opcode: Opcode, message: T) -> Result<(), Box<dyn std::error::Error>> { pub fn send_message<T: Serialize>(&mut self, opcode: Opcode, message: T) -> Result<(), Box<dyn std::error::Error>> {
let json = serde_json::to_string(&message)?; let json = serde_json::to_string(&message)?;
if opcode != Opcode::Encrypted && opcode != Opcode::KeyExchange && opcode != Opcode::StartEncryption {
if self.encryption_started {
println!("Sending encrypted with opcode {:?}.", opcode);
let decrypted_message = DecryptedMessage::new(opcode as u64, Some(json));
let encrypted_message = encrypt_message(&self.aes_key.as_ref().unwrap(), &decrypted_message)?;
return self.send_message(Opcode::Encrypted, &encrypted_message)
} else if self.wait_for_encryption {
println!("Queued message with opcode {:?} until encryption is established.", opcode);
let decrypted_message = DecryptedMessage::new(opcode as u64, Some(json));
self.decrypted_messages_queue.push_back(decrypted_message);
return Ok(());
}
}
let data = json.as_bytes(); let data = json.as_bytes();
let size = 1 + data.len(); let size = 1 + data.len();
let header_size = LENGTH_BYTES + 1; let header_size = LENGTH_BYTES + 1;
@ -134,19 +91,6 @@ impl FCastSession<'_> {
} }
pub fn send_empty(&mut self, opcode: Opcode) -> Result<(), Box<dyn std::error::Error>> { pub fn send_empty(&mut self, opcode: Opcode) -> Result<(), Box<dyn std::error::Error>> {
if opcode != Opcode::Encrypted && opcode != Opcode::KeyExchange && opcode != Opcode::StartEncryption {
let decrypted_message = DecryptedMessage::new(opcode as u64, None);
if self.encryption_started {
println!("Sending encrypted with opcode {:?}.", opcode);
let encrypted_message = encrypt_message(&self.aes_key.as_ref().unwrap(), &decrypted_message)?;
return self.send_message(Opcode::Encrypted, &encrypted_message)
} else if self.wait_for_encryption {
println!("Queued message with opcode {:?} until encryption is established.", opcode);
self.decrypted_messages_queue.push_back(decrypted_message);
return Ok(());
}
}
let json = String::new(); let json = String::new();
let data = json.as_bytes(); let data = json.as_bytes();
let size = 1 + data.len(); let size = 1 + data.len();
@ -159,22 +103,13 @@ impl FCastSession<'_> {
Ok(()) Ok(())
} }
pub fn receive_loop(&mut self, running: &Arc<AtomicBool>, until_queues_are_empty: bool) -> Result<(), Box<dyn std::error::Error>> { pub fn receive_loop(&mut self, running: &Arc<AtomicBool>) -> Result<(), Box<dyn std::error::Error>> {
if let Some(pk) = &self.public_key {
println!("Sending public key.");
self.send_message(Opcode::KeyExchange, &KeyExchangeMessage::new(1, pk.clone()))?;
}
println!("Start receiving."); println!("Start receiving.");
self.state = SessionState::WaitingForLength; self.state = SessionState::WaitingForLength;
let mut buffer = [0u8; 1024]; let mut buffer = [0u8; 1024];
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {
if until_queues_are_empty && self.are_queues_empty() {
break;
}
let bytes_read = self.stream.transport_read(&mut buffer)?; let bytes_read = self.stream.transport_read(&mut buffer)?;
self.process_bytes(&buffer[..bytes_read])?; self.process_bytes(&buffer[..bytes_read])?;
} }
@ -323,68 +258,11 @@ impl FCastSession<'_> {
println!("Received version with no body."); println!("Received version with no body.");
} }
} }
Opcode::KeyExchange => {
if let Some(body_str) = body {
match serde_json::from_str::<KeyExchangeMessage>(body_str.as_str()) {
Ok(key_exchange_message) => {
if let Some(dh) = &self.dh {
println!("Received key exchange message {:?}", key_exchange_message);
self.aes_key = Some(compute_shared_secret(dh, &key_exchange_message)?);
self.send_empty(Opcode::StartEncryption)?;
println!("Processing queued encrypted messages to handle.");
while let Some(encrypted_message) = self.encrypted_messages_queue.pop_front() {
let decrypted_message = decrypt_message(&self.aes_key.as_ref().unwrap(), &encrypted_message)?;
self.handle_packet(Opcode::from_u8(decrypted_message.opcode as u8), decrypted_message.message)?;
}
} else {
println!("Received key exchange message while encryption is diabled {:?}", key_exchange_message);
}
},
Err(e) => println!("Received key exchange with malformed body: {}.", e)
};
} else {
println!("Received key exchange with no body.");
}
}
Opcode::Encrypted => {
if let Some(body_str) = body {
if let Ok(encrypted_message) = serde_json::from_str::<EncryptedMessage>(body_str.as_str()) {
println!("Received encrypted message {:?}", encrypted_message);
if self.aes_key.is_some() {
println!("Decrypting and handling encrypted message.");
let decrypted_message = decrypt_message(&self.aes_key.as_ref().unwrap(), &encrypted_message)?;
self.handle_packet(Opcode::from_u8(decrypted_message.opcode as u8), decrypted_message.message)?;
} else {
println!("Queued encrypted message until encryption is established.");
self.encrypted_messages_queue.push_back(encrypted_message);
if self.encrypted_messages_queue.len() > 15 {
self.encrypted_messages_queue.pop_front();
}
}
} else {
println!("Received encrypted with malformed body.");
}
} else {
println!("Received encrypted with no body.");
}
}
Opcode::Ping => { Opcode::Ping => {
println!("Received ping"); println!("Received ping");
self.send_empty(Opcode::Pong)?; self.send_empty(Opcode::Pong)?;
println!("Sent pong"); println!("Sent pong");
} }
Opcode::StartEncryption => {
self.encryption_started = true;
println!("Processing queued decrypted messages to send.");
while let Some(decrypted_message) = self.decrypted_messages_queue.pop_front() {
let encrypted_message = encrypt_message(&self.aes_key.as_ref().unwrap(), &decrypted_message)?;
self.send_message(Opcode::Encrypted, &encrypted_message)?;
}
}
_ => { _ => {
println!("Error handling packet"); println!("Error handling packet");
} }
@ -393,184 +271,7 @@ impl FCastSession<'_> {
Ok(()) Ok(())
} }
fn are_queues_empty(&self) -> bool {
return self.decrypted_messages_queue.is_empty() && self.encrypted_messages_queue.is_empty();
}
pub fn shutdown(&mut self) -> Result<(), std::io::Error> { pub fn shutdown(&mut self) -> Result<(), std::io::Error> {
return self.stream.transport_shutdown(); return self.stream.transport_shutdown();
} }
}
fn generate_key_pair() -> Result<(Option<Dh<Private>>, Option<String>), Box<dyn std::error::Error>> {
//modp14
let p = "ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff";
let g = "2";
let v = Dh::from_pqg(BigNum::from_hex_str(p)?, None, BigNum::from_hex_str(g)?)?.generate_key()?;
let private = v.private_key().to_owned()?;
let dh2 = Dh::from_pqg(BigNum::from_hex_str(p)?, None, BigNum::from_hex_str(g)?)?.set_private_key(private)?;
let pkey = PKey::from_dh(dh2)?;
let public_key_der = pkey.public_key_to_der()?;
let public_key_base64 = base64::encode_block(public_key_der.as_ref());
Ok((Some(v), Some(public_key_base64)))
}
fn encrypt_message(aes_key: &Vec<u8>, decrypted_message: &DecryptedMessage) -> Result<EncryptedMessage, Box<dyn std::error::Error>> {
let cipher = Cipher::aes_256_cbc();
let iv_len = cipher.iv_len().ok_or("Cipher does not support IV")?;
let mut iv = vec![0; iv_len];
openssl::rand::rand_bytes(&mut iv)?;
let mut crypter = Crypter::new(
cipher,
Mode::Encrypt,
aes_key,
Some(&iv)
)?;
crypter.pad(true);
let json = serde_json::to_string(decrypted_message)?;
let mut ciphertext = vec![0; json.len() + cipher.block_size()];
let count = crypter.update(json.as_bytes(), &mut ciphertext)?;
let rest = crypter.finalize(&mut ciphertext[count..])?;
ciphertext.truncate(count + rest);
Ok(EncryptedMessage::new(1, Some(base64::encode_block(&iv)), base64::encode_block(&ciphertext)))
}
fn decrypt_message(aes_key: &Vec<u8>, encrypted_message: &EncryptedMessage) -> Result<DecryptedMessage, Box<dyn std::error::Error>> {
if encrypted_message.iv.is_none() {
return Err("IV is required for decryption.".into());
}
let cipher = Cipher::aes_256_cbc();
let iv = base64::decode_block(&encrypted_message.iv.as_ref().unwrap())?;
let ciphertext = base64::decode_block(&encrypted_message.blob)?;
let mut crypter = Crypter::new(
cipher,
Mode::Decrypt,
aes_key,
Some(&iv)
)?;
crypter.pad(true);
let mut plaintext = vec![0; ciphertext.len() + cipher.block_size()];
let count = crypter.update(&ciphertext, &mut plaintext)?;
let rest = crypter.finalize(&mut plaintext[count..])?;
plaintext.truncate(count + rest);
let decrypted_str = String::from_utf8(plaintext)?;
Ok(serde_json::from_str(&decrypted_str)?)
}
fn compute_shared_secret(dh: &Dh<Private>, key_exchange_message: &KeyExchangeMessage) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let peer_public_key_der = base64::decode_block(&key_exchange_message.public_key)?;
let peer_public_key = PKey::public_key_from_der(&peer_public_key_der)?;
let peer_dh = peer_public_key.dh()?;
let peer_pub_key = peer_dh.public_key();
let shared_secret = dh.compute_key(&peer_pub_key)?;
let digest = openssl::hash::hash(openssl::hash::MessageDigest::sha256(), &shared_secret)?.to_vec();
Ok(digest)
}
#[cfg(test)]
mod tests {
use super::*;
use openssl::base64;
#[test]
fn test_dh_encryption_self() {
let (key_pair1, public_key1) = generate_key_pair().unwrap();
let (key_pair2, public_key2) = generate_key_pair().unwrap();
let aes_key1 = compute_shared_secret(&key_pair1.unwrap(), &KeyExchangeMessage::new(1, public_key2.unwrap())).unwrap();
let aes_key2 = compute_shared_secret(&key_pair2.unwrap(), &KeyExchangeMessage::new(1, public_key1.unwrap())).unwrap();
assert_eq!(aes_key1, aes_key2);
let message = DecryptedMessage {
opcode: 1,
message: Some(r#"{"type": "text/html"}"#.to_string()),
};
let encrypted_message = encrypt_message(&aes_key1, &message).unwrap();
let decrypted_message = decrypt_message(&aes_key1, &encrypted_message).unwrap();
assert_eq!(message.opcode, decrypted_message.opcode);
assert_eq!(message.message, decrypted_message.message);
}
#[test]
fn test_dh_encryption_known() {
let private_key1 = base64::decode_block("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==").unwrap();
let key_exchange_message_2 = KeyExchangeMessage::new(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=".to_string());
let private_key = PKey::private_key_from_der(&private_key1).unwrap();
let dh = private_key.dh().unwrap();
let aes_key1 = compute_shared_secret(&dh, &key_exchange_message_2).unwrap();
assert_eq!(base64::encode_block(&aes_key1), "vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=");
let message = DecryptedMessage {
opcode: 1,
message: Some(r#"{"type": "text/html"}"#.to_string()),
};
let encrypted_message = encrypt_message(&aes_key1, &message).unwrap();
let decrypted_message = decrypt_message(&aes_key1, &encrypted_message).unwrap();
assert_eq!(message.opcode, decrypted_message.opcode);
assert_eq!(message.message, decrypted_message.message);
}
#[test]
fn test_decrypt_message_known() {
let encrypted_message_json = r#"{"version":1,"iv":"C4H70VC5FWrNtkty9/cLIA==","blob":"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA=="}"#;
let encrypted_message: EncryptedMessage = serde_json::from_str(encrypted_message_json).unwrap();
let aes_key_base64 = "+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=";
let aes_key = base64::decode_block(aes_key_base64).unwrap();
let decrypted_message = decrypt_message(&aes_key, &encrypted_message).unwrap();
assert_eq!(1, decrypted_message.opcode);
assert_eq!("{\"container\":\"text/html\"}", decrypted_message.message.unwrap());
}
#[test]
fn test_aes_key_generation() {
let cases = vec![
(
// Public other
String::from("MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF"),
// Private self
String::from("MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M="),
// Expected AES key
String::from("7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="),
),
(
// Public other
String::from("MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK"),
// Private self
String::from("MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA="),
// Expected AES key
String::from("a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q=")
)
];
for case in cases {
let private_self_key = base64::decode_block(&case.1).expect("Invalid base64 for private self key");
let expected_aes_key = base64::decode_block(&case.2).expect("Invalid base64 for expected AES key");
let private_key = PKey::private_key_from_der(&private_self_key).expect("Failed to create private key");
let dh = private_key.dh().expect("Failed to create DH from private key");
let key_exchange_message = KeyExchangeMessage::new(1, case.0);
let aes_key = compute_shared_secret(&dh, &key_exchange_message).expect("Failed to compute shared secret");
let aes_key_base64 = base64::encode_block(&aes_key);
assert_eq!(aes_key_base64, base64::encode_block(&expected_aes_key), "AES keys do not match");
}
}
} }

View file

@ -3,7 +3,9 @@ mod fcastsession;
mod transport; mod transport;
use clap::{App, Arg, SubCommand}; use clap::{App, Arg, SubCommand};
use native_tls::{TlsConnector, Protocol};
use tiny_http::{Server, Response, ListenAddr, Header}; use tiny_http::{Server, Response, ListenAddr, Header};
use tungstenite::Connector;
use tungstenite::stream::MaybeTlsStream; use tungstenite::stream::MaybeTlsStream;
use url::Url; use url::Url;
use std::net::IpAddr; use std::net::IpAddr;
@ -152,36 +154,68 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
let connection_type = matches.value_of("connection_type").unwrap_or("tcp"); let connection_type = matches.value_of("connection_type").unwrap_or("tcp");
let encrypted = matches.is_present("encrypted");
let port = match matches.value_of("port") { let port = match matches.value_of("port") {
Some(s) => s, Some(s) => s,
_ => match connection_type { _ => match (connection_type, encrypted) {
"tcp" => "46899", ("tcp", false) => "46899",
"ws" => "46898", ("tcp", true) => "46897",
("ws", false) => "46898",
("ws", true) => "46896",
_ => return Err("Unknown connection type, cannot automatically determine port.".into()) _ => return Err("Unknown connection type, cannot automatically determine port.".into())
} }
}; };
let encrypted = matches.is_present("encrypted");
let local_ip: Option<IpAddr>; let local_ip: Option<IpAddr>;
let mut session = match connection_type { let mut session = match (connection_type, encrypted) {
"tcp" => { ("tcp", false) => {
println!("Connecting via TCP to host={} port={}...", host, port); println!("Connecting via TCP to host={} port={}...", host, port);
let stream = TcpStream::connect(format!("{}:{}", host, port))?; let stream = TcpStream::connect(format!("{}:{}", host, port))?;
local_ip = Some(stream.local_addr()?.ip()); local_ip = Some(stream.local_addr()?.ip());
FCastSession::new(stream, encrypted)? FCastSession::new(stream)
}, },
"ws" => { ("tcp", true) => {
println!("Connecting via TCP TLS to host={} port={}...", host, port);
let mut builder = TlsConnector::builder();
builder.min_protocol_version(Some(Protocol::Tlsv12));
builder.danger_accept_invalid_certs(true);
let connector = builder.build()?;
let stream = TcpStream::connect(format!("{}:{}", host, port))?;
let tls_stream = connector.connect(host, stream)?;
local_ip = Some(tls_stream.get_ref().local_addr()?.ip());
FCastSession::new(tls_stream)
},
("ws", false) => {
println!("Connecting via WebSocket to host={} port={}...", host, port); println!("Connecting via WebSocket to host={} port={}...", host, port);
let url = Url::parse(format!("ws://{}:{}", host, port).as_str())?; let url = Url::parse(format!("ws://{}:{}", host, port).as_str())?;
let (stream, _) = tungstenite::connect(url)?; let (stream, _) = tungstenite::connect(url)?;
local_ip = match stream.get_ref() { local_ip = match stream.get_ref() {
MaybeTlsStream::Plain(ref stream) => Some(stream.local_addr()?.ip()), MaybeTlsStream::Plain(ref stream) => Some(stream.local_addr()?.ip()),
_ => None _ => return Err("Established connection type is not plain.".into())
}; };
FCastSession::new(stream, encrypted)? FCastSession::new(stream)
}, },
_ => return Err("Invalid connection type. Use 'tcp' or 'websocket'.".into()), ("ws", true) => {
println!("Connecting via WebSocket to host={} port={}...", host, port);
let mut builder = TlsConnector::builder();
builder.min_protocol_version(Some(Protocol::Tlsv12));
builder.danger_accept_invalid_certs(true);
let connector = builder.build()?;
let url = Url::parse(&format!("wss://{}:{}", host, port))?;
let stream = TcpStream::connect(format!("{}:{}", host, port))?;
let connector = Some(Connector::NativeTls(connector.into()));
let (socket, _) = tungstenite::client_tls_with_config(url, stream, None, connector)?;
local_ip = match socket.get_ref() {
MaybeTlsStream::NativeTls(ref stream) => Some(stream.get_ref().local_addr()?.ip()),
_ => return Err("Expected TLS stream".into()),
};
FCastSession::new(socket)
},
_ => return Err("Invalid connection type or encryption flag.".into()),
}; };
println!("Connection established."); println!("Connection established.");
@ -295,7 +329,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
println!("Waiting for Ctrl+C..."); println!("Waiting for Ctrl+C...");
session.receive_loop(&running, false)?; session.receive_loop(&running)?;
println!("Ctrl+C received, exiting..."); println!("Ctrl+C received, exiting...");
} else if let Some(setvolume_matches) = matches.subcommand_matches("setvolume") { } else if let Some(setvolume_matches) = matches.subcommand_matches("setvolume") {
@ -317,17 +351,6 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1); std::process::exit(1);
} }
let receive_running = Arc::new(AtomicBool::new(true));
let receive_r = receive_running.clone();
ctrlc::set_handler(move || {
println!("Ctrl+C triggered...");
receive_r.store(false, Ordering::SeqCst);
}).expect("Error setting Ctrl-C handler");
println!("Waiting on queues to be empty... press CTRL-C to cancel.");
session.receive_loop(&receive_running, true)?;
println!("Waiting on other threads..."); println!("Waiting on other threads...");
if let Some(v) = join_handle { if let Some(v) = join_handle {
if let Err(_) = v.join() { if let Err(_) = v.join() {

View file

@ -69,42 +69,4 @@ pub struct PlaybackErrorMessage {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct VersionMessage { pub struct VersionMessage {
pub version: u64, pub version: u64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct KeyExchangeMessage {
pub version: u64,
#[serde(rename = "publicKey")]
pub public_key: String,
}
impl KeyExchangeMessage {
pub fn new(version: u64, public_key: String) -> Self {
Self { version, public_key }
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DecryptedMessage {
pub opcode: u64,
pub message: Option<String>,
}
impl DecryptedMessage {
pub fn new(opcode: u64, message: Option<String>) -> Self {
Self { opcode, message }
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct EncryptedMessage {
pub version: u64,
pub iv: Option<String>,
pub blob: String,
}
impl EncryptedMessage {
pub fn new(version: u64, iv: Option<String>, blob: String) -> Self {
Self { version, iv, blob }
}
} }

View file

@ -1,5 +1,6 @@
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
use native_tls::TlsStream;
use tungstenite::Message; use tungstenite::Message;
use tungstenite::protocol::WebSocket; use tungstenite::protocol::WebSocket;
@ -54,3 +55,18 @@ impl<T: Read + Write> Transport for WebSocket<T> {
Ok(()) Ok(())
} }
} }
impl Transport for TlsStream<TcpStream> {
fn transport_read(&mut self, buf: &mut [u8]) -> Result<usize, std::io::Error> {
self.read(buf)
}
fn transport_write(&mut self, buf: &[u8]) -> Result<(), std::io::Error> {
self.write_all(buf)
}
fn transport_shutdown(&mut self) -> Result<(), std::io::Error> {
self.shutdown().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}
}

View file

@ -20,12 +20,12 @@ if (keystorePropertiesFile.exists()) {
android { android {
namespace 'com.futo.fcast.receiver' namespace 'com.futo.fcast.receiver'
compileSdk 33 compileSdk 34
defaultConfig { defaultConfig {
applicationId "com.futo.fcast.receiver" applicationId "com.futo.fcast.receiver"
minSdk 24 minSdk 24
targetSdk 33 targetSdk 34
versionCode currentVersionCode versionCode currentVersionCode
versionName currentVersionName versionName currentVersionName
@ -75,17 +75,22 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '1.8'
} }
buildFeatures {
buildConfig true
}
} }
dependencies { dependencies {
implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.8.0' implementation 'com.google.android.material:material:1.11.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2"
implementation 'com.google.android.exoplayer:exoplayer:2.18.6' implementation 'com.google.android.exoplayer:exoplayer:2.19.1'
implementation "com.squareup.okhttp3:okhttp:4.11.0" implementation "com.squareup.okhttp3:okhttp:4.11.0"
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'org.java-websocket:Java-WebSocket:1.5.4' implementation 'org.java-websocket:Java-WebSocket:1.5.4'
implementation 'org.bouncycastle:bcpkix-jdk18on:1.77'
implementation 'org.bouncycastle:bcprov-jdk18on:1.77'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

View file

@ -1,113 +0,0 @@
package com.futo.fcast.receiver
import android.util.Base64
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import javax.crypto.interfaces.DHPrivateKey
import javax.crypto.spec.SecretKeySpec
@RunWith(AndroidJUnit4::class)
class EncryptionTest {
@Test
fun testDHEncryptionSelf() {
val keyPair1 = FCastSession.generateKeyPair()
val keyPair2 = FCastSession.generateKeyPair()
Log.i("testDHEncryptionSelf", "privates (1: ${Base64.encodeToString(keyPair1.private.encoded, Base64.NO_WRAP)}, 2: ${Base64.encodeToString(keyPair2.private.encoded, Base64.NO_WRAP)})")
val keyExchangeMessage1 = FCastSession.getKeyExchangeMessage(keyPair1)
val keyExchangeMessage2 = FCastSession.getKeyExchangeMessage(keyPair2)
Log.i("testDHEncryptionSelf", "publics (1: ${keyExchangeMessage1.publicKey}, 2: ${keyExchangeMessage2.publicKey})")
val aesKey1 = FCastSession.computeSharedSecret(keyPair1.private, keyExchangeMessage2)
val aesKey2 = FCastSession.computeSharedSecret(keyPair2.private, keyExchangeMessage1)
assertEquals(Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP), Base64.encodeToString(aesKey2.encoded, Base64.NO_WRAP))
Log.i("testDHEncryptionSelf", "aesKey ${Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP)}")
val message = PlayMessage("text/html")
val serializedBody = Json.encodeToString(message)
val encryptedMessage = FCastSession.encryptMessage(aesKey1, DecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
Log.i("testDHEncryptionSelf", Json.encodeToString(encryptedMessage))
val decryptedMessage = FCastSession.decryptMessage(aesKey1, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals(serializedBody, decryptedMessage.message)
}
@Test
fun testAESKeyGeneration() {
val cases = listOf(
listOf(
//Public other
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
//Private self
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
//AES
"7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
),
listOf(
//Public other
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
//Private self
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
//AES
"a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
)
)
for (case in cases) {
val decodedPrivateKey1 = Base64.decode(case[1], Base64.NO_WRAP)
val keyExchangeMessage2 = KeyExchangeMessage(1, case[0])
val keyFactory = KeyFactory.getInstance("DH")
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
val aesKey1 = FCastSession.computeSharedSecret(privateKey, keyExchangeMessage2)
assertEquals(case[2], Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
}
}
@Test
fun testDHEncryptionKnown() {
val decodedPrivateKey1 = Base64.decode("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==", Base64.NO_WRAP)
val keyExchangeMessage2 = KeyExchangeMessage(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=")
val keyFactory = KeyFactory.getInstance("DH")
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
val aesKey1 = FCastSession.computeSharedSecret(privateKey, keyExchangeMessage2)
assertEquals("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=", Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
val message = PlayMessage("text/html")
val serializedBody = Json.encodeToString(message)
val encryptedMessage = FCastSession.encryptMessage(aesKey1, DecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
val decryptedMessage = FCastSession.decryptMessage(aesKey1, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals(serializedBody, decryptedMessage.message)
}
@Test
fun testDecryptMessageKnown() {
val encryptedMessage = Json.decodeFromString<EncryptedMessage>("{\"version\":1,\"iv\":\"C4H70VC5FWrNtkty9/cLIA==\",\"blob\":\"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA==\"}")
val aesKey = SecretKeySpec(Base64.decode("+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=", Base64.NO_WRAP), "AES")
val decryptedMessage = FCastSession.decryptMessage(aesKey, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals("{\"container\":\"text/html\"}", decryptedMessage.message)
}
}

View file

@ -42,13 +42,10 @@ enum class Opcode(val value: Byte) {
VolumeUpdate(7), VolumeUpdate(7),
SetVolume(8), SetVolume(8),
PlaybackError(9), PlaybackError(9),
SetSpeed(10), SetSpeed(10),
Version(11), Version(11),
KeyExchange(12), Ping(12),
Encrypted(13), Pong(13);
Ping(14),
Pong(15),
StartEncryption(16);
companion object { companion object {
private val _map = values().associateBy { it.value } private val _map = values().associateBy { it.value }
@ -65,39 +62,9 @@ class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress:
private var _packetLength = 0 private var _packetLength = 0
private var _state = SessionState.WaitingForLength private var _state = SessionState.WaitingForLength
private var _outputStream: DataOutputStream? = DataOutputStream(outputStream) private var _outputStream: DataOutputStream? = DataOutputStream(outputStream)
private val _keyPair: KeyPair = generateKeyPair()
private var _aesKey: SecretKeySpec? = null
private val _queuedEncryptedMessages = arrayListOf<EncryptedMessage>()
private var _encryptionStarted = false
val id = UUID.randomUUID() val id = UUID.randomUUID()
init { fun send(opcode: Opcode, message: String? = null) {
send(Opcode.KeyExchange, getKeyExchangeMessage(_keyPair))
}
fun sendVersion(value: VersionMessage) {
send(Opcode.Version, value)
}
fun sendPlaybackError(value: PlaybackErrorMessage) {
send(Opcode.PlaybackError, value)
}
fun sendPlaybackUpdate(value: PlaybackUpdateMessage) {
send(Opcode.PlaybackUpdate, value)
}
fun sendVolumeUpdate(value: VolumeUpdateMessage) {
send(Opcode.VolumeUpdate, value)
}
private fun send(opcode: Opcode, message: String? = null) {
val aesKey = _aesKey
if (_encryptionStarted && aesKey != null && opcode != Opcode.Encrypted && opcode != Opcode.KeyExchange && opcode != Opcode.StartEncryption) {
send(Opcode.Encrypted, encryptMessage(aesKey, DecryptedMessage(opcode.value.toLong(), message)))
return
}
try { try {
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0) val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size val size = 1 + data.size
@ -129,7 +96,7 @@ class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress:
} }
} }
private inline fun <reified T> send(opcode: Opcode, message: T) { inline fun <reified T> send(opcode: Opcode, message: T) {
try { try {
send(opcode, message?.let { Json.encodeToString(it) }) send(opcode, message?.let { Json.encodeToString(it) })
} catch (e: Throwable) { } catch (e: Throwable) {
@ -246,42 +213,7 @@ class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress:
Opcode.Seek -> _service.onCastSeek(json.decodeFromString(body!!)) Opcode.Seek -> _service.onCastSeek(json.decodeFromString(body!!))
Opcode.SetVolume -> _service.onSetVolume(json.decodeFromString(body!!)) Opcode.SetVolume -> _service.onSetVolume(json.decodeFromString(body!!))
Opcode.SetSpeed -> _service.onSetSpeed(json.decodeFromString(body!!)) Opcode.SetSpeed -> _service.onSetSpeed(json.decodeFromString(body!!))
Opcode.KeyExchange -> {
val keyExchangeMessage: KeyExchangeMessage = json.decodeFromString(body!!)
_aesKey = computeSharedSecret(_keyPair.private, keyExchangeMessage)
send(Opcode.StartEncryption)
synchronized(_queuedEncryptedMessages) {
for (queuedEncryptedMessages in _queuedEncryptedMessages) {
val decryptedMessage = decryptMessage(_aesKey!!, queuedEncryptedMessages)
val o = Opcode.find(decryptedMessage.opcode.toByte())
handlePacket(o, decryptedMessage.message)
}
_queuedEncryptedMessages.clear()
}
}
Opcode.Ping -> send(Opcode.Pong) Opcode.Ping -> send(Opcode.Pong)
Opcode.Encrypted -> {
val encryptedMessage: EncryptedMessage = json.decodeFromString(body!!)
if (_aesKey != null) {
val decryptedMessage = decryptMessage(_aesKey!!, encryptedMessage)
val o = Opcode.find(decryptedMessage.opcode.toByte())
handlePacket(o, decryptedMessage.message)
} else {
synchronized(_queuedEncryptedMessages) {
if (_queuedEncryptedMessages.size == 15) {
_queuedEncryptedMessages.removeAt(0)
}
_queuedEncryptedMessages.add(encryptedMessage)
}
}
}
Opcode.StartEncryption -> {
_encryptionStarted = true
//TODO: Send decrypted messages waiting for encryption to be established
}
else -> { } else -> { }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@ -292,63 +224,5 @@ class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress:
companion object { companion object {
const val TAG = "FCastSession" const val TAG = "FCastSession"
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
fun getKeyExchangeMessage(keyPair: KeyPair): KeyExchangeMessage {
return KeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
}
fun generateKeyPair(): KeyPair {
//modp14
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
val g = BigInteger("2", 16)
val dhSpec = DHParameterSpec(p, g)
val keyGen = KeyPairGenerator.getInstance("DH")
keyGen.initialize(dhSpec)
return keyGen.generateKeyPair()
}
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: KeyExchangeMessage): SecretKeySpec {
val keyFactory = KeyFactory.getInstance("DH")
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
val keyAgreement = KeyAgreement.getInstance("DH")
keyAgreement.init(privateKey)
keyAgreement.doPhase(receivedPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
val sha256 = MessageDigest.getInstance("SHA-256")
val hashedSecret = sha256.digest(sharedSecret)
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
return SecretKeySpec(hashedSecret, "AES")
}
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: DecryptedMessage): EncryptedMessage {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
val iv = cipher.iv
val json = Json.encodeToString(decryptedMessage)
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
return EncryptedMessage(
version = 1,
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
)
}
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: EncryptedMessage): DecryptedMessage {
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
val decryptedJson = cipher.doFinal(encrypted)
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
}
} }
} }

View file

@ -1,5 +1,6 @@
package com.futo.fcast.receiver package com.futo.fcast.receiver
import SslKeyManager
import WebSocketListenerService import WebSocketListenerService
import android.app.* import android.app.*
import android.content.Context import android.content.Context
@ -11,13 +12,16 @@ import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.bouncycastle.jce.provider.BouncyCastleProvider
import java.security.Security
class NetworkService : Service() { class NetworkService : Service() {
private var _discoveryService: DiscoveryService? = null private var _discoveryService: DiscoveryService? = null
private var _stopped = false
private var _tcpListenerService: TcpListenerService? = null private var _tcpListenerService: TcpListenerService? = null
private var _tlsListenerService: TlsListenerService? = null
private var _webSocketListenerService: WebSocketListenerService? = null private var _webSocketListenerService: WebSocketListenerService? = null
private var _scope: CoroutineScope? = null private var _scope: CoroutineScope? = null
private var _stopped = false
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {
return null return null
@ -63,7 +67,7 @@ class NetworkService : Service() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
Log.i(TAG, "Sending version ${session.id}") Log.i(TAG, "Sending version ${session.id}")
session.sendVersion(VersionMessage(2)) session.send(Opcode.Version, VersionMessage(2))
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Failed to send version ${session.id}") Log.e(TAG, "Failed to send version ${session.id}")
} }
@ -75,7 +79,7 @@ class NetworkService : Service() {
val updateMessage = generateUpdateMessage() val updateMessage = generateUpdateMessage()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
session.sendPlaybackUpdate(updateMessage) session.send(Opcode.PlaybackUpdate, updateMessage)
Log.i(TAG, "Update sent ${session.id}") Log.i(TAG, "Update sent ${session.id}")
} catch (eSend: Throwable) { } catch (eSend: Throwable) {
Log.e(TAG, "Unhandled error sending update ${session.id}", eSend) Log.e(TAG, "Unhandled error sending update ${session.id}", eSend)
@ -106,12 +110,18 @@ class NetworkService : Service() {
start() start()
} }
val sslKeyManager = SslKeyManager("fcast_receiver")
_tlsListenerService = TlsListenerService(this) { onNewSession(it) }.apply {
start(sslKeyManager)
}
Log.i(TAG, "Started NetworkService") Log.i(TAG, "Started NetworkService")
Toast.makeText(this, "Started FCast service", Toast.LENGTH_LONG).show() Toast.makeText(this, "Started FCast service", Toast.LENGTH_LONG).show()
return START_STICKY return START_STICKY
} }
@Suppress("DEPRECATION")
private fun createNotificationBuilder(): NotificationCompat.Builder { private fun createNotificationBuilder(): NotificationCompat.Builder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
@ -134,6 +144,9 @@ class NetworkService : Service() {
_tcpListenerService?.stop() _tcpListenerService?.stop()
_tcpListenerService = null _tcpListenerService = null
_tlsListenerService?.stop()
_tlsListenerService = null
try { try {
_webSocketListenerService?.stop() _webSocketListenerService?.stop()
} catch (e: Throwable) { } catch (e: Throwable) {
@ -170,12 +183,11 @@ class NetworkService : Service() {
} }
} }
fun sendPlaybackError(error: String) { private inline fun <reified T> send(opcode: Opcode, message: T) {
val message = PlaybackErrorMessage(error) val sender: (FCastSession) -> Unit = { session: FCastSession ->
_tcpListenerService?.forEachSession { session ->
_scope?.launch(Dispatchers.IO) { _scope?.launch(Dispatchers.IO) {
try { try {
session.sendPlaybackError(message) session.send(opcode, message)
Log.i(TAG, "Playback error sent ${session.id}") Log.i(TAG, "Playback error sent ${session.id}")
} catch (e: Throwable) { } catch (e: Throwable) {
Log.w(TAG, "Failed to send playback error", e) Log.w(TAG, "Failed to send playback error", e)
@ -183,64 +195,22 @@ class NetworkService : Service() {
} }
} }
_webSocketListenerService?.forEachSession { session -> _tcpListenerService?.forEachSession(sender)
_scope?.launch(Dispatchers.IO) { _webSocketListenerService?.forEachSession(sender)
try { _tlsListenerService?.forEachSession(sender)
session.sendPlaybackError(message) }
Log.i(TAG, "Playback error sent ${session.id}")
} catch (e: Throwable) { fun sendPlaybackError(error: String) {
Log.w(TAG, "Failed to send playback error", e) val message = PlaybackErrorMessage(error)
} send(Opcode.PlaybackError, message)
}
}
} }
fun sendPlaybackUpdate(message: PlaybackUpdateMessage) { fun sendPlaybackUpdate(message: PlaybackUpdateMessage) {
_tcpListenerService?.forEachSession { session -> send(Opcode.PlaybackUpdate, message)
_scope?.launch(Dispatchers.IO) {
try {
session.sendPlaybackUpdate(message)
Log.i(TAG, "Playback update sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send playback update", e)
}
}
}
_webSocketListenerService?.forEachSession { session ->
_scope?.launch(Dispatchers.IO) {
try {
session.sendPlaybackUpdate(message)
Log.i(TAG, "Playback update sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send playback update", e)
}
}
}
} }
fun sendCastVolumeUpdate(value: VolumeUpdateMessage) { fun sendCastVolumeUpdate(value: VolumeUpdateMessage) {
_tcpListenerService?.forEachSession { session -> send(Opcode.VolumeUpdate, value)
_scope?.launch(Dispatchers.IO) {
try {
session.sendVolumeUpdate(value)
Log.i(TAG, "Volume update sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send volume update", e)
}
}
}
_webSocketListenerService?.forEachSession { session ->
_scope?.launch(Dispatchers.IO) {
try {
session.sendVolumeUpdate(value)
Log.i(TAG, "Volume update sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send volume update", e)
}
}
}
} }
fun onCastPlay(playMessage: PlayMessage) { fun onCastPlay(playMessage: PlayMessage) {
@ -361,7 +331,7 @@ class NetworkService : Service() {
private const val CHANNEL_ID = "NetworkListenerServiceChannel" private const val CHANNEL_ID = "NetworkListenerServiceChannel"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val PLAY_NOTIFICATION_ID = 2 private const val PLAY_NOTIFICATION_ID = 2
private const val TAG = "NetworkService" const val TAG = "NetworkService"
var activityCount = 0 var activityCount = 0
var instance: NetworkService? = null var instance: NetworkService? = null
} }

View file

@ -49,23 +49,4 @@ data class SetVolumeMessage(
@Serializable @Serializable
data class VersionMessage( data class VersionMessage(
val version: Long val version: Long
)
@Serializable
data class KeyExchangeMessage(
val version: Long,
val publicKey: String
)
@Serializable
data class DecryptedMessage(
val opcode: Long,
val message: String?
)
@Serializable
data class EncryptedMessage(
val version: Long,
val iv: String?,
val blob: String
) )

View file

@ -0,0 +1,80 @@
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.cert.X509v3CertificateBuilder
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import java.io.FileInputStream
import java.math.BigInteger
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.PublicKey
import java.util.Calendar
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLServerSocketFactory
import java.security.cert.X509Certificate
import javax.net.ssl.TrustManagerFactory
class SslKeyManager(private val alias: String) {
fun getSslServerSocketFactory(): SSLServerSocketFactory {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
//if (!keyStore.containsAlias(alias)) {
generateKeyPairAndCertificate(keyStore)
//}
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
init(keyStore)
}
val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
init(keyStore, null)
}
val sslContext = SSLContext.getInstance("TLS").apply {
init(keyManagerFactory.keyManagers, trustManagerFactory.trustManagers, null)
}
return sslContext.serverSocketFactory
}
private fun generateKeyPairAndCertificate(keyStore: KeyStore) {
val keyPairGenerator = KeyPairGenerator.getInstance("RSA", "AndroidKeyStore")
val parameterSpec = KeyGenParameterSpec
.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY)
//.setBlockModes(KeyProperties.BLOCK_MODE_ECB)
.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
.build()
keyPairGenerator.initialize(parameterSpec)
val keyPair = keyPairGenerator.generateKeyPair()
val privateKey = keyPair.private
val publicKey = keyPair.public
val cert = generateSelfSignedCertificate(privateKey, publicKey)
keyStore.setKeyEntry(alias, privateKey, null, arrayOf(cert))
}
private fun generateSelfSignedCertificate(privateKey: PrivateKey, publicKey: PublicKey): X509Certificate {
val start = Calendar.getInstance().time
val end = Calendar.getInstance().apply { add(Calendar.YEAR, 1000) }.time
val certInfo = X509v3CertificateBuilder(
X500Name("CN=FCastReceiver"),
BigInteger.ONE,
start,
end,
X500Name("CN=FCastReceiver"),
SubjectPublicKeyInfo.getInstance(publicKey.encoded)
)
val signer = JcaContentSignerBuilder("SHA256withRSA").build(privateKey)
return JcaX509CertificateConverter().getCertificate(certInfo.build(signer))
}
}

View file

@ -0,0 +1,134 @@
package com.futo.fcast.receiver
import SslKeyManager
import android.util.Log
import java.io.BufferedInputStream
import java.net.Socket
import java.security.KeyStore
import java.security.cert.Certificate
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLServerSocket
import javax.net.ssl.TrustManagerFactory
class TlsListenerService(private val _networkService: NetworkService, private val _onNewSession: (session: FCastSession) -> Unit) {
private var _serverSocket: SSLServerSocket? = null
private var _stopped: Boolean = false
private var _listenThread: Thread? = null
private var _clientThreads: ArrayList<Thread> = arrayListOf()
private var _sessions: ArrayList<FCastSession> = arrayListOf()
fun start(sslKeyManager: SslKeyManager) {
Log.i(TAG, "Starting TlsListenerService")
val serverSocketFactory = sslKeyManager.getSslServerSocketFactory()
_serverSocket = (serverSocketFactory.createServerSocket(PORT) as SSLServerSocket)
_listenThread = Thread {
Log.i(TAG, "Starting TLS listener")
try {
listenForIncomingConnections()
} catch (e: Throwable) {
Log.e(TAG, "Stopped TLS listening for connections due to an unexpected error", e)
}
}
_listenThread?.start()
Log.i(TAG, "Started TlsListenerService")
}
fun stop() {
Log.i(TAG, "Stopping TlsListenerService")
_stopped = true
_serverSocket?.close()
_serverSocket = null
_listenThread?.join()
_listenThread = null
synchronized(_clientThreads) {
_clientThreads.clear()
}
Log.i(TAG, "Stopped TlsListenerService")
}
fun forEachSession(handler: (FCastSession) -> Unit) {
synchronized(_sessions) {
for (session in _sessions) {
handler(session)
}
}
}
private fun listenForIncomingConnections() {
Log.i(TAG, "Started TLS listening for incoming connections")
while (!_stopped) {
val clientSocket = _serverSocket?.accept() ?: break
val clientThread = Thread {
try {
handleClientConnection(clientSocket)
} catch (e: Throwable) {
Log.e(TAG, "Failed handle TLS client connection due to an error", e)
}
}
synchronized(_clientThreads) {
_clientThreads.add(clientThread)
}
clientThread.start()
}
Log.i(TAG, "Stopped TLS listening for incoming connections")
}
private fun handleClientConnection(socket: Socket) {
Log.i(TAG, "New TLS connection received from ${socket.remoteSocketAddress}")
val session = FCastSession(socket.getOutputStream(), socket.remoteSocketAddress, _networkService)
synchronized(_sessions) {
_sessions.add(session)
}
_onNewSession(session)
Log.i(TAG, "Waiting for data from ${socket.remoteSocketAddress}")
val bufferSize = 4096
val buffer = ByteArray(bufferSize)
val inputStream = BufferedInputStream(socket.getInputStream())
var bytesRead: Int
while (!_stopped) {
bytesRead = inputStream.read(buffer, 0, bufferSize)
if (bytesRead == -1) {
break
}
session.processBytes(buffer, bytesRead)
}
socket.close()
synchronized(_sessions) {
_sessions.remove(session)
}
synchronized(_clientThreads) {
_clientThreads.remove(Thread.currentThread())
}
Log.i(TAG, "Disconnected ${socket.remoteSocketAddress}")
}
companion object {
const val TAG = "TlsListenerService"
const val PORT = 46897
}
}

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '7.4.2' apply false id 'com.android.application' version '8.2.0' apply false
id 'com.android.library' version '7.4.2' apply false id 'com.android.library' version '8.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
} }

View file

@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the # Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.nonFinalResIds=false

View file

@ -1,6 +1,6 @@
#Mon May 01 06:24:43 CDT 2023 #Mon May 01 06:24:43 CDT 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View file

@ -10,15 +10,16 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
"crypto": "^1.0.1", "https": "^1.0.0",
"libsodium-wrappers": "^0.7.13", "node-forge": "^1.3.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"utf-8-validate": "^6.0.3", "tls": "^0.0.1",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/libsodium-wrappers": "^0.7.13", "@types/mdns": "^0.0.38",
"@types/node-forge": "^1.3.10",
"@types/workerpool": "^6.1.1", "@types/workerpool": "^6.1.1",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"electron": "^22.2.0", "electron": "^22.2.0",
@ -1311,11 +1312,14 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/libsodium-wrappers": { "node_modules/@types/mdns": {
"version": "0.7.13", "version": "0.0.38",
"resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.13.tgz", "resolved": "https://registry.npmjs.org/@types/mdns/-/mdns-0.0.38.tgz",
"integrity": "sha512-KeAKtlObirLJk/na6jHBFEdTDjDfFS6Vcr0eG2FjiHKn3Nw8axJFfIu0Y9TpwaauRldQBj/pZm/MHtK76r6OWg==", "integrity": "sha512-uiDl+FWeO2JYStfiPsyPpU7bHK3VYETquPo3A8bj6h2+iqDIEfXpaZaLvyGDGL9ilcrGc1vp+ek3Ab+QtDBXPA==",
"dev": true "dev": true,
"dependencies": {
"@types/node": "*"
}
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "18.13.0", "version": "18.13.0",
@ -1323,6 +1327,15 @@
"integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==", "integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==",
"dev": true "dev": true
}, },
"node_modules/@types/node-forge": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.10.tgz",
"integrity": "sha512-y6PJDYN4xYBxwd22l+OVH35N+1fCYWiuC3aiP2SlXVE6Lo7SS+rSx9r89hLxrP4pn6n1lBGhHJ12pj3F3Mpttw==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/responselike": { "node_modules/@types/responselike": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
@ -2174,12 +2187,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in."
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@ -2970,6 +2977,11 @@
"node": ">=10.19.0" "node": ">=10.19.0"
} }
}, },
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="
},
"node_modules/human-signals": { "node_modules/human-signals": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -4060,19 +4072,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/libsodium": {
"version": "0.7.13",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.13.tgz",
"integrity": "sha512-mK8ju0fnrKXXfleL53vtp9xiPq5hKM0zbDQtcxQIsSmxNgSxqCj6R7Hl9PkrNe2j29T4yoDaF7DJLK9/i5iWUw=="
},
"node_modules/libsodium-wrappers": {
"version": "0.7.13",
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.13.tgz",
"integrity": "sha512-kasvDsEi/r1fMzKouIDv7B8I6vNmknXwGiYodErGuESoFTohGSKZplFtVxZqHaoQ217AynyIFgnOVRitpHs0Qw==",
"dependencies": {
"libsodium": "^0.7.13"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -4310,6 +4309,14 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true "dev": true
}, },
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/node-gyp-build": { "node_modules/node-gyp-build": {
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.7.1.tgz", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.7.1.tgz",
@ -5151,6 +5158,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tls": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/tls/-/tls-0.0.1.tgz",
"integrity": "sha512-GzHpG+hwupY8VMR6rYsnAhTHqT/97zT45PG8WD5eTT1lq+dFE0nN+1PYpsoBcHJgSmTz5ceK2Cv88IkPmIPOtQ=="
},
"node_modules/tmpl": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -5367,6 +5379,8 @@
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.3.tgz", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.3.tgz",
"integrity": "sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==", "integrity": "sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==",
"hasInstallScript": true, "hasInstallScript": true,
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"node-gyp-build": "^4.3.0" "node-gyp-build": "^4.3.0"
}, },

View file

@ -12,6 +12,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/mdns": "^0.0.38",
"@types/node-forge": "^1.3.10",
"@types/workerpool": "^6.1.1", "@types/workerpool": "^6.1.1",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"electron": "^22.2.0", "electron": "^22.2.0",
@ -25,9 +27,10 @@
}, },
"dependencies": { "dependencies": {
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
"crypto": "^1.0.1", "https": "^1.0.0",
"node-forge": "^1.3.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"utf-8-validate": "^6.0.3", "tls": "^0.0.1",
"ws": "^8.14.2" "ws": "^8.14.2"
} }
} }

View file

@ -3,7 +3,10 @@ const cp = require('child_process');
const os = require('os'); const os = require('os');
export class DiscoveryService { export class DiscoveryService {
private service: any; private serviceTcp: any;
private serviceTls: any;
private serviceWs: any;
private serviceWss: any;
private static getComputerName() { private static getComputerName() {
switch (process.platform) { switch (process.platform) {
@ -20,23 +23,42 @@ export class DiscoveryService {
} }
start() { start() {
if (this.service) { if (this.serviceTcp || this.serviceTls || this.serviceWs || this.serviceWss) {
return; return;
} }
const name = `FCast-${DiscoveryService.getComputerName()}`; const name = `FCast-${DiscoveryService.getComputerName()}`;
console.log("Discovery service started.", name); console.log("Discovery service started.", name);
this.service = mdns.createAdvertisement(mdns.tcp('_fcast'), 46899, { name: name }); this.serviceTcp = mdns.createAdvertisement(mdns.tcp('_fcast'), 46899, { name: name });
this.service.start(); this.serviceTcp.start();
this.serviceTls = mdns.createAdvertisement(mdns.tcp('_fcast-tls'), 46897, { name: name });
this.serviceTls.start();
this.serviceWs = mdns.createAdvertisement(mdns.tcp('_fcast-ws'), 46898, { name: name });
this.serviceWs.start();
this.serviceWss = mdns.createAdvertisement(mdns.tcp('_fcast-wss'), 46896, { name: name });
this.serviceWss.start();
} }
stop() { stop() {
if (!this.service) { if (this.serviceTcp) {
return; this.serviceTcp.stop();
this.serviceTcp = null;
} }
this.service.stop(); if (this.serviceTls) {
this.service = null; this.serviceTls.stop();
this.serviceTls = null;
}
if (this.serviceWs) {
this.serviceWs.stop();
this.serviceWs = null;
}
if (this.serviceWss) {
this.serviceWss.stop();
this.serviceWss = null;
}
} }
} }

View file

@ -1,7 +1,6 @@
import net = require('net'); import net = require('net');
import * as crypto from 'crypto';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { DecryptedMessage, EncryptedMessage, KeyExchangeMessage, PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from './Packets'; import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VersionMessage, VolumeUpdateMessage } from './Packets';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
enum SessionState { enum SessionState {
@ -24,11 +23,8 @@ export enum Opcode {
PlaybackError = 9, PlaybackError = 9,
SetSpeed = 10, SetSpeed = 10,
Version = 11, Version = 11,
KeyExchange = 12, Ping = 12,
Encrypted = 13, Pong = 13
Ping = 14,
Pong = 15,
StartEncryption = 16
}; };
const LENGTH_BYTES = 4; const LENGTH_BYTES = 4;
@ -42,52 +38,17 @@ export class FCastSession {
writer: (data: Buffer) => void; writer: (data: Buffer) => void;
state: SessionState; state: SessionState;
emitter = new EventEmitter(); emitter = new EventEmitter();
encryptionStarted = false;
private aesKey: Buffer;
private dh: crypto.DiffieHellman;
private queuedEncryptedMessages: EncryptedMessage[] = [];
constructor(socket: net.Socket | WebSocket, writer: (data: Buffer) => void) { constructor(socket: net.Socket | WebSocket, writer: (data: Buffer) => void) {
this.socket = socket; this.socket = socket;
this.writer = writer; this.writer = writer;
this.state = SessionState.WaitingForLength; this.state = SessionState.WaitingForLength;
this.dh = generateKeyPair();
const keyExchangeMessage = getKeyExchangeMessage(this.dh);
console.log(`Sending KeyExchangeMessage: ${keyExchangeMessage}`);
this.send(Opcode.KeyExchange, keyExchangeMessage);
} }
sendVersion(value: VersionMessage) { send(opcode: number, message = null) {
this.send(Opcode.Version, value);
}
sendPlaybackError(value: PlaybackErrorMessage) {
this.send(Opcode.PlaybackError, value);
}
sendPlaybackUpdate(value: PlaybackUpdateMessage) {
this.send(Opcode.PlaybackUpdate, value);
}
sendVolumeUpdate(value: VolumeUpdateMessage) {
this.send(Opcode.VolumeUpdate, value);
}
private send(opcode: number, message = null) {
if (this.encryptionStarted && opcode != Opcode.Encrypted && opcode != Opcode.KeyExchange && opcode != Opcode.StartEncryption) {
const decryptedMessage: DecryptedMessage = {
opcode,
message
};
this.send(Opcode.Encrypted, encryptMessage(this.aesKey, decryptedMessage));
return;
}
const json = message ? JSON.stringify(message) : null; const json = message ? JSON.stringify(message) : null;
console.log(`send (opcode: ${opcode}, body: ${json})`);
let data: Uint8Array; let data: Uint8Array;
if (json) { if (json) {
const utf8Encode = new TextEncoder(); const utf8Encode = new TextEncoder();
@ -215,35 +176,9 @@ export class FCastSession {
case Opcode.SetSpeed: case Opcode.SetSpeed:
this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage); this.emitter.emit("setspeed", JSON.parse(body) as SetSpeedMessage);
break; break;
case Opcode.KeyExchange:
const keyExchangeMessage = JSON.parse(body) as KeyExchangeMessage;
this.aesKey = computeSharedSecret(this.dh, keyExchangeMessage);
this.send(Opcode.StartEncryption);
for (const encryptedMessage of this.queuedEncryptedMessages) {
const decryptedMessage = decryptMessage(this.aesKey, encryptedMessage);
this.handlePacket(decryptedMessage.opcode, decryptedMessage.message);
}
this.queuedEncryptedMessages = [];
break;
case Opcode.Ping: case Opcode.Ping:
this.send(Opcode.Pong); this.send(Opcode.Pong);
break; break;
case Opcode.Encrypted:
const encryptedMessage = JSON.parse(body) as EncryptedMessage;
if (this.aesKey) {
const decryptedMessage = decryptMessage(this.aesKey, encryptedMessage);
this.handlePacket(decryptedMessage.opcode, decryptedMessage.message);
} else {
if (this.queuedEncryptedMessages.length === 15) {
this.queuedEncryptedMessages.shift();
}
this.queuedEncryptedMessages.push(encryptedMessage);
}
break;
} }
} catch (e) { } catch (e) {
console.warn(`Error handling packet from.`, e); console.warn(`Error handling packet from.`, e);
@ -258,52 +193,14 @@ export class FCastSession {
console.log('body', body); console.log('body', body);
this.handlePacket(opcode, body); this.handlePacket(opcode, body);
} }
}
export function getKeyExchangeMessage(dh: crypto.DiffieHellman): KeyExchangeMessage { bindEvents(emitter: EventEmitter) {
return { version: 1, publicKey: dh.getPublicKey().toString('base64') }; this.emitter.on("play", (body: PlayMessage) => { emitter.emit("play", body) });
} this.emitter.on("pause", () => { emitter.emit("pause") });
this.emitter.on("resume", () => { emitter.emit("resume") });
export function computeSharedSecret(dh: crypto.DiffieHellman, keyExchangeMessage: KeyExchangeMessage): Buffer { this.emitter.on("stop", () => { emitter.emit("stop") });
console.log("private", dh.getPrivateKey().toString('base64')); this.emitter.on("seek", (body: SeekMessage) => { emitter.emit("seek", body) });
this.emitter.on("setvolume", (body: SetVolumeMessage) => { emitter.emit("setvolume", body) });
const theirPublicKey = Buffer.from(keyExchangeMessage.publicKey, 'base64'); this.emitter.on("setspeed", (body: SetSpeedMessage) => { emitter.emit("setspeed", body) });
console.log("theirPublicKey", theirPublicKey.toString('base64')); }
const secret = dh.computeSecret(theirPublicKey);
console.log("secret", secret.toString('base64'));
const digest = crypto.createHash('sha256').update(secret).digest();
console.log("digest", digest.toString('base64'));
return digest;
}
export function encryptMessage(aesKey: Buffer, decryptedMessage: DecryptedMessage): EncryptedMessage {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
let encrypted = cipher.update(JSON.stringify(decryptedMessage), 'utf8', 'base64');
encrypted += cipher.final('base64');
return {
version: 1,
iv: iv.toString('base64'),
blob: encrypted
};
}
export function decryptMessage(aesKey: Buffer, encryptedMessage: EncryptedMessage): DecryptedMessage {
const iv = Buffer.from(encryptedMessage.iv, 'base64');
const decipher = crypto.createDecipheriv('aes-256-cbc', aesKey, iv);
let decrypted = decipher.update(encryptedMessage.blob, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted) as DecryptedMessage;
}
export function generateKeyPair() {
const dh = createDiffieHellman();
dh.generateKeys();
return dh;
}
export function createDiffieHellman(): crypto.DiffieHellman {
const p = Buffer.from('ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff', 'hex');
const g = Buffer.from('02', 'hex');
return crypto.createDiffieHellman(p, g);
} }

View file

@ -6,7 +6,11 @@ import { DiscoveryService } from './DiscoveryService';
import { Updater } from './Updater'; import { Updater } from './Updater';
import { WebSocketListenerService } from './WebSocketListenerService'; import { WebSocketListenerService } from './WebSocketListenerService';
import * as os from 'os'; import * as os from 'os';
import * as sodium from 'libsodium-wrappers'; import { Opcode } from './FCastSession';
import fs = require('fs');
import forge = require('node-forge');
import { TlsListenerService } from './TlsTcpListenerService';
import { WebSocketSecureListenerService } from './WebSocketSecureListenerService';
export default class Main { export default class Main {
static shouldOpenMainWindow = true; static shouldOpenMainWindow = true;
@ -15,8 +19,12 @@ export default class Main {
static application: Electron.App; static application: Electron.App;
static tcpListenerService: TcpListenerService; static tcpListenerService: TcpListenerService;
static webSocketListenerService: WebSocketListenerService; static webSocketListenerService: WebSocketListenerService;
static tlsListenerService: TlsListenerService;
static webSocketSecureListenerService: WebSocketSecureListenerService;
static discoveryService: DiscoveryService; static discoveryService: DiscoveryService;
static tray: Tray; static tray: Tray;
static key: string = null;
static cert: string = null;
private static createTray() { private static createTray() {
const icon = (process.platform === 'win32') ? path.join(__dirname, 'app.ico') : path.join(__dirname, 'app.png'); const icon = (process.platform === 'win32') ? path.join(__dirname, 'app.ico') : path.join(__dirname, 'app.png');
@ -100,8 +108,10 @@ export default class Main {
Main.tcpListenerService = new TcpListenerService(); Main.tcpListenerService = new TcpListenerService();
Main.webSocketListenerService = new WebSocketListenerService(); Main.webSocketListenerService = new WebSocketListenerService();
const listeners = [Main.tcpListenerService, Main.webSocketListenerService]; Main.tlsListenerService = new TlsListenerService(Main.key, Main.cert);
Main.webSocketSecureListenerService = new WebSocketSecureListenerService(Main.key, Main.cert);
const listeners = [Main.tcpListenerService, Main.webSocketListenerService, Main.tlsListenerService, Main.webSocketSecureListenerService];
listeners.forEach(l => { listeners.forEach(l => {
l.emitter.on("play", (message) => { l.emitter.on("play", (message) => {
if (Main.playerWindow == null) { if (Main.playerWindow == null) {
@ -142,15 +152,15 @@ export default class Main {
l.start(); l.start();
ipcMain.on('send-playback-error', (event: IpcMainEvent, value: PlaybackErrorMessage) => { ipcMain.on('send-playback-error', (event: IpcMainEvent, value: PlaybackErrorMessage) => {
l.sendPlaybackError(value); l.send(Opcode.PlaybackError, value);
}); });
ipcMain.on('send-playback-update', (event: IpcMainEvent, value: PlaybackUpdateMessage) => { ipcMain.on('send-playback-update', (event: IpcMainEvent, value: PlaybackUpdateMessage) => {
l.sendPlaybackUpdate(value); l.send(Opcode.PlaybackUpdate, value);
}); });
ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => { ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => {
l.sendVolumeUpdate(value); l.send(Opcode.VolumeUpdate, value);
}); });
}); });
@ -196,12 +206,6 @@ export default class Main {
} }
static openMainWindow() { static openMainWindow() {
(async () => {
console.log("waiting for sodium...");
await sodium.ready;
console.log("sodium ready");
})();
if (Main.mainWindow) { if (Main.mainWindow) {
Main.mainWindow.focus(); Main.mainWindow.focus();
return; return;
@ -230,6 +234,50 @@ export default class Main {
} }
static main(app: Electron.App) { static main(app: Electron.App) {
if (!fs.existsSync('./cert.pem') || !fs.existsSync('./key.pem')) {
try {
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date(9999, 11, 31);
cert.sign(keys.privateKey);
const pemCert = forge.pki.certificateToPem(cert);
const pemKey = forge.pki.privateKeyToPem(keys.privateKey);
fs.writeFileSync('./cert.pem', pemCert);
fs.writeFileSync('./key.pem', pemKey);
} catch {
console.error("Failed to generate key pair.");
}
}
try {
Main.key = fs.readFileSync('./key.pem', 'utf8');
Main.cert = fs.readFileSync('./cert.pem', 'utf8');
} catch (e) {
console.error("Failed to load key pair.", e);
dialog.showMessageBox({
type: 'error',
title: 'Failed to initialize crypto',
message: `The application failed to start properly '${JSON.stringify(e)}'.`,
buttons: ['Restart', 'Close'],
defaultId: 0,
cancelId: 1
}).then((p) => {
if (p.response === 0) {
app.relaunch();
app.exit(0);
} else {
app.exit(0);
}
});
return;
}
Main.application = app; Main.application = app;
const argv = process.argv; const argv = process.argv;
if (argv.includes('--no-main-window')) { if (argv.includes('--no-main-window')) {

View file

@ -53,26 +53,4 @@ export class VersionMessage {
constructor( constructor(
public version: number, public version: number,
) {} ) {}
}
export class KeyExchangeMessage {
constructor(
public version: number,
public publicKey: string
) {}
}
export class DecryptedMessage {
constructor(
public opcode: number,
public message: string | undefined
) {}
}
export class EncryptedMessage {
constructor(
public version: number,
public iv: string | undefined,
public blob: string
) {}
} }

View file

@ -1,11 +1,12 @@
import net = require('net'); import net = require('net');
import { FCastSession } from './FCastSession'; import { FCastSession, Opcode } from './FCastSession';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VolumeUpdateMessage } from './Packets';
import { dialog } from 'electron'; import { dialog } from 'electron';
import Main from './Main'; import Main from './Main';
export class TcpListenerService { export class TcpListenerService {
public static PORT = 46899;
emitter = new EventEmitter(); emitter = new EventEmitter();
private server: net.Server; private server: net.Server;
@ -17,7 +18,7 @@ export class TcpListenerService {
} }
this.server = net.createServer() this.server = net.createServer()
.listen(46899) .listen(TcpListenerService.PORT)
.on("connection", this.handleConnection.bind(this)) .on("connection", this.handleConnection.bind(this))
.on("error", this.handleServerError.bind(this)); .on("error", this.handleServerError.bind(this));
} }
@ -33,14 +34,10 @@ export class TcpListenerService {
server.close(); server.close();
} }
send(opcode: number, message = null) {
sendPlaybackError(value: PlaybackErrorMessage) {
console.info("Sending playback error.", value);
this.sessions.forEach(session => { this.sessions.forEach(session => {
try { try {
session.sendPlaybackError(value); session.send(opcode, message);
} catch (e) { } catch (e) {
console.warn("Failed to send error.", e); console.warn("Failed to send error.", e);
session.close(); session.close();
@ -48,32 +45,6 @@ export class TcpListenerService {
}); });
} }
sendPlaybackUpdate(value: PlaybackUpdateMessage) {
console.info("Sending playback update.", value);
this.sessions.forEach(session => {
try {
session.sendPlaybackUpdate(value);
} catch (e) {
console.warn("Failed to send update.", e);
session.close();
}
});
}
sendVolumeUpdate(value: VolumeUpdateMessage) {
console.info("Sending volume update.", value);
this.sessions.forEach(session => {
try {
session.sendVolumeUpdate(value);
} catch (e) {
console.warn("Failed to send update.", e);
session.close();
}
});
}
private async handleServerError(err: NodeJS.ErrnoException) { private async handleServerError(err: NodeJS.ErrnoException) {
console.error("Server error:", err); console.error("Server error:", err);
@ -98,13 +69,7 @@ export class TcpListenerService {
console.log(`new connection from ${socket.remoteAddress}:${socket.remotePort}`); console.log(`new connection from ${socket.remoteAddress}:${socket.remotePort}`);
const session = new FCastSession(socket, (data) => socket.write(data)); const session = new FCastSession(socket, (data) => socket.write(data));
session.emitter.on("play", (body: PlayMessage) => { this.emitter.emit("play", body) }); session.bindEvents(this.emitter);
session.emitter.on("pause", () => { this.emitter.emit("pause") });
session.emitter.on("resume", () => { this.emitter.emit("resume") });
session.emitter.on("stop", () => { this.emitter.emit("stop") });
session.emitter.on("seek", (body: SeekMessage) => { this.emitter.emit("seek", body) });
session.emitter.on("setvolume", (body: SetVolumeMessage) => { this.emitter.emit("setvolume", body) });
session.emitter.on("setspeed", (body: SetSpeedMessage) => { this.emitter.emit("setspeed", body) });
this.sessions.push(session); this.sessions.push(session);
socket.on("error", (err) => { socket.on("error", (err) => {
@ -130,7 +95,7 @@ export class TcpListenerService {
try { try {
console.log('Sending version'); console.log('Sending version');
session.sendVersion({version: 2}); session.send(Opcode.Version, {version: 2});
} catch (e) { } catch (e) {
console.log('Failed to send version'); console.log('Failed to send version');
} }

View file

@ -0,0 +1,105 @@
import tls = require('tls');
import { FCastSession, Opcode } from './FCastSession';
import { EventEmitter } from 'node:events';
import { dialog } from 'electron';
import Main from './Main';
export class TlsListenerService {
public static PORT = 46897;
emitter = new EventEmitter();
private server: tls.Server;
private sessions: FCastSession[] = [];
constructor(private key: string, private cert: string) {}
start() {
if (this.server != null) {
return;
}
const options: tls.TlsOptions = {key: this.key, cert: this.cert};
this.server = tls.createServer(options).listen(TlsListenerService.PORT)
.on("secureConnection", this.handleConnection.bind(this))
.on("error", this.handleServerError.bind(this));
}
stop() {
if (this.server == null) {
return;
}
const server = this.server;
this.server = null;
server.close();
}
send(opcode: number, message = null) {
this.sessions.forEach(session => {
try {
session.send(opcode, message);
} catch (e) {
console.warn("Failed to send error.", e);
session.close();
}
});
}
private async handleServerError(err: NodeJS.ErrnoException) {
console.error("Server error:", err);
const restartPrompt = await dialog.showMessageBox({
type: 'error',
title: 'Failed to start',
message: 'The application failed to start properly.',
buttons: ['Restart', 'Close'],
defaultId: 0,
cancelId: 1
});
if (restartPrompt.response === 0) {
Main.application.relaunch();
Main.application.exit(0);
} else {
Main.application.exit(0);
}
}
private handleConnection(socket: tls.TLSSocket) {
console.log(`new secure connection from ${socket.remoteAddress}:${socket.remotePort}`);
const session = new FCastSession(socket, (data) => socket.write(data));
session.bindEvents(this.emitter);
this.sessions.push(session);
socket.on("error", (err) => {
console.warn(`Error from ${socket.remoteAddress}:${socket.remotePort}.`, err);
socket.destroy();
});
socket.on("data", buffer => {
try {
session.processBytes(buffer);
} catch (e) {
console.warn(`Error while handling packet from ${socket.remoteAddress}:${socket.remotePort}.`, e);
socket.end();
}
});
socket.on("close", () => {
const index = this.sessions.indexOf(session);
if (index != -1) {
this.sessions.splice(index, 1);
}
});
try {
console.log('Sending version');
session.send(Opcode.Version, {version: 2});
} catch (e) {
console.log('Failed to send version');
}
}
}

View file

@ -1,11 +1,12 @@
import { FCastSession } from './FCastSession'; import { FCastSession, Opcode } from './FCastSession';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { PlaybackErrorMessage, PlaybackUpdateMessage, PlayMessage, SeekMessage, SetSpeedMessage, SetVolumeMessage, VolumeUpdateMessage } from './Packets';
import { dialog } from 'electron'; import { dialog } from 'electron';
import Main from './Main'; import Main from './Main';
import { WebSocket, WebSocketServer } from 'ws'; import { WebSocket, WebSocketServer } from 'ws';
export class WebSocketListenerService { export class WebSocketListenerService {
public static PORT = 46898;
emitter = new EventEmitter(); emitter = new EventEmitter();
private server: WebSocketServer; private server: WebSocketServer;
@ -16,7 +17,7 @@ export class WebSocketListenerService {
return; return;
} }
this.server = new WebSocketServer({ port: 46898 }) this.server = new WebSocketServer({ port: WebSocketListenerService.PORT })
.on("connection", this.handleConnection.bind(this)) .on("connection", this.handleConnection.bind(this))
.on("error", this.handleServerError.bind(this)); .on("error", this.handleServerError.bind(this));
} }
@ -32,12 +33,10 @@ export class WebSocketListenerService {
server.close(); server.close();
} }
sendPlaybackError(value: PlaybackErrorMessage) { send(opcode: number, message = null) {
console.info("Sending playback error.", value);
this.sessions.forEach(session => { this.sessions.forEach(session => {
try { try {
session.sendPlaybackError(value); session.send(opcode, message);
} catch (e) { } catch (e) {
console.warn("Failed to send error.", e); console.warn("Failed to send error.", e);
session.close(); session.close();
@ -45,32 +44,6 @@ export class WebSocketListenerService {
}); });
} }
sendPlaybackUpdate(value: PlaybackUpdateMessage) {
console.info("Sending playback update.", value);
this.sessions.forEach(session => {
try {
session.sendPlaybackUpdate(value);
} catch (e) {
console.warn("Failed to send update.", e);
session.close();
}
});
}
sendVolumeUpdate(value: VolumeUpdateMessage) {
console.info("Sending volume update.", value);
this.sessions.forEach(session => {
try {
session.sendVolumeUpdate(value);
} catch (e) {
console.warn("Failed to send update.", e);
session.close();
}
});
}
private async handleServerError(err: NodeJS.ErrnoException) { private async handleServerError(err: NodeJS.ErrnoException) {
console.error("Server error:", err); console.error("Server error:", err);
@ -95,13 +68,7 @@ export class WebSocketListenerService {
console.log('New WebSocket connection'); console.log('New WebSocket connection');
const session = new FCastSession(socket, (data) => socket.send(data)); const session = new FCastSession(socket, (data) => socket.send(data));
session.emitter.on("play", (body: PlayMessage) => { this.emitter.emit("play", body) }); session.bindEvents(this.emitter);
session.emitter.on("pause", () => { this.emitter.emit("pause") });
session.emitter.on("resume", () => { this.emitter.emit("resume") });
session.emitter.on("stop", () => { this.emitter.emit("stop") });
session.emitter.on("seek", (body: SeekMessage) => { this.emitter.emit("seek", body) });
session.emitter.on("setvolume", (body: SetVolumeMessage) => { this.emitter.emit("setvolume", body) });
session.emitter.on("setspeed", (body: SetSpeedMessage) => { this.emitter.emit("setspeed", body) });
this.sessions.push(session); this.sessions.push(session);
socket.on("error", (err) => { socket.on("error", (err) => {
@ -133,7 +100,7 @@ export class WebSocketListenerService {
try { try {
console.log('Sending version'); console.log('Sending version');
session.sendVersion({version: 2}); session.send(Opcode.Version, {version: 2});
} catch (e) { } catch (e) {
console.log('Failed to send version'); console.log('Failed to send version');
} }

View file

@ -0,0 +1,118 @@
import { FCastSession, Opcode } from './FCastSession';
import { EventEmitter } from 'node:events';
import { dialog } from 'electron';
import Main from './Main';
import { WebSocket, WebSocketServer } from 'ws';
import * as https from 'https';
export class WebSocketSecureListenerService {
public static PORT = 46896;
emitter = new EventEmitter();
private server: WebSocketServer;
private sessions: FCastSession[] = [];
private httpsServer: https.Server;
constructor(private key: string, private cert: string) {}
start() {
if (this.server != null || this.httpsServer != null) {
return;
}
this.httpsServer = https.createServer({key: this.key, cert: this.cert});
this.httpsServer.listen(WebSocketSecureListenerService.PORT);
this.server = new WebSocketServer({server: this.httpsServer})
.on("connection", this.handleConnection.bind(this))
.on("error", this.handleServerError.bind(this));
}
stop() {
if (this.server != null) {
const server = this.server;
this.server = null;
server.close();
}
if (this.httpsServer != null) {
const httpsServer = this.httpsServer;
this.httpsServer = null;
httpsServer.close();
}
}
send(opcode: number, message = null) {
this.sessions.forEach(session => {
try {
session.send(opcode, message);
} catch (e) {
console.warn("Failed to send error.", e);
session.close();
}
});
}
private async handleServerError(err: NodeJS.ErrnoException) {
console.error("Server error:", err);
const restartPrompt = await dialog.showMessageBox({
type: 'error',
title: 'Failed to start',
message: 'The application failed to start properly.',
buttons: ['Restart', 'Close'],
defaultId: 0,
cancelId: 1
});
if (restartPrompt.response === 0) {
Main.application.relaunch();
Main.application.exit(0);
} else {
Main.application.exit(0);
}
}
private handleConnection(socket: WebSocket) {
console.log('New WebSocketSecure connection');
const session = new FCastSession(socket, (data) => socket.send(data));
session.bindEvents(this.emitter);
this.sessions.push(session);
socket.on("error", (err) => {
console.warn(`Error.`, err);
session.close();
});
socket.on('message', data => {
try {
if (data instanceof Buffer) {
session.processBytes(data);
} else {
console.warn("Received unhandled string message", data);
}
} catch (e) {
console.warn(`Error while handling packet.`, e);
session.close();
}
});
socket.on("close", () => {
console.log('WebSocketSecure connection closed');
const index = this.sessions.indexOf(session);
if (index != -1) {
this.sessions.splice(index, 1);
}
});
try {
console.log('Sending version');
session.send(Opcode.Version, {version: 2});
} catch (e) {
console.log('Failed to send version');
}
}
}

View file

@ -22,8 +22,8 @@
<div id="manual-connection-info">Manual connection information</div> <div id="manual-connection-info">Manual connection information</div>
<div> <div>
<div id="ips">IPs</div><br /> <div id="ips">IPs</div><br />
<div>Port<br>46899 (TCP), 46898 (WS)</div> <div>Port<br>46899 (TCP), 46898 (WS), 46897 (TLS), 46896 (WSS)</div>
</div> </div>
<div id="automatic-discovery">Automatic discovery is available via mDNS</div> <div id="automatic-discovery">Automatic discovery is available via mDNS</div>
<div id="qr-code"></div> <div id="qr-code"></div>
<div id="scan-to-connect" style="font-weight: bold;">Scan to connect</div> <div id="scan-to-connect" style="font-weight: bold;">Scan to connect</div>

View file

@ -22,8 +22,10 @@ window.electronAPI.onDeviceInfo((_event, value) => {
name: value.name, name: value.name,
addresses: value.addresses, addresses: value.addresses,
services: [ services: [
{ port: 46899, type: 0 }, { port: 46899, type: 0 }, //TCP
{ port: 46898, type: 1 } { port: 46898, type: 1 }, //WS
{ port: 46897, type: 2 }, //TCP-TLS
{ port: 46896, type: 3 } //WSS
] ]
}; };

View file

@ -1,76 +0,0 @@
import { EncryptedMessage, DecryptedMessage, KeyExchangeMessage } from '../src/Packets';
import { generateKeyPair, computeSharedSecret, encryptMessage, decryptMessage, createDiffieHellman, Opcode } from '../src/FCastSession';
/*test("testDHEncryptionSelf", () => {
const keyPair1 = generateKeyPair();
const keyPair2 = generateKeyPair();
const aesKey1 = computeSharedSecret(keyPair1, { version:1, publicKey: keyPair2.getPublicKey().toString('base64') });
const aesKey2 = computeSharedSecret(keyPair2, { version:1, publicKey: keyPair1.getPublicKey().toString('base64') });
expect(aesKey1.toString('base64')).toBe(aesKey2.toString('base64'));
const message: DecryptedMessage = { opcode: 1, message: 'text/html' };
const encryptedMessage: EncryptedMessage = encryptMessage(aesKey1, message);
const decryptedMessage: DecryptedMessage = decryptMessage(aesKey1, encryptedMessage);
expect(decryptedMessage.opcode).toBe(message.opcode);
expect(decryptedMessage.message).toBe(message.message);
});*/
test("testDHEncryptionKnown", () => {
const encodedPrivateKey1 = "MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==";
const keyExchangeMessage2: KeyExchangeMessage = { version: 1, publicKey: "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=" };
const dh = createDiffieHellman();
dh.setPrivateKey(Buffer.from(encodedPrivateKey1, 'base64'));
const aesKey1 = computeSharedSecret(dh, keyExchangeMessage2);
expect(aesKey1.toString('base64')).toBe("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=");
const message = { opcode: 1, message: 'text/html' };
const serializedBody = JSON.stringify(message);
const encryptedMessage = encryptMessage(aesKey1, message as DecryptedMessage);
const decryptedMessage = decryptMessage(aesKey1, encryptedMessage as EncryptedMessage);
expect(decryptedMessage.opcode).toBe(1);
expect(decryptedMessage.message).toBe(serializedBody);
});
/*test("testAESKeyGeneration", () => {
const testCases = [
{
publicKey: "MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
privateKey: "MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
expectedAES: "7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
},
{
publicKey: "MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
privateKey: "MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
expectedAES: "a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
}
];
testCases.forEach(({ publicKey, privateKey, expectedAES }) => {
const theirPublicKey = Buffer.from(publicKey, 'base64');
const dh = createDiffieHellman();
dh.setPrivateKey(Buffer.from(privateKey, 'base64'));
const aesKey = computeSharedSecret(dh, { version: 1, publicKey: theirPublicKey.toString('base64') });
expect(aesKey.toString('base64')).toBe(expectedAES);
});
});*/
/*test("testDecryptMessageKnown", () => {
const encryptedMessage: EncryptedMessage = {
version: 1,
iv: "C4H70VC5FWrNtkty9/cLIA==",
blob: "K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA=="
};
const aesKeyBase64 = "+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=";
const aesKey = Buffer.from(aesKeyBase64, 'base64');
const decryptedMessage = decryptMessage(aesKey, encryptedMessage);
expect(decryptedMessage.opcode).toBe(Opcode.Play);
expect(decryptedMessage.message).toBe("{\"container\":\"text/html\"}");
});*/