diff --git a/Cargo.lock b/Cargo.lock index d020c65..e21878e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2513,11 +2513,13 @@ dependencies = [ "askama", "askama_axum", "axum", + "base64 0.22.1", "cached", "chrono", "clap", "config", "git2", + "hex", "mime_guess", "monero 0.21.0", "monero-rpc", diff --git a/Cargo.toml b/Cargo.toml index 80fc868..197a72e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ rust-embed = { version = "8.4.0", features = ["axum", "debug-embed"] } mime_guess = "2.0.4" chrono = "0.4.38" tokio_schedule = "0.3.1" +base64 = "0.22.1" +hex = "0.4.3" [features] monero = ["dep:monero-rpc"] diff --git a/Dockerfile b/Dockerfile index 2c55f33..2c16c38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ EXPOSE 80 ENV TZ=Etc/UTC RUN apt-get update \ - && apt-get install -y bzip2 ca-certificates curl tzdata \ + && apt-get install -y bzip2 ca-certificates curl tzdata git gpg \ && rm -rf /var/lib/apt/lists/* # Install monero wallet RPC daemon diff --git a/README.md b/README.md index ce93599..a2ae3c7 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ It's up to the discretion of the person that merges PRs to make sure contributor aren't unfairly boosting their rewards. In the event that such an injustice occurs, maintainers can cancel payouts or ban contributors. +### What if the `turbine` owner steals the project's funds? + +Since `turbine` is self-hosted, the crypto wallet is fully under control of the +project owner. We have to trust them not to misuse funds deposited in `turbine`, +just like we have to trust them not to include a backdoor in the software. + ## Using `turbine` as a contributor First, you need to find a repository that's hosting a `turbine`. The full list @@ -41,6 +47,11 @@ is currently small enough to maintain here: TODO +### Setup commit signing + + + ## Running your own `turbine` +### Create a new wallet TODO diff --git a/src/currency/mod.rs b/src/currency/mod.rs index 3783865..f38b0de 100644 --- a/src/currency/mod.rs +++ b/src/currency/mod.rs @@ -1,7 +1,7 @@ #[cfg(feature = "monero")] pub mod monero; -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum Address { BTC(String), #[cfg(feature = "monero")] diff --git a/src/repo.rs b/src/repo.rs index fb1262b..72dba98 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -1,12 +1,13 @@ -use std::process::{Command, Stdio}; - use crate::currency::Address; use anyhow::{bail, Result}; +use base64::prelude::*; use chrono::{DateTime, Utc}; use git2::{Commit, Oid, Repository, Revwalk, Sort}; +use std::process::{Command, Stdio}; use tempfile::TempDir; -use tracing::{debug, trace}; +use tracing::{debug, info, instrument, trace}; +#[derive(Debug)] pub struct Contributor { pub address: Address, pub last_payout: Option>, @@ -18,6 +19,7 @@ pub struct Contributor { } impl Contributor { + #[instrument(ret)] pub fn compute_payout(&self, commit_id: Oid) -> u64 { // TODO 1 @@ -46,29 +48,62 @@ impl std::fmt::Debug for TurbineRepo { } } +/// Get the key ID of the public key that corresponds to the private key that +/// signed this commit. +#[instrument(ret, level = "trace")] +fn get_public_key_id(commit: &Commit) -> Result { + if let Some(header) = commit.raw_header() { + if let Some((_, gpgsig)) = header.split_once("gpgsig") { + let mut signature_base64 = String::new(); + let mut lines = gpgsig.lines(); + loop { + match lines.next() { + Some(line) => { + if line.starts_with("-----BEGIN") { + continue; + } else if line.starts_with("-----END") { + break; + } else { + signature_base64.push_str(&line); + } + } + None => bail!("Failed to get GPG signature"), + } + } + + let decoded = BASE64_STANDARD.decode(signature_base64)?; + return Ok(hex::encode(&decoded[19..26])); + } + } + bail!("Failed to get GPG public key ID"); +} + /// Verify a commit's GPG signature and return its key ID. +#[instrument(ret)] fn verify_signature(commit: &Commit) -> Result { + // Receive the public key first + Command::new("gpg") + .arg("--recv-keys") + .arg(get_public_key_id(&commit)?) + .spawn()? + .wait()?; + let output = Command::new("git") .arg("verify-commit") .arg("--raw") .arg(commit.id().to_string()) .stdout(Stdio::piped()) .output()?; + let output = std::str::from_utf8(&output.stdout)?; - for line in std::str::from_utf8(&output.stdout)?.lines() { + trace!(output = output, "verify-commit output"); + for line in output.lines() { if line.contains("GOODSIG") { return Ok(line.split_whitespace().nth(2).unwrap().into()); } } - // Get the commit's GPG signature - // TODO - // if let Some(header) = commit.raw_header() { - // if let Some((_, gpgsig)) = header.split_once("gpgsig") { - // // Verify signature - // // TODO - // } - // } + // TODO verify the signature ourselves bail!("Failed to verify signature"); } @@ -78,13 +113,16 @@ impl TurbineRepo { debug!(remote = remote, dest = ?tmp.path(), "Cloning repository"); let container = Repository::clone(&remote, tmp.path())?; - Ok(Self { + let mut repo = Self { branch: branch.into(), tmp, container, last: None, contributors: vec![], - }) + }; + + repo.refresh()?; + Ok(repo) } pub fn refresh(&mut self) -> Result<()> { @@ -114,22 +152,37 @@ impl TurbineRepo { if let Some(next) = revwalk.next() { let commit = self.container.find_commit(next?)?; + // Check for GPG signature + if let Some(header) = commit.raw_header() { + if !header.contains("gpgsig") { + continue; + } + } + if let Ok(key_id) = verify_signature(&commit) { if let Some(message) = commit.message() { - if let Some((_, address)) = message.split_once("XMR:") { + if let Some((_, address)) = message.split_once("XMR") { if let Some(contributor) = self .contributors .iter_mut() .find(|contributor| contributor.key_id == key_id) { + debug!( + old = ?contributor.address, + new = ?address, + "Updating contributor address" + ); contributor.address = Address::XMR(address.into()); } else { - self.contributors.push(Contributor { + let contributor = Contributor { address: Address::XMR(address.into()), last_payout: None, key_id, commits: Vec::new(), - }); + }; + + info!(contributor = ?contributor, "Adding new contributor"); + self.contributors.push(contributor); } } } @@ -146,12 +199,21 @@ impl TurbineRepo { match revwalk.next() { Some(next) => { let commit = self.container.find_commit(next?)?; + + // Check for GPG signature + if let Some(header) = commit.raw_header() { + if !header.contains("gpgsig") { + continue; + } + } + if let Ok(key_id) = verify_signature(&commit) { if let Some(contributor) = self .contributors .iter_mut() .find(|contributor| contributor.key_id == key_id) { + info!(contributor = ?contributor, commit = ?commit, "Found new paid commit"); contributor.commits.push(commit.id()); } } diff --git a/tests/repo_with_signed_commits b/tests/repo_with_signed_commits new file mode 160000 index 0000000..5547969 --- /dev/null +++ b/tests/repo_with_signed_commits @@ -0,0 +1 @@ +Subproject commit 5547969316e8050c659b33d3bcf18e3974a3b5eb diff --git a/tests/repo_without_signed_commits b/tests/repo_without_signed_commits new file mode 160000 index 0000000..a5255dc --- /dev/null +++ b/tests/repo_without_signed_commits @@ -0,0 +1 @@ +Subproject commit a5255dc515bcf2ec06f1f1c303736d2179c90a09