Skip to content

Commit

Permalink
feat: memo signature
Browse files Browse the repository at this point in the history
When providing a Tari address in a Shopify order, we cannot let users provide just
any wallet address there, because this would let folks put other wallet addresses in
there and hope that one day someone makes a payment from that wallet and their order will
then be fulfilled.

This module provides the method and standard format for providing the
wallet address and signature into the memo field f an order that proves
the buyer owns the wallet.

* Updating nightly version

Some updated dependencies were failing on nightly (dalek!)
Updated nightly version to one where stdsimd has been stabilised seems
to fix this:
rust-lang/rust#48556

feat: memo signature utility

Add a utility to generate the order memo signature object.

Run it like this

```
memo_signature <address> <order_id> <secret_key>
```

The result is an object like
```json
{
  "address":"b8971598a865b25b6508d4ba154db228e044f367bd9a1ef50dd4051db42b63143d",
  "order_id":"alice001",
  "signature":"56e39d539f1865742b41993bdc771a2d0c16b35c83c57ca6173f8c1ced34140aeaf32bfdc0629e73f971344e7e45584cbbb778dc98564d0ec5c419e6f9ff5d06"
}
```
  • Loading branch information
CjS77 committed May 24, 2024
1 parent 69b9f6a commit 02e14cf
Show file tree
Hide file tree
Showing 17 changed files with 660 additions and 344 deletions.
638 changes: 351 additions & 287 deletions Cargo.lock

Large diffs are not rendered by default.

40 changes: 31 additions & 9 deletions e2e/tests/cucumber/steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use tari_jwt::{
Ristretto256SigningKey,
};
use tari_payment_engine::db_types::{MicroTari, OrderId, Role};
use tari_payment_server::auth::{build_jwt_signer, JwtClaims};
use tari_payment_server::{
auth::{build_jwt_signer, JwtClaims},
shopify_order::ShopifyOrder,
};
use tokio::time::sleep;

use crate::cucumber::{
Expand Down Expand Up @@ -158,23 +161,42 @@ async fn expire_access_token(world: &mut TPGWorld) {
world.access_token = Some(token);
}

#[when(expr = "{word} places an order \"{word}\" for {int} XTR, memo = {string}")]
async fn place_short_order(world: &mut TPGWorld, customer_id: String, order_id: String, amount: i64, memo: String) {
#[when(expr = "Customer #{int} [{string}] places order \"{word}\" for {int} XTR, with memo")]
async fn place_short_order(world: &mut TPGWorld, user: i64, email: String, order_id: String, amount: i64, step: &Step) {
let now = chrono::Utc::now();
place_order(world, customer_id, order_id, amount, memo, now.to_rfc3339()).await;
place_order(world, user, email, order_id, amount, now.to_rfc3339(), step).await;
}

#[when(expr = "{word} places an order \"{word}\" for {int} XTR, memo = {string} at {string}")]
#[when(expr = "Customer #{int} [{string}] places order \"{word}\" for {int} XTR at {string}, with memo")]
async fn place_order(
world: &mut TPGWorld,
customer_id: String,
user: i64,
email: String,
order_id: String,
amount: i64,
memo: String,
address: String,
created_at: String,
step: &Step,
) {
let order_id = OrderId(order_id);
let memo = step.docstring().map(String::from);
world.response = None;
let res = world
.request(Method::POST, "/shopify/webhook/checkout_create", |req| {
let mut order = ShopifyOrder::default();
order.created_at = created_at;
order.name = order_id;
order.note = memo;
order.currency = "XTR".to_string();
order.total_price = MicroTari::from(amount).value().to_string();
order.user_id = Some(user);
order.email = email;
let order = serde_json::to_string(&order).expect("Failed to serialize order");
req.body(order).header("Content-Type", "application/json")
})
.await;
trace!("Got Response: {} {}", res.0, res.1);
world.response = Some(res);
}

fn modify_signature(token: String, value: &str) -> String {
let mut parts = token.split('.').map(|s| s.to_owned()).collect::<Vec<_>>();
let n = value.len();
Expand Down
15 changes: 11 additions & 4 deletions e2e/tests/features/payment_flow.feature
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ Feature: Order flow
Given a blank slate

Scenario: Standard order flow
When Alice places an order "alice001" on the store. Memo "": "Item A" for 100T, "Item B" for 200T
Then Alice's account has a balance of 300 Tari
Then Alice's order "alice001" is pending
When Alice's sends a payment of 300 Tari
When Customer #1 ["alice"] places order "alice001" for 2500 XTR, with memo
"""
{ "address": "b8971598a865b25b6508d4ba154db228e044f367bd9a1ef50dd4051db42b63143d",
"order_id": "alice001",
"signature": "deadbeef34534534534534543435345"
}
"""
Then Customer #1 has a balance of 2500 Tari
Then order "alice001" is in state pending
When Alice sends a payment of 2525 Tari
Then order "alice001" is fulfilled
And Alice has a balance of 25 Tari


File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[toolchain]
channel = "nightly-2023-11-15"
channel = "nightly-2024-02-20"
8 changes: 8 additions & 0 deletions tari_payment_engine/src/db_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ impl AsRef<TariAddress> for SerializedTariAddress {
}
}

impl FromStr for SerializedTariAddress {
type Err = TariAddressError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<TariAddress>().map(Self)
}
}

impl TryFrom<String> for SerializedTariAddress {
type Error = TariAddressError;

Expand Down
27 changes: 27 additions & 0 deletions tari_payment_server/examples/memo_signature.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
use tari_common_types::tari_address::TariAddress;
use tari_jwt::tari_crypto::{ristretto::RistrettoSecretKey, tari_utilities::hex::Hex};
use tari_payment_server::memo_signature::MemoSignature;

fn main() {
let mut args = std::env::args();
args.next(); // executable name
let Some(address) = args.next().and_then(|s| s.parse::<TariAddress>().ok()) else {
println!("Address is required");
return;
};
let Some(order_id) = args.next() else {
println!("Order ID is required");
return;
};
let Some(secret_key) = args.next().and_then(|k| RistrettoSecretKey::from_hex(&k).ok()) else {
println!("Secret key is required");
return;
};

match MemoSignature::create(address, order_id, &secret_key) {
Ok(signature) => {
println!("Memo signature: {}", signature.as_json());
},
Err(e) => eprintln!("Invalid input. {e}"),
}
}
2 changes: 2 additions & 0 deletions tari_payment_server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub mod errors;
pub mod helpers;

pub mod middleware;

pub mod memo_signature;
pub mod routes;
pub mod server;

Expand Down
184 changes: 184 additions & 0 deletions tari_payment_server/src/memo_signature.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! # Order memo signature format
//!
//! When providing a Tari address in a Shopify order, we cannot let users provide just any wallet address there,
//! because this would let folks put other wallet addresses in there and hope that one day someone makes a payment
//! from that wallet and their order will then be fulfilled.
//!
//! Users need to _prove_ that they own the wallet address they provide in the order. This is done by signing a message
//! with the wallet's private key. The message is constructed from the wallet address and the order ID (preventing
//! naughty people from using the same signature for their own orders, and again, trying to get free stuff).
//!
//! The signature is then stored in the order memo field, and the payment server can verify the signature by checking
//! the wallet's public key against the signature.
//!
//! ## Message format
//!
//! The message is constructed by concatenating the wallet address and the order ID, separated by a colon.
//! The challenge is a domain-separated Schnorr signature. The full format is:
//!
//! ```text
//! {aaaaaaaa}MemoSignature.v1.challenge{bbbbbbbb}{address}:{order_id}
//! ```
//!
//! where
//! * `aaaaaaaa` is the length of `MemoSignature.v1.challenge`, i.e. 25 in little-endian format.
//! * `bbbbbbbb` is the length of `address`(64) + `:`(1) + `order_id.len()` in little-endian format.
//! * `address` is the Tari address of the wallet owner, in hexadecimal
//! * `order_id` is the order ID, a string
//!
//! The message is then hashed with `Blake2b<U64>` to get the challenge.

use serde::{Deserialize, Serialize};
use tari_common_types::tari_address::TariAddress;
use tari_jwt::tari_crypto::{
hash_domain,
ristretto::{RistrettoPublicKey, RistrettoSchnorrWithDomain, RistrettoSecretKey},
signatures::SchnorrSignatureError,
tari_utilities::{hex::Hex, message_format::MessageFormat},
};
use tari_payment_engine::db_types::SerializedTariAddress;
use thiserror::Error;

hash_domain!(MemoSignatureDomain, "MemoSignature");

pub type MemoSchnorr = RistrettoSchnorrWithDomain<MemoSignatureDomain>;

#[derive(Debug, Clone, Error)]
#[error("Invalid memo signature: {0}")]
pub struct MemoSignatureError(String);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoSignature {
pub address: SerializedTariAddress,
pub order_id: String,
#[serde(serialize_with = "ser_sig", deserialize_with = "de_sig")]
pub signature: MemoSchnorr,
}

impl MemoSignature {
pub fn create(
address: TariAddress,
order_id: String,
secret_key: &RistrettoSecretKey,
) -> Result<Self, MemoSignatureError> {
let address = SerializedTariAddress::from(address);
let message = signature_message(&address, &order_id);
let signature = sign_message(&message, secret_key).map_err(|e| MemoSignatureError(e.to_string()))?;
Ok(Self { address, order_id, signature })
}

pub fn new(address: &str, order_id: &str, signature: &str) -> Result<Self, MemoSignatureError> {
let address = address.parse::<SerializedTariAddress>().map_err(|e| MemoSignatureError(e.to_string()))?;
let signature = hex_to_memo_schnorr(signature).map_err(|e| MemoSignatureError(e.to_string()))?;
let order_id = order_id.to_string();
Ok(Self { address, order_id, signature })
}

pub fn message(&self) -> String {
signature_message(&self.address, &self.order_id)
}

pub fn is_valid(&self) -> bool {
let message = self.message();
let pubkey = self.address.as_address().public_key();
println!(
"Verifying. pubkey: {:x}. nonce: {:x}, sig:{}",
pubkey,
self.signature.get_public_nonce(),
self.signature.get_signature().reveal().to_string()
);
self.signature.verify(pubkey, message)
}

pub fn as_json(&self) -> String {
serde_json::to_string(self).unwrap()
}
}

pub fn signature_message(address: &SerializedTariAddress, order_id: &str) -> String {
let addr = address.as_address().to_hex();
format!("{addr}:{order_id}")
}

pub fn sign_message(message: &str, secret_key: &RistrettoSecretKey) -> Result<MemoSchnorr, SchnorrSignatureError> {
let mut rng = rand::thread_rng();
MemoSchnorr::sign(secret_key, message.as_bytes(), &mut rng)
}

pub fn ser_sig<S>(sig: &MemoSchnorr, s: S) -> Result<S::Ok, S::Error>
where S: serde::Serializer {
let nonce = sig.get_public_nonce().to_hex();
let sig = sig.get_signature().to_hex();
s.serialize_str(&format!("{nonce}{sig}"))
}

pub fn de_sig<'de, D>(d: D) -> Result<MemoSchnorr, D::Error>
where D: serde::Deserializer<'de> {
let s = String::deserialize(d)?;
hex_to_memo_schnorr(&s).map_err(serde::de::Error::custom)
}

pub fn hex_to_memo_schnorr(s: &str) -> Result<MemoSchnorr, MemoSignatureError> {
if s.len() != 128 {
return Err(MemoSignatureError("Invalid signature length".into()));
}
let nonce = RistrettoPublicKey::from_hex(&s[..64])
.map_err(|e| MemoSignatureError(format!("Signature contains an invalid public nonce. {e}")))?;
let sig = RistrettoSecretKey::from_hex(&s[64..])
.map_err(|e| MemoSignatureError(format!("Signature contains an invalid signature key. {e}")))?;
Ok(MemoSchnorr::new(nonce, sig))
}

#[cfg(test)]
mod test {
use log::info;

use super::*;

// These tests use this address
// ----------------------------- Tari Address -----------------------------
// Network: mainnet
// Secret key: 1dbbce83de2b0233c404b96b9234233bb3cec51503e2124d8c728a2d9b4fb00c
// Public key: a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e547
// Address: a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733
// Emoji ID: 👽🔥🍓🐗🎼😉🍊👘🍁🔮🐎👘👣👙🎮💨🍆🐑🏉🐬🎷👒🍪🚜💦🚌👽💼🐼🐬😍🎡🍰
// ------------------------------------------------------------------------

fn secret_key() -> RistrettoSecretKey {
RistrettoSecretKey::from_hex("1dbbce83de2b0233c404b96b9234233bb3cec51503e2124d8c728a2d9b4fb00c").unwrap()
}

#[test]
fn create_memo_signature() {
let address = "a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733"
.parse()
.expect("Failed to parse TariAddress");
let sig =
MemoSignature::create(address, "oid554432".into(), &secret_key()).expect("Failed to create memo signature");
println!("{}", sig.as_json());
let msg = signature_message(&sig.address, &sig.order_id);
assert_eq!(msg, "a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733:oid554432");
assert_eq!(
sig.address.as_address().to_hex(),
"a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733"
);
assert_eq!(sig.order_id, "oid554432");
assert!(sig.is_valid());
}

#[test]
fn verify_from_json() {
let json = r#"{
"address": "a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733",
"order_id": "oid554432",
"signature": "2421e3c98522d7c5518f55ddb39f759ee9051dde8060679d48f257994372fb214e9024917a5befacb132fc9979527ff92daa2c5d42062b8a507dc4e3b6954c05"
}"#;
let sig = serde_json::from_str::<MemoSignature>(json).expect("Failed to deserialize memo signature");
assert_eq!(
sig.address.as_address().to_hex(),
"a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733"
);
assert_eq!(sig.order_id, "oid554432");
assert!(sig.is_valid());
}
}
Loading

0 comments on commit 02e14cf

Please sign in to comment.