From 3d14f680fb14b5ca28f6371360f259c0f9bc99ec Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 14 Jul 2026 16:53:55 -0700 Subject: [PATCH 1/3] feat(core): verify git delivery before payment --- crates/mobee-core/Cargo.toml | 1 + crates/mobee-core/src/delivery.rs | 200 +++++++++++++++ crates/mobee-core/src/delivery_git.rs | 311 ++++++++++++++++++++++++ crates/mobee-core/src/gateway.rs | 168 +++++++++++++ crates/mobee-core/src/lib.rs | 5 +- crates/mobee-core/src/payment.rs | 211 +++++++++++++--- crates/mobee-core/src/payment_wallet.rs | 8 +- crates/mobee-core/src/receipt.rs | 36 ++- 8 files changed, 902 insertions(+), 38 deletions(-) create mode 100644 crates/mobee-core/src/delivery.rs create mode 100644 crates/mobee-core/src/delivery_git.rs diff --git a/crates/mobee-core/Cargo.toml b/crates/mobee-core/Cargo.toml index 38eec5a..119115d 100644 --- a/crates/mobee-core/Cargo.toml +++ b/crates/mobee-core/Cargo.toml @@ -10,6 +10,7 @@ publish.workspace = true default = [] acp = [] gateway = ["dep:nostr-sdk"] +git-delivery = [] wallet = ["dep:cashu", "dep:cdk", "dep:nostr-sdk", "dep:tokio"] test-support = [] diff --git a/crates/mobee-core/src/delivery.rs b/crates/mobee-core/src/delivery.rs new file mode 100644 index 0000000..dd5ce29 --- /dev/null +++ b/crates/mobee-core/src/delivery.rs @@ -0,0 +1,200 @@ +use std::fmt; + +/// A full Git commit object id advertised by a seller. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommitOid(String); + +impl CommitOid { + /// Parses a full SHA-1 or SHA-256 Git object id. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if !matches!(value.len(), 40 | 64) || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(DeliveryError::InvalidCommitOid); + } + Ok(Self(value.to_ascii_lowercase())) + } + + /// Returns the canonical lowercase object id. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CommitOid { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Git delivery fields advertised by a result event. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitDelivery { + repo: String, + branch: String, + commit_oid: CommitOid, +} + +impl GitDelivery { + /// Creates an advertised git delivery from result-event fields. + pub fn new( + repo: impl Into, + branch: impl Into, + commit_oid: CommitOid, + ) -> Result { + let repo = repo.into(); + let branch = branch.into(); + if repo.is_empty() { + return Err(DeliveryError::MissingRepo); + } + if branch.is_empty() + || branch.starts_with('-') + || branch.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(DeliveryError::InvalidBranch); + } + Ok(Self { + repo, + branch, + commit_oid, + }) + } + + /// Returns the repository locator carried by the event. + pub fn repo(&self) -> &str { + &self.repo + } + + /// Returns the advertised branch name. + pub fn branch(&self) -> &str { + &self.branch + } + + /// Returns the advertised full commit object id. + pub fn commit_oid(&self) -> &CommitOid { + &self.commit_oid + } +} + +/// Proof that the advertised branch tip was fetched into buyer custody. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedDelivery { + commit_oid: CommitOid, +} + +impl VerifiedDelivery { + /// Binds a fetched tip to the advertisement or refuses a mismatch. + pub fn from_fetched_tip( + advertised: &GitDelivery, + fetched_tip: CommitOid, + ) -> Result { + if &fetched_tip != advertised.commit_oid() { + return Err(DeliveryError::TipMismatch { + expected: advertised.commit_oid().clone(), + actual: fetched_tip, + }); + } + Ok(Self { + commit_oid: advertised.commit_oid().clone(), + }) + } + + /// Returns the verified commit object id used by payment and receipt binding. + pub fn commit_oid(&self) -> &CommitOid { + &self.commit_oid + } +} + +/// Injected buyer-side delivery verification effect. +pub trait DeliveryVerifier { + /// Fetches and verifies a delivery before payment intent is persisted. + fn verify(&mut self, delivery: &GitDelivery) -> Result; +} + +/// Fail-closed delivery verification errors. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DeliveryError { + InvalidCommitOid, + MissingRepo, + InvalidBranch, + GitUnavailable, + GitCommandFailed(&'static str), + MissingFetchedTip, + TipMismatch { + expected: CommitOid, + actual: CommitOid, + }, + MissingCommitObject, +} + +impl fmt::Display for DeliveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidCommitOid => { + formatter.write_str("delivery commit oid must be 40 or 64 hex characters") + } + Self::MissingRepo => formatter.write_str("delivery repository is missing"), + Self::InvalidBranch => formatter.write_str("delivery branch is invalid"), + Self::GitUnavailable => formatter.write_str("git executable is unavailable"), + Self::GitCommandFailed(operation) => write!(formatter, "git {operation} failed"), + Self::MissingFetchedTip => { + formatter.write_str("git fetch did not produce one commit tip") + } + Self::TipMismatch { expected, actual } => { + write!( + formatter, + "fetched tip {actual} does not match advertised {expected}" + ) + } + Self::MissingCommitObject => { + formatter.write_str("fetched commit object is not in buyer custody") + } + } + } +} + +impl std::error::Error for DeliveryError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commit_oid_requires_full_hex() { + assert_eq!( + CommitOid::parse("abc"), + Err(DeliveryError::InvalidCommitOid) + ); + assert_eq!( + CommitOid::parse("z".repeat(40)), + Err(DeliveryError::InvalidCommitOid) + ); + assert_eq!( + CommitOid::parse("A".repeat(40)).expect("full oid").as_str(), + "a".repeat(40) + ); + assert_eq!( + CommitOid::parse("B".repeat(64)) + .expect("full sha256 oid") + .as_str(), + "b".repeat(64) + ); + } + + #[test] + fn verified_delivery_refuses_a_different_fetched_tip() { + let advertised = GitDelivery::new( + "repo", + "work", + CommitOid::parse("1".repeat(40)).expect("advertised oid"), + ) + .expect("delivery"); + + assert!(matches!( + VerifiedDelivery::from_fetched_tip( + &advertised, + CommitOid::parse("2".repeat(40)).expect("fetched oid") + ), + Err(DeliveryError::TipMismatch { .. }) + )); + } +} diff --git a/crates/mobee-core/src/delivery_git.rs b/crates/mobee-core/src/delivery_git.rs new file mode 100644 index 0000000..d7464d7 --- /dev/null +++ b/crates/mobee-core/src/delivery_git.rs @@ -0,0 +1,311 @@ +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use crate::delivery::{CommitOid, DeliveryError, DeliveryVerifier, GitDelivery, VerifiedDelivery}; + +/// Real git-backed verifier that retains fetched objects in a buyer-owned repository. +pub struct GitDeliveryVerifier { + repository: PathBuf, +} + +impl GitDeliveryVerifier { + /// Creates a verifier whose fetched object database lives at `repository`. + pub fn new(repository: impl Into) -> Self { + Self { + repository: repository.into(), + } + } + + /// Returns the local repository that holds verified delivery objects. + pub fn repository(&self) -> &Path { + &self.repository + } + + fn ensure_repository(&self, oid: &CommitOid) -> Result<(), DeliveryError> { + if self.repository.join("HEAD").is_file() { + let output = git_output([ + OsStr::new("-C"), + self.repository.as_os_str(), + OsStr::new("rev-parse"), + OsStr::new("--show-object-format"), + ])?; + if !output.status.success() { + return Err(DeliveryError::GitCommandFailed("object-format")); + } + let actual = String::from_utf8_lossy(&output.stdout); + let expected = if oid.as_str().len() == 64 { + "sha256" + } else { + "sha1" + }; + if actual.trim() != expected { + return Err(DeliveryError::GitCommandFailed("object-format")); + } + return Ok(()); + } + if let Some(parent) = self.repository.parent() { + fs::create_dir_all(parent).map_err(|_| DeliveryError::GitCommandFailed("init"))?; + } + let mut args = vec![OsStr::new("init"), OsStr::new("--bare")]; + if oid.as_str().len() == 64 { + args.push(OsStr::new("--object-format=sha256")); + } + args.push(OsStr::new("--")); + args.push(self.repository.as_os_str()); + let output = git_output(args)?; + require_success("init", output) + } + + fn check_branch(&self, branch: &str) -> Result<(), DeliveryError> { + let output = git_output([ + OsStr::new("check-ref-format"), + OsStr::new("--branch"), + OsStr::new(branch), + ])?; + if output.status.success() { + Ok(()) + } else { + Err(DeliveryError::InvalidBranch) + } + } + + fn fetch(&self, delivery: &GitDelivery) -> Result { + let fetched_ref = format!("refs/mobee/deliveries/{}", delivery.commit_oid().as_str()); + let refspec = format!("+refs/heads/{}:{fetched_ref}", delivery.branch()); + let output = git_output([ + OsStr::new("-C"), + self.repository.as_os_str(), + OsStr::new("fetch"), + OsStr::new("--no-tags"), + OsStr::new("--force"), + OsStr::new("--end-of-options"), + OsStr::new(delivery.repo()), + OsStr::new(&refspec), + ])?; + require_success("fetch", output)?; + + let fetched_object = format!("{fetched_ref}^{{commit}}"); + let output = git_output([ + OsStr::new("-C"), + self.repository.as_os_str(), + OsStr::new("rev-parse"), + OsStr::new("--verify"), + OsStr::new(&fetched_object), + ])?; + if !output.status.success() { + return Err(DeliveryError::MissingFetchedTip); + } + let oid = String::from_utf8(output.stdout) + .map_err(|_| DeliveryError::MissingFetchedTip)? + .trim() + .to_owned(); + CommitOid::parse(oid).map_err(|_| DeliveryError::MissingFetchedTip) + } + + fn require_local_object(&self, oid: &CommitOid) -> Result<(), DeliveryError> { + let object = format!("{}^{{commit}}", oid.as_str()); + let output = git_output([ + OsStr::new("-C"), + self.repository.as_os_str(), + OsStr::new("cat-file"), + OsStr::new("-e"), + OsStr::new(&object), + ])?; + if output.status.success() { + Ok(()) + } else { + Err(DeliveryError::MissingCommitObject) + } + } +} + +impl DeliveryVerifier for GitDeliveryVerifier { + fn verify(&mut self, delivery: &GitDelivery) -> Result { + self.check_branch(delivery.branch())?; + self.ensure_repository(delivery.commit_oid())?; + let fetched_tip = self.fetch(delivery)?; + let verified = VerifiedDelivery::from_fetched_tip(delivery, fetched_tip)?; + self.require_local_object(verified.commit_oid())?; + Ok(verified) + } +} + +fn git_output(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + Command::new("git") + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GCM_INTERACTIVE", "never") + .output() + .map_err(|_| DeliveryError::GitUnavailable) +} + +fn require_success(operation: &'static str, output: Output) -> Result<(), DeliveryError> { + if output.status.success() { + Ok(()) + } else { + Err(DeliveryError::GitCommandFailed(operation)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + static NEXT_REPO: AtomicU64 = AtomicU64::new(1); + + struct Fixture { + root: PathBuf, + work: PathBuf, + remote: PathBuf, + custody: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let id = NEXT_REPO.fetch_add(1, Ordering::Relaxed); + let root = + std::env::temp_dir().join(format!("mobee-delivery-{}-{id}", std::process::id())); + let work = root.join("work"); + let remote = root.join("remote.git"); + let custody = root.join("custody.git"); + fs::create_dir_all(&work).expect("create fixture"); + run(["init", "--initial-branch=main"], Some(&work)); + run(["config", "user.name", "Mobee Test"], Some(&work)); + run( + ["config", "user.email", "mobee@example.invalid"], + Some(&work), + ); + fs::write(work.join("delivery.txt"), "one\n").expect("write delivery"); + run(["add", "delivery.txt"], Some(&work)); + run(["commit", "-m", "delivery one"], Some(&work)); + run( + ["init", "--bare", remote.to_str().expect("remote path")], + None, + ); + run( + [ + "remote", + "add", + "origin", + remote.to_str().expect("remote path"), + ], + Some(&work), + ); + run(["push", "origin", "main"], Some(&work)); + Self { + root, + work, + remote, + custody, + } + } + + fn head(&self) -> CommitOid { + let output = Command::new("git") + .args([ + "-C", + self.work.to_str().expect("work path"), + "rev-parse", + "HEAD", + ]) + .output() + .expect("rev-parse"); + assert!(output.status.success()); + CommitOid::parse(String::from_utf8(output.stdout).expect("utf8").trim()) + .expect("head oid") + } + + fn delivery(&self, oid: CommitOid) -> GitDelivery { + GitDelivery::new(self.remote.to_str().expect("remote path"), "main", oid) + .expect("delivery") + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn run(args: [&str; N], cwd: Option<&Path>) { + let mut command = Command::new("git"); + command.args(args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let output = command.output().expect("run git fixture command"); + assert!( + output.status.success(), + "git fixture command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn fetch_tip_match_returns_custodied_commit() { + let fixture = Fixture::new(); + let advertised = fixture.delivery(fixture.head()); + let mut verifier = GitDeliveryVerifier::new(&fixture.custody); + + let verified = verifier.verify(&advertised).expect("verify delivery"); + + assert_eq!(verified.commit_oid(), advertised.commit_oid()); + let object = format!("{}^{{commit}}", verified.commit_oid()); + let status = Command::new("git") + .args([ + "-C", + fixture.custody.to_str().expect("custody path"), + "cat-file", + "-e", + &object, + ]) + .status() + .expect("inspect custody"); + assert!(status.success()); + } + + #[test] + fn moved_tip_refuses_the_stale_advertised_oid() { + let fixture = Fixture::new(); + let advertised = fixture.delivery(fixture.head()); + let mut verifier = GitDeliveryVerifier::new(&fixture.custody); + verifier + .verify(&advertised) + .expect("initial delivery verifies"); + fs::write(fixture.work.join("delivery.txt"), "two\n").expect("advance delivery"); + run(["add", "delivery.txt"], Some(&fixture.work)); + run(["commit", "-m", "delivery two"], Some(&fixture.work)); + run(["push", "origin", "main"], Some(&fixture.work)); + + assert!(matches!( + verifier.verify(&advertised), + Err(DeliveryError::TipMismatch { .. }) + )); + } + + #[test] + fn malformed_branch_refuses_before_creating_the_custody_repo() { + let fixture = Fixture::new(); + let delivery = GitDelivery::new( + fixture.remote.to_str().expect("remote path"), + "bad..branch", + fixture.head(), + ) + .expect("delivery shape"); + let mut verifier = GitDeliveryVerifier::new(&fixture.custody); + + assert_eq!( + verifier.verify(&delivery), + Err(DeliveryError::InvalidBranch) + ); + assert!(!fixture.custody.exists()); + } +} diff --git a/crates/mobee-core/src/gateway.rs b/crates/mobee-core/src/gateway.rs index 50fd73c..b87c06d 100644 --- a/crates/mobee-core/src/gateway.rs +++ b/crates/mobee-core/src/gateway.rs @@ -2,6 +2,8 @@ use std::fmt; use serde::{Deserialize, Serialize}; +use crate::delivery::{CommitOid, DeliveryError, GitDelivery}; + pub const MOBEE_TAG: &str = "mobee"; pub const PROTOCOL_VERSION: &str = "1"; @@ -182,6 +184,56 @@ pub enum OfferParseError { MissingMobeeTag, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GitResultParseError { + WrongKind(u16), + MissingTag(&'static str), + UnsupportedDelivery(String), + InvalidDelivery(DeliveryError), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BoundGitDeliveryError { + WrongOfferKind(u16), + MissingOfferTag(&'static str), + UnsupportedOfferDelivery(String), + Result(GitResultParseError), + TargetMismatch, +} + +impl fmt::Display for BoundGitDeliveryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongOfferKind(kind) => write!(f, "expected kind {JOB_OFFER_KIND}, got {kind}"), + Self::MissingOfferTag(tag) => write!(f, "missing required git offer tag {tag}"), + Self::UnsupportedOfferDelivery(delivery) => { + write!(f, "unsupported offer delivery {delivery:?}") + } + Self::Result(error) => error.fmt(f), + Self::TargetMismatch => { + f.write_str("git result repository or branch does not match the offer") + } + } + } +} + +impl std::error::Error for BoundGitDeliveryError {} + +impl fmt::Display for GitResultParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongKind(kind) => write!(f, "expected kind {JOB_RESULT_KIND}, got {kind}"), + Self::MissingTag(tag) => write!(f, "missing required git result tag {tag}"), + Self::UnsupportedDelivery(delivery) => { + write!(f, "unsupported result delivery {delivery:?}") + } + Self::InvalidDelivery(error) => write!(f, "invalid git result delivery: {error}"), + } + } +} + +impl std::error::Error for GitResultParseError {} + impl fmt::Display for OfferParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -257,6 +309,54 @@ pub fn parse_offer(event: &EventDraft) -> Result { }) } +/// Parses the buyer-visible git delivery fields carried by a result event. +pub fn parse_git_result_delivery(event: &EventDraft) -> Result { + if event.kind != JOB_RESULT_KIND { + return Err(GitResultParseError::WrongKind(event.kind)); + } + let delivery = first_tag_value(&event.tags, "delivery") + .ok_or(GitResultParseError::MissingTag("delivery"))?; + if delivery != "git" { + return Err(GitResultParseError::UnsupportedDelivery( + delivery.to_owned(), + )); + } + let repo = + first_tag_value(&event.tags, "repo").ok_or(GitResultParseError::MissingTag("repo"))?; + let branch = + first_tag_value(&event.tags, "branch").ok_or(GitResultParseError::MissingTag("branch"))?; + let commit = + first_tag_value(&event.tags, "commit").ok_or(GitResultParseError::MissingTag("commit"))?; + let commit_oid = CommitOid::parse(commit).map_err(GitResultParseError::InvalidDelivery)?; + GitDelivery::new(repo, branch, commit_oid).map_err(GitResultParseError::InvalidDelivery) +} + +/// Parses a result only when it targets the repository and branch named by the offer. +pub fn parse_bound_git_delivery( + offer: &EventDraft, + result: &EventDraft, +) -> Result { + if offer.kind != JOB_OFFER_KIND { + return Err(BoundGitDeliveryError::WrongOfferKind(offer.kind)); + } + let delivery = first_tag_value(&offer.tags, "delivery") + .ok_or(BoundGitDeliveryError::MissingOfferTag("delivery"))?; + if delivery != "git" { + return Err(BoundGitDeliveryError::UnsupportedOfferDelivery( + delivery.to_owned(), + )); + } + let offer_repo = first_tag_value(&offer.tags, "repo") + .ok_or(BoundGitDeliveryError::MissingOfferTag("repo"))?; + let offer_branch = first_tag_value(&offer.tags, "branch") + .ok_or(BoundGitDeliveryError::MissingOfferTag("branch"))?; + let delivery = parse_git_result_delivery(result).map_err(BoundGitDeliveryError::Result)?; + if delivery.repo() != offer_repo || delivery.branch() != offer_branch { + return Err(BoundGitDeliveryError::TargetMismatch); + } + Ok(delivery) +} + pub fn claim_draft(offer_id: &str, buyer_pubkey: &str, seller_pubkey: &str) -> EventDraft { feedback_draft( "processing", @@ -583,6 +683,74 @@ mod tests { assert!(has_tag_value_at(&receipt.tags, "sig", 1, "buyer")); } + #[test] + fn git_result_parses_repo_branch_and_full_commit_oid() { + let result = EventDraft::new( + JOB_RESULT_KIND, + vec![ + TagSpec::new(["delivery", "git"]), + TagSpec::new(["repo", "https://example.invalid/repo.git"]), + TagSpec::new(["branch", "mobee/job"]), + TagSpec::new(["commit", &"a".repeat(40)]), + ], + "", + ); + + let delivery = parse_git_result_delivery(&result).expect("parse git delivery"); + assert_eq!(delivery.repo(), "https://example.invalid/repo.git"); + assert_eq!(delivery.branch(), "mobee/job"); + assert_eq!(delivery.commit_oid().as_str(), "a".repeat(40)); + } + + #[test] + fn git_result_refuses_an_abbreviated_commit_oid() { + let result = EventDraft::new( + JOB_RESULT_KIND, + vec![ + TagSpec::new(["delivery", "git"]), + TagSpec::new(["repo", "repo"]), + TagSpec::new(["branch", "work"]), + TagSpec::new(["commit", "abc123"]), + ], + "", + ); + + assert_eq!( + parse_git_result_delivery(&result), + Err(GitResultParseError::InvalidDelivery( + DeliveryError::InvalidCommitOid + )) + ); + } + + #[test] + fn git_result_cannot_redirect_away_from_the_offered_repo_or_branch() { + let offer = EventDraft::new( + JOB_OFFER_KIND, + vec![ + TagSpec::new(["delivery", "git"]), + TagSpec::new(["repo", "https://example.invalid/offered.git"]), + TagSpec::new(["branch", "mobee/job"]), + ], + "", + ); + let redirected = EventDraft::new( + JOB_RESULT_KIND, + vec![ + TagSpec::new(["delivery", "git"]), + TagSpec::new(["repo", "https://attacker.invalid/other.git"]), + TagSpec::new(["branch", "mobee/job"]), + TagSpec::new(["commit", &"a".repeat(40)]), + ], + "", + ); + + assert_eq!( + parse_bound_git_delivery(&offer, &redirected), + Err(BoundGitDeliveryError::TargetMismatch) + ); + } + fn has_tag_value_at(tags: &[TagSpec], name: &str, index: usize, value: &str) -> bool { tags.iter().any(|tag| { tag.0.first().map(String::as_str) == Some(name) diff --git a/crates/mobee-core/src/lib.rs b/crates/mobee-core/src/lib.rs index 0a730a2..c44ee67 100644 --- a/crates/mobee-core/src/lib.rs +++ b/crates/mobee-core/src/lib.rs @@ -1,3 +1,6 @@ +pub mod delivery; +#[cfg(feature = "git-delivery")] +pub mod delivery_git; pub mod driver; pub mod engine; pub mod event; @@ -6,9 +9,9 @@ pub mod gateway; pub mod log; #[cfg(feature = "wallet")] pub mod payment; +pub mod payment_send; #[cfg(feature = "wallet")] pub mod payment_wallet; -pub mod payment_send; pub mod receipt; #[cfg(feature = "wallet")] pub mod wallet; diff --git a/crates/mobee-core/src/payment.rs b/crates/mobee-core/src/payment.rs index eba4975..d1336d6 100644 --- a/crates/mobee-core/src/payment.rs +++ b/crates/mobee-core/src/payment.rs @@ -10,6 +10,7 @@ use nostr_sdk::PublicKey as NostrPublicKey; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::delivery::{DeliveryError, DeliveryVerifier, GitDelivery}; use crate::payment_send::PaymentSent; use crate::wallet::VerifiedPayment; @@ -51,13 +52,24 @@ impl ResultId { #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] -/// Validated streamed result-content digest. -pub struct ContentHash(String); +/// Validated integrity identifier for delivered work. +pub struct DeliveryIntegrityHash(String); -impl ContentHash { - /// Parses a lowercase SHA-256 digest. +impl DeliveryIntegrityHash { + /// Parses a full git commit oid or lowercase SHA-256 content digest. pub fn from_hex(value: impl Into) -> Result { - digest_hex(value.into(), "content hash").map(Self) + let value = value.into(); + let valid_length = value.len() == 40 || value.len() == 64; + let lowercase_hex = value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)); + if valid_length && lowercase_hex { + Ok(Self(value)) + } else { + Err(PaymentError::InvalidInput( + "delivery integrity hash must be full lowercase git or SHA-256 hex".into(), + )) + } } /// Returns the digest hex. @@ -99,7 +111,7 @@ impl AttemptId { hasher.update(ATTEMPT_DOMAIN); hash_field(&mut hasher, key.job_id.as_str()); hash_field(&mut hasher, key.result_id.as_str()); - hash_field(&mut hasher, key.content_hash.as_str()); + hash_field(&mut hasher, key.delivery_integrity_hash.as_str()); hash_field(&mut hasher, key.job_hash.as_str()); hash_field(&mut hasher, &key.seller_pubkey.to_string()); hash_field(&mut hasher, &key.amount.to_string()); @@ -143,7 +155,8 @@ impl PaymentTerms { pub struct PaymentKey { pub job_id: JobId, pub result_id: ResultId, - pub content_hash: ContentHash, + #[serde(alias = "content_hash")] + pub delivery_integrity_hash: DeliveryIntegrityHash, pub job_hash: JobHash, pub seller_pubkey: NostrPublicKey, pub amount: Amount, @@ -156,14 +169,14 @@ impl PaymentKey { pub fn new( job_id: JobId, result_id: ResultId, - content_hash: ContentHash, + delivery_integrity_hash: DeliveryIntegrityHash, job_hash: JobHash, terms: &PaymentTerms, ) -> Self { Self { job_id, result_id, - content_hash, + delivery_integrity_hash, job_hash, seller_pubkey: terms.seller_nostr_pubkey, amount: terms.amount, @@ -524,8 +537,29 @@ impl<'a, J: PaymentJournal> PaymentService<'a, J> { Self { journal } } - /// Advances a payment as far as its current durable state permits. - pub fn run( + /// Verifies buyer custody before allowing the first durable payment intent. + pub fn run( + &self, + delivery: &GitDelivery, + delivery_verifier: &mut D, + key: &PaymentKey, + terms: &PaymentTerms, + authority: &ReceiptAuthority, + effects: &mut E, + ) -> Result { + let verified = delivery_verifier + .verify(delivery) + .map_err(PaymentError::Delivery)?; + if key.delivery_integrity_hash.as_str() != verified.commit_oid().as_str() { + return Err(PaymentError::Refused( + "payment key does not bind the verified delivery commit".into(), + )); + } + self.advance(key, terms, authority, effects) + } + + /// Advances an already delivery-gated payment inside the core crate. + pub(crate) fn advance( &self, key: &PaymentKey, terms: &PaymentTerms, @@ -781,6 +815,7 @@ pub enum PaymentError { to: &'static str, }, Journal(JournalError), + Delivery(DeliveryError), Effect(EffectError), NoRelayAccepted, AmbiguousSendRefused, @@ -796,6 +831,7 @@ impl fmt::Display for PaymentError { write!(formatter, "illegal payment transition: {from} -> {to}") } Self::Journal(error) => error.fmt(formatter), + Self::Delivery(error) => write!(formatter, "delivery verification refused: {error}"), Self::Effect(error) => write!(formatter, "payment effect failed: {error}"), Self::NoRelayAccepted => formatter.write_str("no relay accepted the payment"), Self::AmbiguousSendRefused => { @@ -957,6 +993,78 @@ mod tests { assert_ne!(sat.attempt_id(), msat.attempt_id()); } + #[test] + fn delivery_refusal_leaves_no_intent_and_fires_no_wallet_effect() { + let journal = MemoryPaymentJournal::default(); + let shared = FakeShared::default(); + let mut effects = FakeEffects::new(shared.clone()); + let mut verifier = RejectDelivery; + + let result = PaymentService::new(&journal).run( + &git_delivery(), + &mut verifier, + &git_key(), + &terms(), + &authority(), + &mut effects, + ); + + assert!(matches!(result, Err(PaymentError::Delivery(_)))); + assert!(journal.records().is_empty()); + assert_eq!(shared.lock_calls.load(Ordering::SeqCst), 0); + assert_eq!(shared.mint_count.load(Ordering::SeqCst), 0); + } + + #[test] + fn verified_delivery_commit_reaches_the_existing_payment_spine() { + let journal = MemoryPaymentJournal::default(); + let shared = FakeShared::default(); + let mut effects = FakeEffects::new(shared.clone()); + let mut verifier = AcceptDelivery; + + let state = PaymentService::new(&journal) + .run( + &git_delivery(), + &mut verifier, + &git_key(), + &terms(), + &authority(), + &mut effects, + ) + .expect("verified delivery payment"); + + assert!(matches!(state, PaymentState::Closed { .. })); + assert_eq!(shared.mint_count.load(Ordering::SeqCst), 1); + } + + #[test] + fn payment_key_must_bind_the_verified_commit_before_intent() { + let journal = MemoryPaymentJournal::default(); + let shared = FakeShared::default(); + let mut effects = FakeEffects::new(shared.clone()); + let mut verifier = AcceptDelivery; + let wrong_key = PaymentKey::new( + JobId::new("job").unwrap(), + ResultId::new("result").unwrap(), + DeliveryIntegrityHash::from_hex("44".repeat(20)).unwrap(), + JobHash::from_hex("22".repeat(32)).unwrap(), + &terms(), + ); + + let result = PaymentService::new(&journal).run( + &git_delivery(), + &mut verifier, + &wrong_key, + &terms(), + &authority(), + &mut effects, + ); + + assert!(matches!(result, Err(PaymentError::Refused(_)))); + assert!(journal.records().is_empty()); + assert_eq!(shared.lock_calls.load(Ordering::SeqCst), 0); + } + #[test] fn service_mints_at_most_once_across_retry_and_concurrency() { let journal = Arc::new(MemoryPaymentJournal::default()); @@ -974,7 +1082,7 @@ mod tests { let authority = Arc::clone(&authority); thread::spawn(move || { let mut effects = FakeEffects::new(shared); - PaymentService::new(journal.as_ref()).run( + PaymentService::new(journal.as_ref()).advance( &key, &terms, &authority, @@ -1000,7 +1108,7 @@ mod tests { crashing.crash_after_lock = true; let first = - PaymentService::new(&journal).run(&key(), &terms(), &authority(), &mut crashing); + PaymentService::new(&journal).advance(&key(), &terms(), &authority(), &mut crashing); assert!(matches!(first, Err(PaymentError::Effect(_)))); assert!(matches!( journal.records().last().map(|record| &record.value), @@ -1009,7 +1117,7 @@ mod tests { let mut recovered = FakeEffects::new(shared.clone()); let result = PaymentService::new(&journal) - .run(&key(), &terms(), &authority(), &mut recovered) + .advance(&key(), &terms(), &authority(), &mut recovered) .unwrap(); assert!(matches!(result, PaymentState::Closed { .. })); @@ -1027,7 +1135,7 @@ mod tests { assert!( PaymentService::new(&journal) - .run(&key(), &terms(), &authority(), &mut crashing) + .advance(&key(), &terms(), &authority(), &mut crashing) .is_err() ); @@ -1035,7 +1143,7 @@ mod tests { recovered.blind_lock = true; assert!( PaymentService::new(&journal) - .run(&key(), &terms(), &authority(), &mut recovered) + .advance(&key(), &terms(), &authority(), &mut recovered) .is_ok() ); @@ -1049,9 +1157,11 @@ mod tests { let mut effects = FakeEffects::new(shared); effects.ordering_journal = Some(journal.clone()); - assert!(PaymentService::new(&journal) - .run(&key(), &terms(), &authority(), &mut effects) - .is_ok()); + assert!( + PaymentService::new(&journal) + .advance(&key(), &terms(), &authority(), &mut effects) + .is_ok() + ); assert_eq!(journal.sync_count(), 5); } @@ -1064,7 +1174,7 @@ mod tests { assert!( PaymentService::new(&journal) - .run(&key(), &terms(), &authority(), &mut effects) + .advance(&key(), &terms(), &authority(), &mut effects) .is_ok() ); } @@ -1086,7 +1196,7 @@ mod tests { effects.locked_terms = Some(locked_terms); let error = PaymentService::new(&journal) - .run(&key(), &expected, &authority(), &mut effects) + .advance(&key(), &expected, &authority(), &mut effects) .unwrap_err(); assert!( @@ -1108,7 +1218,8 @@ mod tests { let mut effects = FakeEffects::new(shared.clone()); effects.empty_send = true; - let first = PaymentService::new(&journal).run(&key(), &terms(), &authority(), &mut effects); + let first = + PaymentService::new(&journal).advance(&key(), &terms(), &authority(), &mut effects); assert!(matches!(first, Err(PaymentError::NoRelayAccepted))); assert!(matches!( journal.records().last().map(|record| &record.value), @@ -1117,7 +1228,7 @@ mod tests { let mut retry = FakeEffects::new(shared.clone()); assert!(matches!( - PaymentService::new(&journal).run(&key(), &terms(), &authority(), &mut retry), + PaymentService::new(&journal).advance(&key(), &terms(), &authority(), &mut retry), Err(PaymentError::AmbiguousSendRefused) )); assert_eq!(shared.send_count.load(Ordering::SeqCst), 1); @@ -1131,7 +1242,7 @@ mod tests { effects.fail_receipt = true; assert!(matches!( - PaymentService::new(&journal).run(&key(), &terms(), &authority(), &mut effects), + PaymentService::new(&journal).advance(&key(), &terms(), &authority(), &mut effects), Err(PaymentError::Effect(_)) )); assert!(matches!( @@ -1142,7 +1253,7 @@ mod tests { let mut retry = FakeEffects::new(shared.clone()); assert!(matches!( PaymentService::new(&journal) - .run(&key(), &terms(), &authority(), &mut retry) + .advance(&key(), &terms(), &authority(), &mut retry) .unwrap(), PaymentState::Closed { .. } )); @@ -1190,7 +1301,7 @@ mod tests { assert!(matches!( PaymentService::new(&journal) - .run(&payment_key, &terms(), &authority(), &mut effects) + .advance(&payment_key, &terms(), &authority(), &mut effects) .unwrap(), PaymentState::Closed { .. } )); @@ -1207,7 +1318,7 @@ mod tests { effects.forged_receipt = true; assert!(matches!( - PaymentService::new(&journal).run(&key(), &terms(), &authority(), &mut effects), + PaymentService::new(&journal).advance(&key(), &terms(), &authority(), &mut effects), Err(PaymentError::ForgedReceipt) )); assert!(matches!( @@ -1318,6 +1429,31 @@ mod tests { replay_sync_journal: Option, } + struct RejectDelivery; + + impl DeliveryVerifier for RejectDelivery { + fn verify( + &mut self, + _delivery: &GitDelivery, + ) -> Result { + Err(DeliveryError::GitCommandFailed("fetch")) + } + } + + struct AcceptDelivery; + + impl DeliveryVerifier for AcceptDelivery { + fn verify( + &mut self, + delivery: &GitDelivery, + ) -> Result { + crate::delivery::VerifiedDelivery::from_fetched_tip( + delivery, + delivery.commit_oid().clone(), + ) + } + } + impl FakeEffects { fn new(shared: FakeShared) -> Self { Self { @@ -1455,7 +1591,26 @@ mod tests { PaymentKey::new( JobId::new("job").unwrap(), ResultId::new("result").unwrap(), - ContentHash::from_hex("11".repeat(32)).unwrap(), + DeliveryIntegrityHash::from_hex("11".repeat(32)).unwrap(), + JobHash::from_hex("22".repeat(32)).unwrap(), + &terms(), + ) + } + + fn git_delivery() -> GitDelivery { + GitDelivery::new( + "https://example.invalid/repo.git", + "mobee/job", + crate::delivery::CommitOid::parse("33".repeat(20)).unwrap(), + ) + .unwrap() + } + + fn git_key() -> PaymentKey { + PaymentKey::new( + JobId::new("job").unwrap(), + ResultId::new("result").unwrap(), + DeliveryIntegrityHash::from_hex("33".repeat(20)).unwrap(), JobHash::from_hex("22".repeat(32)).unwrap(), &terms(), ) diff --git a/crates/mobee-core/src/payment_wallet.rs b/crates/mobee-core/src/payment_wallet.rs index d47bcf7..0b476f0 100644 --- a/crates/mobee-core/src/payment_wallet.rs +++ b/crates/mobee-core/src/payment_wallet.rs @@ -566,7 +566,7 @@ mod tests { use super::*; use crate::gateway::ParsedOffer; use crate::payment::{ - ContentHash, JobHash, JobId, MemoryPaymentJournal, PaymentKey, PaymentService, + DeliveryIntegrityHash, JobHash, JobId, MemoryPaymentJournal, PaymentKey, PaymentService, PaymentState, ReceiptAuthority, ResultId, }; use crate::payment_send::{PaymentSendError, PaymentSent}; @@ -853,7 +853,7 @@ mod tests { let journal = MemoryPaymentJournal::default(); let state = PaymentService::new(&journal) - .run(&key, &fixture.terms, &authority, &mut effects) + .advance(&key, &fixture.terms, &authority, &mut effects) .unwrap(); assert!(matches!(state, PaymentState::Closed { .. })); @@ -899,7 +899,7 @@ mod tests { let journal = MemoryPaymentJournal::default(); let state = PaymentService::new(&journal) - .run(&key, &fixture.terms, &authority, &mut effects) + .advance(&key, &fixture.terms, &authority, &mut effects) .unwrap(); assert!(matches!(state, PaymentState::Closed { .. })); @@ -1065,7 +1065,7 @@ mod tests { PaymentKey::new( JobId::new("job").unwrap(), ResultId::new("result").unwrap(), - ContentHash::from_hex("11".repeat(32)).unwrap(), + DeliveryIntegrityHash::from_hex("11".repeat(32)).unwrap(), JobHash::from_hex("22".repeat(32)).unwrap(), terms, ) diff --git a/crates/mobee-core/src/receipt.rs b/crates/mobee-core/src/receipt.rs index 46de250..84757a6 100644 --- a/crates/mobee-core/src/receipt.rs +++ b/crates/mobee-core/src/receipt.rs @@ -7,8 +7,9 @@ pub const RECEIPT_HASH_DOMAIN: &str = "mobee/v1/receipt"; pub struct ReceiptHashInput { /// Market/offer job id; this is part of the receipt hash tuple and must not change. pub job_id: String, - /// SHA-256 hex digest of the delivered result content. - pub result_content_hash: String, + /// Integrity identifier for the delivered work (commit oid for git delivery). + #[serde(alias = "result_content_hash")] + pub delivery_integrity_hash: String, /// Integer payment amount for the receipt. pub price_int: u64, /// Payment unit for `price_int`, such as `sat`. @@ -26,7 +27,7 @@ impl ReceiptHashInput { serde_json::to_string(&serde_json::json!([ RECEIPT_HASH_DOMAIN, self.job_id, - self.result_content_hash, + self.delivery_integrity_hash, self.price_int, self.unit, self.mint_url, @@ -56,7 +57,7 @@ mod tests { fn input() -> ReceiptHashInput { ReceiptHashInput { job_id: "job".into(), - result_content_hash: "result-content-hash".into(), + delivery_integrity_hash: "delivery-integrity-hash".into(), price_int: 7, unit: "sat".into(), mint_url: "https://testnut.cashu.space".into(), @@ -69,7 +70,7 @@ mod tests { fn canonical_json_matches_locked_receipt_tuple_order() { assert_eq!( input().canonical_json(), - "[\"mobee/v1/receipt\",\"job\",\"result-content-hash\",7,\"sat\",\"https://testnut.cashu.space\",\"buyer\",\"seller\"]" + "[\"mobee/v1/receipt\",\"job\",\"delivery-integrity-hash\",7,\"sat\",\"https://testnut.cashu.space\",\"buyer\",\"seller\"]" ); } @@ -90,4 +91,29 @@ mod tests { assert_ne!(base, changed.hash_hex()); assert_eq!(base.len(), 64); } + + #[test] + fn receipt_hash_binds_the_verified_delivery_oid() { + let first = input(); + let mut second = first.clone(); + second.delivery_integrity_hash = "b".repeat(40); + + assert_ne!(first.hash_hex(), second.hash_hex()); + } + + #[test] + fn legacy_result_content_hash_field_still_deserializes() { + let legacy = serde_json::json!({ + "job_id": "job", + "result_content_hash": "legacy-hash", + "price_int": 7, + "unit": "sat", + "mint_url": "https://testnut.cashu.space", + "buyer_pubkey_hex": "buyer", + "seller_pubkey_hex": "seller" + }); + + let parsed: ReceiptHashInput = serde_json::from_value(legacy).expect("legacy receipt"); + assert_eq!(parsed.delivery_integrity_hash, "legacy-hash"); + } } From 31ef49f7013c65c63321444c72e8a6bb3a23e721 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 14 Jul 2026 22:50:57 -0700 Subject: [PATCH 2/3] fix(core): drop dead serde aliases on payment/receipt hashes Legacy field names never existed in production journals; aliases enabled fail-open deserialize. Refuse old names; keep result_content_hash_hex. Co-authored-by: Cursor --- crates/mobee-core/src/payment.rs | 24 +++++++++++++++++++++++- crates/mobee-core/src/receipt.rs | 18 ++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/mobee-core/src/payment.rs b/crates/mobee-core/src/payment.rs index d1336d6..f2c76f1 100644 --- a/crates/mobee-core/src/payment.rs +++ b/crates/mobee-core/src/payment.rs @@ -155,7 +155,6 @@ impl PaymentTerms { pub struct PaymentKey { pub job_id: JobId, pub result_id: ResultId, - #[serde(alias = "content_hash")] pub delivery_integrity_hash: DeliveryIntegrityHash, pub job_hash: JobHash, pub seller_pubkey: NostrPublicKey, @@ -993,6 +992,29 @@ mod tests { assert_ne!(sat.attempt_id(), msat.attempt_id()); } + #[test] + fn legacy_content_hash_field_name_refuses_to_deserialize() { + let mut value = serde_json::to_value(key()).expect("serialize payment key"); + let object = value.as_object_mut().expect("payment key object"); + let hash = object + .remove("delivery_integrity_hash") + .expect("delivery_integrity_hash present"); + object.insert("content_hash".into(), hash); + + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn payment_key_delivery_integrity_hash_round_trips() { + let original = key(); + let json = serde_json::to_value(&original).expect("serialize payment key"); + assert!(json.get("delivery_integrity_hash").is_some()); + assert!(json.get("content_hash").is_none()); + + let parsed: PaymentKey = serde_json::from_value(json).expect("deserialize payment key"); + assert_eq!(parsed, original); + } + #[test] fn delivery_refusal_leaves_no_intent_and_fires_no_wallet_effect() { let journal = MemoryPaymentJournal::default(); diff --git a/crates/mobee-core/src/receipt.rs b/crates/mobee-core/src/receipt.rs index 84757a6..554b150 100644 --- a/crates/mobee-core/src/receipt.rs +++ b/crates/mobee-core/src/receipt.rs @@ -8,7 +8,6 @@ pub struct ReceiptHashInput { /// Market/offer job id; this is part of the receipt hash tuple and must not change. pub job_id: String, /// Integrity identifier for the delivered work (commit oid for git delivery). - #[serde(alias = "result_content_hash")] pub delivery_integrity_hash: String, /// Integer payment amount for the receipt. pub price_int: u64, @@ -102,7 +101,7 @@ mod tests { } #[test] - fn legacy_result_content_hash_field_still_deserializes() { + fn legacy_result_content_hash_field_name_refuses_to_deserialize() { let legacy = serde_json::json!({ "job_id": "job", "result_content_hash": "legacy-hash", @@ -113,7 +112,18 @@ mod tests { "seller_pubkey_hex": "seller" }); - let parsed: ReceiptHashInput = serde_json::from_value(legacy).expect("legacy receipt"); - assert_eq!(parsed.delivery_integrity_hash, "legacy-hash"); + assert!(serde_json::from_value::(legacy).is_err()); + } + + #[test] + fn receipt_delivery_integrity_hash_round_trips() { + let original = input(); + let json = serde_json::to_value(&original).expect("serialize receipt"); + assert!(json.get("delivery_integrity_hash").is_some()); + assert!(json.get("result_content_hash").is_none()); + + let parsed: ReceiptHashInput = + serde_json::from_value(json).expect("deserialize receipt"); + assert_eq!(parsed, original); } } From 6d484b2c5e30fa2ced3781ca58fb065449c24324 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 15 Jul 2026 17:01:00 -0700 Subject: [PATCH 3/3] docs(core): clarify isolated refs/mobee/deliveries/ fetch namespace (PR #14 review) The delivery fetch stages the untrusted seller branch in an isolated, OID-keyed local ref namespace (not refs/heads or refs/remotes) so it cannot masquerade as a real branch or remote-tracking ref, keyed by the claimed commit OID to avoid cross-delivery collision. Internal verify-staging, not a config knob. Answers the review comment on delivery_git.rs line 75. Co-Authored-By: Claude Opus 4.8 --- crates/mobee-core/src/delivery_git.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/mobee-core/src/delivery_git.rs b/crates/mobee-core/src/delivery_git.rs index d7464d7..651c7e4 100644 --- a/crates/mobee-core/src/delivery_git.rs +++ b/crates/mobee-core/src/delivery_git.rs @@ -72,6 +72,12 @@ impl GitDeliveryVerifier { } fn fetch(&self, delivery: &GitDelivery) -> Result { + // Stage the untrusted seller branch in an isolated, OID-keyed local ref + // namespace — deliberately NOT refs/heads/* or refs/remotes/*, so a delivery + // cannot masquerade as a real branch or remote-tracking ref, and each delivery + // is uniquely keyed by its claimed commit OID (no cross-delivery collision). + // Internal verify-staging only, not a config knob: the buyer then resolves + // `^{commit}` and tip-matches the expected OID before paying. let fetched_ref = format!("refs/mobee/deliveries/{}", delivery.commit_oid().as_str()); let refspec = format!("+refs/heads/{}:{fetched_ref}", delivery.branch()); let output = git_output([