diff --git a/API.md b/API.md new file mode 100644 index 0000000..4209465 --- /dev/null +++ b/API.md @@ -0,0 +1,53 @@ +### Get list of binaries + +#### Request + +```http request +GET / HTTP/1.1 +``` + +#### Example response + +``` +- hello (platforms: ["Linux-aarch64", "Darwin-arm64", "Darwin-x86_64"]) +``` + +### Get script for specific binary (suitable for `curl | sh` syntax) + +This script will determine platform and arch and download necessary binary (and check hashsum if `sha256sum` binary is present in `$PATH`). + +#### Request + +```http request +GET / HTTP/1.1 +``` + +### Get binary for specific platform + +#### Request + +```http request +GET /bin/// HTTP/1.1 +``` + +### Get ed25519 signature + +#### Request + +```http request +GET /bin////sign HTTP/1.1 +``` + +### Get manifest + +Manifest is a file, that contains ED25519 public key and SHA256sums list + +```http request +GET /runner/manifest HTTP/1.1 +``` + +### Get binary runner + +```http request +GET /runner/ HTTP/1.1 +``` \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index de62dbf..424ced5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,28 @@ +workspace = { members = [ "runner" ] } + [package] name = "binhost" -version = "0.2.2" +version = "0.3.0" edition = "2021" license = "MIT" repository = "https://github.com/ivabus/binhost" description = "HTTP server to easily serve files" [dependencies] -clap = { version = "4.4.11", features = ["derive"] } +clap = { version = "4.4.18", features = ["derive"] } rocket = "0.5.0" -serde = { version = "1.0.193", features = ["derive"] } -sha2 = { version = "0.10.8", optional = true } +serde = { version = "1.0", features = ["derive"] } +sha2 = { version = "0.10"} +ed25519-compact = { version = "2.0.6", default-features = false, features = [ "random", "self-verify", "std"] } -[features] -default = [ "sha256" ] -sha256 = [ "dep:sha2" ] +# Reducing size as much as possible +[profile.release] +strip = true +opt-level = "s" +lto = true +panic = "abort" +codegen-units = 1 + +[profile.dev] +# Doesn't build on my machine without +opt-level = 1 diff --git a/README.md b/README.md index 3fbdea7..8d39969 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BinHost -> HTTP server to easily serve (prebuilt) binaries for any (UNIX-like) platform +> HTTP server to easily serve (prebuilt) binaries for any (UNIX-like) platform with authenticity check ## Installation @@ -33,55 +33,51 @@ bin └── hello ``` +#### Runners + +Runner is a (necessary) subprogram, that checks ED25519 signature of a binary file and needs to be statically compiled for every platform, that could use binaries from `binhost` server. + +Directory, passed to `binhost` `--runners-dir` option (defaults to `./runners`) should look like (for `Linux-x86_64`, `Linux-aarch64` and `Darwin-arm64` compiled runners) + +```tree +runners +├── runner-Darwin-arm64 +├── runner-Linux-aarch64 +└── runner-Linux-x86_64 +``` + ## Client usage -### Get available binaries +### Execute specific binary with manifest validity check -#### Request +Manifest validity check provides a fully-secured binary distribution chain. -```http request -GET / HTTP/1.1 +```shell +curl ADDRESS:PORT/ | KEY=... bash ``` -#### Example response +`KEY` first few symbols from hex representation of SHA256 sum of manifest (printed to stdout on `binhost` startup). -``` -- hello (platforms: ["Linux-aarch64", "Darwin-arm64", "Darwin-x86_64"]) +Only this option should be considered as secure. + +### Execute specific binary without validity check + +```shell +curl ADDRESS:PORT/ | bash ``` -### Get script for specific binary (suitable for `curl | sh` syntax) +### Download and reuse script -This script will determine platform and arch and download necessary binary (and check hashsum if `sha256sum` binary is present in `$PATH`). - -#### Request - -```http request -GET / HTTP/1.1 +```shell +curl ADDRESS:PORT/ -o script.sh +./script.sh # Execute preloaded bin configuration +BIN= ./script.sh # Execute newbin (download) +BIN= EXTERNAL_ADDRESS= ./script.sh # Execute newbin from newaddress ``` -### Get binary for specific platform +### API -#### Request - -```http request -GET /bin/// HTTP/1.1 -``` - -### Get sha256 hash of binary for specific platform - -Only with "sha256" feature (recalculates hash on each request, may be bad on large files or lots of requests) - -#### Request - -```http request -GET /bin////sha256 HTTP/1.1 -``` - -#### Example response - -```text -a5d1fba1c28b60038fb1008a3c482b4119070a537af86a05046dedbe8f85e18d hello -``` +See full HTTP API in [API.md](./API.md) ## License diff --git a/runner/Cargo.toml b/runner/Cargo.toml new file mode 100644 index 0000000..7617e0a --- /dev/null +++ b/runner/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "runner" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +libc = { version = "0.2.152", default-features = false } +ed25519-compact = { version = "2.0.6", default-features = false } +numtoa = "0.2.4" \ No newline at end of file diff --git a/runner/src/main.rs b/runner/src/main.rs new file mode 100644 index 0000000..63537bb --- /dev/null +++ b/runner/src/main.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +// "Runner" is written in no_std Rust for the smaller executable size: ~49KiB (Darwin arm64) vs ~300KiB + +#![no_main] +#![no_std] + +use core::slice::from_raw_parts; +use ed25519_compact::{PublicKey, Signature}; +use libc::{c_char, c_int, c_void, exit, open, printf, read, O_RDONLY}; + +// 1 KiB seems fine +const BUFFER_SIZE: usize = 1024; +const PUBKEY_LEN: usize = PublicKey::BYTES; +const SIGNATURE_LEN: usize = Signature::BYTES; + +#[allow(clippy::missing_safety_doc)] +#[no_mangle] +pub unsafe extern "C" fn main(_argc: i32, _argv: *const *const c_char) -> i32 { + printf("Starting runner\n\0".as_bytes().as_ptr() as *const c_char); + + let mut buff_public_key = [0_u8; PUBKEY_LEN]; + read( + open("public_key\0".as_bytes().as_ptr() as *const c_char, O_RDONLY), + buff_public_key.as_mut_ptr() as *mut c_void, + PUBKEY_LEN, + ); + let public_key = PublicKey::new(buff_public_key); + + let mut signature = [0_u8; SIGNATURE_LEN]; + read( + open("signature\0".as_bytes().as_ptr() as *const c_char, O_RDONLY), + signature.as_mut_ptr() as *mut c_void, + SIGNATURE_LEN, + ); + + let arg = from_raw_parts(_argv, _argc as usize)[1]; // Getting path to binary + let binary_fd = open(arg, O_RDONLY); + let mut buffer = [0u8; BUFFER_SIZE]; + let mut state = public_key.verify_incremental(&Signature::new(signature)).unwrap(); + loop { + let bytes_read: usize = + read(binary_fd, buffer.as_mut_ptr() as *mut c_void, BUFFER_SIZE) as usize; + state.absorb(&buffer[0..bytes_read]); + if bytes_read != BUFFER_SIZE { + break; + } + } + + printf("Signature: \0".as_bytes().as_ptr() as *const c_char); + if state.verify().is_ok() { + printf("OK\n\0".as_bytes().as_ptr() as *const c_char); + } else { + printf("Bad\n\0".as_bytes().as_ptr() as *const c_char); + return 2; + } + 0 +} + +#[panic_handler] +fn panic(info: &core::panic::PanicInfo) -> ! { + use numtoa::NumToA; + let mut buff = [0u8; 8]; + unsafe { + printf("Panicked\0".as_bytes().as_ptr() as *const c_char); + if let Some(location) = info.location() { + printf(" at \0".as_bytes().as_ptr() as *const c_char); + location.line().numtoa(10, &mut buff).iter().for_each(|ch| { + libc::putchar(*ch as c_int); + }); + printf(":\0".as_bytes().as_ptr() as *const c_char); + location.column().numtoa(10, &mut buff).iter().for_each(|ch| { + libc::putchar(*ch as c_int); + }); + printf(" in \0".as_bytes().as_ptr() as *const c_char); + location.file().chars().for_each(|ch| { + libc::putchar(ch as c_int); + }); + } + libc::putchar(b'\n' as c_int); + exit(3) + } +} diff --git a/src/main.rs b/src/main.rs index 6b00bb2..31a67bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,45 +1,39 @@ -mod structs; -use structs::*; - -use clap::Parser; -use rocket::figment::Figment; -use rocket::fs::NamedFile; -use std::collections::HashMap; -use std::time::Instant; +// SPDX-License-Identifier: MIT #[macro_use] extern crate rocket; +use std::collections::HashMap; +use std::time::Instant; + +use clap::Parser; +use ed25519_compact::{KeyPair, Noise}; +use rocket::figment::Figment; +use rocket::fs::{FileServer, NamedFile}; use rocket::http::Status; use rocket::response::content::RawText; +use sha2::digest::FixedOutput; +use sha2::Digest; + +use structs::*; + +mod structs; static mut BINS: Option<(HashMap, Instant)> = None; - +static mut MANIFEST: Option> = None; +static mut KEYPAIR: Option = None; static WEB_SH: &str = include_str!("../web.sh"); -#[cfg(feature = "sha256")] -static HASH_CALCULATION_SH: &str = r#" -if ! which sha256sum > /dev/null; then - echo "No \`sha256sum\` command found, continuing without checking" 1>&2 -else - echo ":: Checking hashsum" 1>&2 - if ! ($DOWNLOAD_COMMAND {{EXTERNAL_ADDRESS}}/bin/$NAME/$(uname)/$(uname -m)/sha256 $OUTPUT_ARG - | sha256sum -c - > /dev/null); then - echo "sha256 is invalid" 1>&2 - exit 255 - fi -fi -"#; -#[cfg(not(feature = "sha256"))] static HASH_CALCULATION_SH: &str = ""; -async fn reload_bins(bins: (&mut HashMap, &mut Instant), args: &Args) { +fn reload_bins(bins: (&mut HashMap, &mut Instant), args: &Args) { if (Instant::now() - *bins.1).as_secs() > args.refresh { - *bins.0 = get_bins(args).await; + *bins.0 = get_bins(args); *bins.1 = Instant::now(); } } -async fn get_bins(args: &Args) -> HashMap { +fn get_bins(args: &Args) -> HashMap { let mut bins: HashMap = HashMap::new(); std::fs::read_dir(&args.dir).unwrap().for_each(|entry| { let en = entry.unwrap(); @@ -79,7 +73,7 @@ async fn index() -> RawText { let mut ret = String::new(); unsafe { if let Some((bins, time)) = &mut BINS { - reload_bins((bins, time), &args).await; + reload_bins((bins, time), &args); if bins.is_empty() { return RawText(String::from("No binaries found")); } @@ -98,12 +92,21 @@ async fn index() -> RawText { RawText(ret) } +#[get("/runner/manifest")] +async fn get_manifest() -> Vec { + unsafe { + match &MANIFEST { + Some(man) => man.clone(), + _ => unreachable!(), + } + } +} #[get("/")] async fn get_script(bin: &str) -> ScriptResponse { let args = Args::parse(); unsafe { if let Some((bins, time)) = &mut BINS { - reload_bins((bins, time), &args).await; + reload_bins((bins, time), &args); return match bins.get(bin) { None => ScriptResponse::Status(Status::NotFound), Some(bin) => { @@ -121,6 +124,21 @@ async fn get_script(bin: &str) -> ScriptResponse { ScriptResponse::Status(Status::NotFound) } +#[get("//platforms")] +async fn get_platforms(bin: &str) -> ScriptResponse { + let args = Args::parse(); + unsafe { + if let Some((bins, time)) = &mut BINS { + reload_bins((bins, time), &args); + return match bins.get(bin) { + None => ScriptResponse::Status(Status::NotFound), + Some(bin) => ScriptResponse::Text(RawText(format_platform_list(bin))), + }; + } + } + ScriptResponse::Status(Status::NotFound) +} + #[get("/bin///")] async fn get_binary(bin: &str, platform: &str, arch: &str) -> BinaryResponse { let args = Args::parse(); @@ -139,44 +157,27 @@ async fn get_binary(bin: &str, platform: &str, arch: &str) -> BinaryResponse { } } -#[cfg(feature = "sha256")] -#[get("/bin////sha256")] -async fn get_binary_hash(bin: &str, platform: &str, arch: &str) -> ScriptResponse { - use rocket::tokio::io::AsyncReadExt; - use sha2::digest::FixedOutput; - use sha2::Digest; - +#[get("/bin////sign")] +async fn get_binary_sign(bin: &str, platform: &str, arch: &str) -> SignResponse { let args = Args::parse(); - let file = NamedFile::open(format!( + let file = match std::fs::read(format!( "{}/{}/{}/{}/{}", args.dir.file_name().unwrap().to_str().unwrap(), bin, platform, arch, bin - )) - .await; - - match file { - Ok(mut f) => { - let mut hasher = sha2::Sha256::new(); - let mut contents: Vec = vec![]; - f.read_to_end(&mut contents).await.unwrap(); - hasher.update(contents); - ScriptResponse::Text(RawText(format!( - "{:x} {}", - hasher.finalize_fixed(), - f.path().file_name().unwrap().to_str().unwrap() - ))) + )) { + Ok(f) => f, + Err(_) => return SignResponse::Status(Status::BadRequest), + }; + let keypair = unsafe { + match &KEYPAIR { + Some(pair) => pair, + _ => unreachable!(), } - Err(_) => ScriptResponse::Status(Status::BadRequest), - } -} - -#[cfg(not(feature = "sha256"))] -#[get("/bin/<_bin>/<_platform>/<_arch>/sha256")] -async fn get_binary_hash(_bin: &str, _platform: &str, _arch: &str) -> ScriptResponse { - ScriptResponse::Status(Status::BadRequest) + }; + SignResponse::Bin(keypair.sk.sign(file.as_slice(), Some(Noise::generate())).as_slice().to_vec()) } #[launch] async fn rocket() -> _ { @@ -187,12 +188,55 @@ async fn rocket() -> _ { } unsafe { - BINS = Some((get_bins(&args).await, Instant::now())); - } + BINS = Some((get_bins(&args), Instant::now())); + println!("Generating keypair"); + let kp = KeyPair::generate(); + KEYPAIR = Some(kp.clone()); + println!( + "Keypair generated. Public key: {}", + &kp.pk.iter().map(|x| format!("{:x}", x)).collect::>().join("") + ); + println!("Generating manifest"); + let mut manifest: Vec = Vec::new(); + let mut bin_pub_key: Vec = kp.pk.to_vec(); + manifest.append(&mut bin_pub_key); + let mut runners = 0; + + for element in std::fs::read_dir(args.runners_dir).unwrap() { + let en = element.unwrap(); + if en.path().is_file() { + let mut hasher = sha2::Sha256::new(); + hasher.update(std::fs::read(en.path()).unwrap().as_slice()); + let mut contents = Vec::from( + format!( + "{:x} {}\n", + hasher.finalize_fixed(), + en.path().file_name().unwrap().to_str().unwrap() + ) + .as_bytes(), + ); + runners += 1; + manifest.append(&mut contents); + } + } + let mut hasher = sha2::Sha256::new(); + hasher.update(&manifest); + println!( + "Manifest generated with {} runners and SHA256: {:x}", + runners, + hasher.finalize_fixed() + ); + MANIFEST = Some(manifest) + }; let figment = Figment::from(rocket::Config::default()) - .merge(("ident", "BinHost")) + .merge(("ident", "Binhost")) .merge(("port", args.port)) .merge(("address", args.address)); - rocket::custom(figment).mount("/", routes![index, get_script, get_binary, get_binary_hash]) + rocket::custom(figment) + .mount( + "/", + routes![index, get_manifest, get_script, get_platforms, get_binary, get_binary_sign], + ) + .mount("/runner", FileServer::from("runners")) } diff --git a/src/structs.rs b/src/structs.rs index ff99d89..2b554c8 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -12,6 +12,10 @@ pub struct Args { #[arg(long, default_value = "bin")] pub dir: PathBuf, + /// Directory where runners are contained + #[arg(long, default_value = "runners")] + pub runners_dir: PathBuf, + /// Refresh time (in secs) #[arg(long, default_value = "300")] pub refresh: u64, @@ -52,3 +56,9 @@ pub enum BinaryResponse { Status(Status), Bin(NamedFile), } + +#[derive(Responder, Clone)] +pub enum SignResponse { + Status(Status), + Bin(Vec), +} diff --git a/web.sh b/web.sh index 28724da..4d370a5 100644 --- a/web.sh +++ b/web.sh @@ -1,44 +1,103 @@ -#!/bin/sh +#!/usr/bin/env bash # SPDX-License-Identifier: MIT -set -e -o pipefail +set -e -NAME="{{NAME}}" +print() { + echo "$@" >&2 +} -if ! uname > /dev/null; then - echo "No \`uname\` command was found, cannot continue" 1>&2 - exit 1 -fi - -if ! expr "{{PLATFORM_LIST}}" : "\(.*$(uname)-$(uname -m).*\)" > /dev/null; then - echo Platform $(uname)-$(uname -m) is not supported 1>&2 +fail() { + print "$@" exit 1 +} + +requireCommands() { + for cmd in $*; do + if ! command -v $cmd &> /dev/null; then + fail "Cannot find required command: $cmd" + fi + done +} + +requireCommands uname sha256sum cut dd chmod rm realpath expr + +if [ "$(realpath $(command -v sha256sum))" = "/bin/busybox" ]; then + fail "Busybox sha256sum detected, will not work. Refusing to continue" fi +# Script could be used for any binary +NAME=${BIN:="{{NAME}}"} +EXTERNAL_ADDRESS=${EXTERNAL_ADDRESS:="{{EXTERNAL_ADDRESS}}"} DOWNLOAD_COMMAND="curl" OUTPUT_ARG="-o" DIR="/tmp/binhost-$NAME-$(date +%s)" FILE="$DIR/$NAME" +PLATFORM="$(uname)" +ARCH="$(uname -m)" -if ! which curl > /dev/null; then - if ! which wget > /dev/null; then - echo "No curl or wget found, install one and rerun the script" 1>&2 - exit 1 +if ! command -v curl &> /dev/null; then + if ! command -v wget &> /dev/null; then + fail "No curl or wget found, install one and rerun the script" fi export DOWNLOAD_COMMAND="wget" export OUTPUT_ARG="-O" fi +PLATFORM_LIST="{{PLATFORM_LIST}}" +# Making script truly portable +if [ ! "{{NAME}}" = $NAME ]; then + print ":: Fetching platforms" + export PLATFORM_LIST=$($DOWNLOAD_COMMAND $EXTERNAL_ADDRESS/$NAME/platforms $OUTPUT_ARG /dev/stdout) +fi + +if ! expr "$PLATFORM_LIST" : "\(.*$(uname)-$(uname -m).*\)" > /dev/null; then + fail "Platform \"$(uname)-$(uname -m)\" is not supported" +fi + mkdir $DIR +cd $DIR -echo ":: Downloading binary" 1>&2 +print ":: Downloading manifest" +$DOWNLOAD_COMMAND $EXTERNAL_ADDRESS/runner/manifest $OUTPUT_ARG manifest -# shellcheck disable=SC1083 -$DOWNLOAD_COMMAND {{EXTERNAL_ADDRESS}}/bin/$NAME/$(uname)/$(uname -m) $OUTPUT_ARG "$FILE" +MANIFEST_HASHSUM=$(sha256sum manifest) + +if [ ! -z $KEY ]; then + if [ ! $KEY = "$(echo $MANIFEST_HASHSUM | cut -c 1-${#KEY})" ]; then + fail "Invalid manifest hashsum" + fi +else + print "Manifest KEY missing, skipping manifest check" +fi + +print ":: Downloading signature" +$DOWNLOAD_COMMAND $EXTERNAL_ADDRESS/bin/$NAME/$PLATFORM/$ARCH/sign $OUTPUT_ARG signature + +dd if=manifest of=public_key count=32 bs=1 2> /dev/null +dd if=manifest of=hashes skip=32 bs=1 2> /dev/null + +print ":: Downloading runner" + +$DOWNLOAD_COMMAND $EXTERNAL_ADDRESS/runner/runner-$PLATFORM-$ARCH $OUTPUT_ARG "runner-$PLATFORM-$ARCH" + +if ! sha256sum -c hashes --ignore-missing; then + fail "Incorrect hashsum of runner" +fi + +chmod +x "runner-$PLATFORM-$ARCH" + +print ":: Downloading binary" + +$DOWNLOAD_COMMAND $EXTERNAL_ADDRESS/bin/$NAME/$PLATFORM/$ARCH $OUTPUT_ARG "$FILE" + +if ! ./runner-$PLATFORM-$ARCH "$FILE" >&2; then + exit 2 +fi chmod +x "$FILE" -cd $DIR -{{HASH_CALCULATION}} $FILE < /dev/tty -rm "$FILE" +cd + +rm -rf "$DIR"