Skip to content

Commit 02e14cf

Browse files
committed
feat: memo signature
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" } ```
1 parent 69b9f6a commit 02e14cf

17 files changed

+660
-344
lines changed

Diff for: Cargo.lock

+351-287
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: e2e/tests/cucumber/steps.rs

+31-9
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ use tari_jwt::{
1010
Ristretto256SigningKey,
1111
};
1212
use tari_payment_engine::db_types::{MicroTari, OrderId, Role};
13-
use tari_payment_server::auth::{build_jwt_signer, JwtClaims};
13+
use tari_payment_server::{
14+
auth::{build_jwt_signer, JwtClaims},
15+
shopify_order::ShopifyOrder,
16+
};
1417
use tokio::time::sleep;
1518

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

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

167-
#[when(expr = "{word} places an order \"{word}\" for {int} XTR, memo = {string} at {string}")]
170+
#[when(expr = "Customer #{int} [{string}] places order \"{word}\" for {int} XTR at {string}, with memo")]
168171
async fn place_order(
169172
world: &mut TPGWorld,
170-
customer_id: String,
173+
user: i64,
174+
email: String,
171175
order_id: String,
172176
amount: i64,
173-
memo: String,
174-
address: String,
177+
created_at: String,
178+
step: &Step,
175179
) {
176-
let order_id = OrderId(order_id);
180+
let memo = step.docstring().map(String::from);
181+
world.response = None;
182+
let res = world
183+
.request(Method::POST, "/shopify/webhook/checkout_create", |req| {
184+
let mut order = ShopifyOrder::default();
185+
order.created_at = created_at;
186+
order.name = order_id;
187+
order.note = memo;
188+
order.currency = "XTR".to_string();
189+
order.total_price = MicroTari::from(amount).value().to_string();
190+
order.user_id = Some(user);
191+
order.email = email;
192+
let order = serde_json::to_string(&order).expect("Failed to serialize order");
193+
req.body(order).header("Content-Type", "application/json")
194+
})
195+
.await;
196+
trace!("Got Response: {} {}", res.0, res.1);
197+
world.response = Some(res);
177198
}
199+
178200
fn modify_signature(token: String, value: &str) -> String {
179201
let mut parts = token.split('.').map(|s| s.to_owned()).collect::<Vec<_>>();
180202
let n = value.len();

Diff for: e2e/tests/features/payment_flow.feature

+11-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ Feature: Order flow
33
Given a blank slate
44

55
Scenario: Standard order flow
6-
When Alice places an order "alice001" on the store. Memo "": "Item A" for 100T, "Item B" for 200T
7-
Then Alice's account has a balance of 300 Tari
8-
Then Alice's order "alice001" is pending
9-
When Alice's sends a payment of 300 Tari
6+
When Customer #1 ["alice"] places order "alice001" for 2500 XTR, with memo
7+
"""
8+
{ "address": "b8971598a865b25b6508d4ba154db228e044f367bd9a1ef50dd4051db42b63143d",
9+
"order_id": "alice001",
10+
"signature": "deadbeef34534534534534543435345"
11+
}
12+
"""
13+
Then Customer #1 has a balance of 2500 Tari
14+
Then order "alice001" is in state pending
15+
When Alice sends a payment of 2525 Tari
1016
Then order "alice001" is fulfilled
17+
And Alice has a balance of 25 Tari
1118

1219

File renamed without changes.
File renamed without changes.
File renamed without changes.

Diff for: rust-toolchain.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[toolchain]
2-
channel = "nightly-2023-11-15"
2+
channel = "nightly-2024-02-20"

Diff for: tari_payment_engine/src/db_types.rs

+8
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ impl AsRef<TariAddress> for SerializedTariAddress {
125125
}
126126
}
127127

128+
impl FromStr for SerializedTariAddress {
129+
type Err = TariAddressError;
130+
131+
fn from_str(s: &str) -> Result<Self, Self::Err> {
132+
s.parse::<TariAddress>().map(Self)
133+
}
134+
}
135+
128136
impl TryFrom<String> for SerializedTariAddress {
129137
type Error = TariAddressError;
130138

Diff for: tari_payment_server/examples/memo_signature.rs

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use tari_common_types::tari_address::TariAddress;
2+
use tari_jwt::tari_crypto::{ristretto::RistrettoSecretKey, tari_utilities::hex::Hex};
3+
use tari_payment_server::memo_signature::MemoSignature;
4+
5+
fn main() {
6+
let mut args = std::env::args();
7+
args.next(); // executable name
8+
let Some(address) = args.next().and_then(|s| s.parse::<TariAddress>().ok()) else {
9+
println!("Address is required");
10+
return;
11+
};
12+
let Some(order_id) = args.next() else {
13+
println!("Order ID is required");
14+
return;
15+
};
16+
let Some(secret_key) = args.next().and_then(|k| RistrettoSecretKey::from_hex(&k).ok()) else {
17+
println!("Secret key is required");
18+
return;
19+
};
20+
21+
match MemoSignature::create(address, order_id, &secret_key) {
22+
Ok(signature) => {
23+
println!("Memo signature: {}", signature.as_json());
24+
},
25+
Err(e) => eprintln!("Invalid input. {e}"),
26+
}
27+
}

Diff for: tari_payment_server/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ pub mod errors;
5555
pub mod helpers;
5656

5757
pub mod middleware;
58+
59+
pub mod memo_signature;
5860
pub mod routes;
5961
pub mod server;
6062

Diff for: tari_payment_server/src/memo_signature.rs

+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//! # Order memo signature format
2+
//!
3+
//! When providing a Tari address in a Shopify order, we cannot let users provide just any wallet address there,
4+
//! because this would let folks put other wallet addresses in there and hope that one day someone makes a payment
5+
//! from that wallet and their order will then be fulfilled.
6+
//!
7+
//! Users need to _prove_ that they own the wallet address they provide in the order. This is done by signing a message
8+
//! with the wallet's private key. The message is constructed from the wallet address and the order ID (preventing
9+
//! naughty people from using the same signature for their own orders, and again, trying to get free stuff).
10+
//!
11+
//! The signature is then stored in the order memo field, and the payment server can verify the signature by checking
12+
//! the wallet's public key against the signature.
13+
//!
14+
//! ## Message format
15+
//!
16+
//! The message is constructed by concatenating the wallet address and the order ID, separated by a colon.
17+
//! The challenge is a domain-separated Schnorr signature. The full format is:
18+
//!
19+
//! ```text
20+
//! {aaaaaaaa}MemoSignature.v1.challenge{bbbbbbbb}{address}:{order_id}
21+
//! ```
22+
//!
23+
//! where
24+
//! * `aaaaaaaa` is the length of `MemoSignature.v1.challenge`, i.e. 25 in little-endian format.
25+
//! * `bbbbbbbb` is the length of `address`(64) + `:`(1) + `order_id.len()` in little-endian format.
26+
//! * `address` is the Tari address of the wallet owner, in hexadecimal
27+
//! * `order_id` is the order ID, a string
28+
//!
29+
//! The message is then hashed with `Blake2b<U64>` to get the challenge.
30+
31+
use serde::{Deserialize, Serialize};
32+
use tari_common_types::tari_address::TariAddress;
33+
use tari_jwt::tari_crypto::{
34+
hash_domain,
35+
ristretto::{RistrettoPublicKey, RistrettoSchnorrWithDomain, RistrettoSecretKey},
36+
signatures::SchnorrSignatureError,
37+
tari_utilities::{hex::Hex, message_format::MessageFormat},
38+
};
39+
use tari_payment_engine::db_types::SerializedTariAddress;
40+
use thiserror::Error;
41+
42+
hash_domain!(MemoSignatureDomain, "MemoSignature");
43+
44+
pub type MemoSchnorr = RistrettoSchnorrWithDomain<MemoSignatureDomain>;
45+
46+
#[derive(Debug, Clone, Error)]
47+
#[error("Invalid memo signature: {0}")]
48+
pub struct MemoSignatureError(String);
49+
50+
#[derive(Debug, Clone, Serialize, Deserialize)]
51+
pub struct MemoSignature {
52+
pub address: SerializedTariAddress,
53+
pub order_id: String,
54+
#[serde(serialize_with = "ser_sig", deserialize_with = "de_sig")]
55+
pub signature: MemoSchnorr,
56+
}
57+
58+
impl MemoSignature {
59+
pub fn create(
60+
address: TariAddress,
61+
order_id: String,
62+
secret_key: &RistrettoSecretKey,
63+
) -> Result<Self, MemoSignatureError> {
64+
let address = SerializedTariAddress::from(address);
65+
let message = signature_message(&address, &order_id);
66+
let signature = sign_message(&message, secret_key).map_err(|e| MemoSignatureError(e.to_string()))?;
67+
Ok(Self { address, order_id, signature })
68+
}
69+
70+
pub fn new(address: &str, order_id: &str, signature: &str) -> Result<Self, MemoSignatureError> {
71+
let address = address.parse::<SerializedTariAddress>().map_err(|e| MemoSignatureError(e.to_string()))?;
72+
let signature = hex_to_memo_schnorr(signature).map_err(|e| MemoSignatureError(e.to_string()))?;
73+
let order_id = order_id.to_string();
74+
Ok(Self { address, order_id, signature })
75+
}
76+
77+
pub fn message(&self) -> String {
78+
signature_message(&self.address, &self.order_id)
79+
}
80+
81+
pub fn is_valid(&self) -> bool {
82+
let message = self.message();
83+
let pubkey = self.address.as_address().public_key();
84+
println!(
85+
"Verifying. pubkey: {:x}. nonce: {:x}, sig:{}",
86+
pubkey,
87+
self.signature.get_public_nonce(),
88+
self.signature.get_signature().reveal().to_string()
89+
);
90+
self.signature.verify(pubkey, message)
91+
}
92+
93+
pub fn as_json(&self) -> String {
94+
serde_json::to_string(self).unwrap()
95+
}
96+
}
97+
98+
pub fn signature_message(address: &SerializedTariAddress, order_id: &str) -> String {
99+
let addr = address.as_address().to_hex();
100+
format!("{addr}:{order_id}")
101+
}
102+
103+
pub fn sign_message(message: &str, secret_key: &RistrettoSecretKey) -> Result<MemoSchnorr, SchnorrSignatureError> {
104+
let mut rng = rand::thread_rng();
105+
MemoSchnorr::sign(secret_key, message.as_bytes(), &mut rng)
106+
}
107+
108+
pub fn ser_sig<S>(sig: &MemoSchnorr, s: S) -> Result<S::Ok, S::Error>
109+
where S: serde::Serializer {
110+
let nonce = sig.get_public_nonce().to_hex();
111+
let sig = sig.get_signature().to_hex();
112+
s.serialize_str(&format!("{nonce}{sig}"))
113+
}
114+
115+
pub fn de_sig<'de, D>(d: D) -> Result<MemoSchnorr, D::Error>
116+
where D: serde::Deserializer<'de> {
117+
let s = String::deserialize(d)?;
118+
hex_to_memo_schnorr(&s).map_err(serde::de::Error::custom)
119+
}
120+
121+
pub fn hex_to_memo_schnorr(s: &str) -> Result<MemoSchnorr, MemoSignatureError> {
122+
if s.len() != 128 {
123+
return Err(MemoSignatureError("Invalid signature length".into()));
124+
}
125+
let nonce = RistrettoPublicKey::from_hex(&s[..64])
126+
.map_err(|e| MemoSignatureError(format!("Signature contains an invalid public nonce. {e}")))?;
127+
let sig = RistrettoSecretKey::from_hex(&s[64..])
128+
.map_err(|e| MemoSignatureError(format!("Signature contains an invalid signature key. {e}")))?;
129+
Ok(MemoSchnorr::new(nonce, sig))
130+
}
131+
132+
#[cfg(test)]
133+
mod test {
134+
use log::info;
135+
136+
use super::*;
137+
138+
// These tests use this address
139+
// ----------------------------- Tari Address -----------------------------
140+
// Network: mainnet
141+
// Secret key: 1dbbce83de2b0233c404b96b9234233bb3cec51503e2124d8c728a2d9b4fb00c
142+
// Public key: a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e547
143+
// Address: a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733
144+
// Emoji ID: 👽🔥🍓🐗🎼😉🍊👘🍁🔮🐎👘👣👙🎮💨🍆🐑🏉🐬🎷👒🍪🚜💦🚌👽💼🐼🐬😍🎡🍰
145+
// ------------------------------------------------------------------------
146+
147+
fn secret_key() -> RistrettoSecretKey {
148+
RistrettoSecretKey::from_hex("1dbbce83de2b0233c404b96b9234233bb3cec51503e2124d8c728a2d9b4fb00c").unwrap()
149+
}
150+
151+
#[test]
152+
fn create_memo_signature() {
153+
let address = "a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733"
154+
.parse()
155+
.expect("Failed to parse TariAddress");
156+
let sig =
157+
MemoSignature::create(address, "oid554432".into(), &secret_key()).expect("Failed to create memo signature");
158+
println!("{}", sig.as_json());
159+
let msg = signature_message(&sig.address, &sig.order_id);
160+
assert_eq!(msg, "a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733:oid554432");
161+
assert_eq!(
162+
sig.address.as_address().to_hex(),
163+
"a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733"
164+
);
165+
assert_eq!(sig.order_id, "oid554432");
166+
assert!(sig.is_valid());
167+
}
168+
169+
#[test]
170+
fn verify_from_json() {
171+
let json = r#"{
172+
"address": "a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733",
173+
"order_id": "oid554432",
174+
"signature": "2421e3c98522d7c5518f55ddb39f759ee9051dde8060679d48f257994372fb214e9024917a5befacb132fc9979527ff92daa2c5d42062b8a507dc4e3b6954c05"
175+
}"#;
176+
let sig = serde_json::from_str::<MemoSignature>(json).expect("Failed to deserialize memo signature");
177+
assert_eq!(
178+
sig.address.as_address().to_hex(),
179+
"a8d523755de41b9c14de709ca59d52bc1772658258962ef5bbefa8c59082e54733"
180+
);
181+
assert_eq!(sig.order_id, "oid554432");
182+
assert!(sig.is_valid());
183+
}
184+
}

0 commit comments

Comments
 (0)