v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
//! Minimal BTCPay Greenfield API client — only the endpoints this service
|
||||
//! actually calls. Add more as needs grow.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BtcpayClient {
|
||||
http: Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
store_id: String,
|
||||
}
|
||||
|
||||
/// Response subset from `POST /api/v1/stores/{storeId}/invoices`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatedInvoice {
|
||||
pub id: String,
|
||||
#[serde(rename = "checkoutLink")]
|
||||
pub checkout_link: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Fields we include when creating an invoice. BTCPay accepts many more; we
|
||||
/// only send what we need.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateInvoiceRequest<'a> {
|
||||
amount: String,
|
||||
currency: &'a str,
|
||||
metadata: serde_json::Value,
|
||||
checkout: CheckoutOptions<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CheckoutOptions<'a> {
|
||||
#[serde(rename = "redirectURL")]
|
||||
redirect_url: Option<&'a str>,
|
||||
#[serde(rename = "redirectAutomatically")]
|
||||
redirect_automatically: bool,
|
||||
}
|
||||
|
||||
impl BtcpayClient {
|
||||
pub fn new(base_url: &str, api_key: &str, store_id: &str) -> Self {
|
||||
Self {
|
||||
http: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("reqwest client"),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
store_id: store_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an invoice priced in satoshis. BTCPay accepts "BTC" currency
|
||||
/// with decimal amounts; we convert sats → BTC here.
|
||||
pub async fn create_invoice(
|
||||
&self,
|
||||
amount_sats: i64,
|
||||
metadata: serde_json::Value,
|
||||
redirect_url: Option<&str>,
|
||||
) -> Result<CreatedInvoice> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{}/invoices",
|
||||
self.base_url, self.store_id
|
||||
);
|
||||
let amount_btc = format!("{:.8}", amount_sats as f64 / 100_000_000.0);
|
||||
|
||||
let body = CreateInvoiceRequest {
|
||||
amount: amount_btc,
|
||||
currency: "BTC",
|
||||
metadata,
|
||||
checkout: CheckoutOptions {
|
||||
redirect_url,
|
||||
redirect_automatically: true,
|
||||
},
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay create-invoice")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay create-invoice returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
|
||||
let invoice: CreatedInvoice = resp
|
||||
.json()
|
||||
.await
|
||||
.context("parsing BTCPay create-invoice response")?;
|
||||
Ok(invoice)
|
||||
}
|
||||
|
||||
/// Pay a BOLT11 Lightning invoice from the operator's BTCPay node.
|
||||
/// Used by the tip-recipient flow. Returns the BTCPay payment record so
|
||||
/// the caller can extract the payment hash and surface it in the audit
|
||||
/// log. Errors if the store has no internal LN node or the node refuses
|
||||
/// the payment (insufficient liquidity, invoice already paid, etc.).
|
||||
///
|
||||
/// BTCPay endpoint:
|
||||
/// POST /api/v1/stores/{storeId}/lightning/BTC/invoices/pay
|
||||
/// { "BOLT11": "<bolt11>" }
|
||||
///
|
||||
/// The BTC path-component is the cryptoCode; on BTCPay-Server it's
|
||||
/// always "BTC" for the Bitcoin Lightning network.
|
||||
pub async fn pay_lightning_invoice(&self, bolt11: &str) -> Result<serde_json::Value> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{}/lightning/BTC/invoices/pay",
|
||||
self.base_url, self.store_id
|
||||
);
|
||||
let body = json!({ "BOLT11": bolt11 });
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay pay-lightning-invoice")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay pay-lightning-invoice returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
|
||||
let payment: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("parsing BTCPay pay-lightning-invoice response")?;
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
/// Fetch invoice state for reconciliation on startup / admin queries.
|
||||
/// Not used in the hot path; webhooks are the source of truth.
|
||||
pub async fn get_invoice(&self, invoice_id: &str) -> Result<serde_json::Value> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{}/invoices/{}",
|
||||
self.base_url, self.store_id, invoice_id
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {}", self.api_key))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay get-invoice returned {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn store_id(&self) -> &str {
|
||||
&self.store_id
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
pub fn api_key(&self) -> &str {
|
||||
&self.api_key
|
||||
}
|
||||
|
||||
// Helper to quickly construct sample metadata for invoice correlation.
|
||||
pub fn invoice_metadata(product_id: &str, internal_invoice_id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"orderId": internal_invoice_id,
|
||||
"productId": product_id,
|
||||
"source": "keysat",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Standalone helpers for the authorize / bootstrap flow. These operate
|
||||
/// *before* a full `BtcpayClient` exists, since we don't yet know which
|
||||
/// store the API key is scoped to.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StoreSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// List the stores the given API key has access to.
|
||||
pub async fn list_stores(base_url: &str, api_key: &str) -> Result<Vec<StoreSummary>> {
|
||||
let url = format!("{}/api/v1/stores", base_url.trim_end_matches('/'));
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay list-stores")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay list-stores returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
Ok(resp.json::<Vec<StoreSummary>>().await?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatedWebhook {
|
||||
pub id: String,
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Register a webhook on the given store pointing at `callback_url` and
|
||||
/// subscribing to the three invoice lifecycle events we care about.
|
||||
pub async fn create_webhook(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
store_id: &str,
|
||||
callback_url: &str,
|
||||
secret: &str,
|
||||
) -> Result<CreatedWebhook> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{store_id}/webhooks",
|
||||
base_url.trim_end_matches('/')
|
||||
);
|
||||
let body = json!({
|
||||
"url": callback_url,
|
||||
"enabled": true,
|
||||
"automaticRedelivery": true,
|
||||
"secret": secret,
|
||||
"authorizedEvents": {
|
||||
"everything": false,
|
||||
"specificEvents": [
|
||||
"InvoiceSettled",
|
||||
"InvoiceExpired",
|
||||
"InvoiceInvalid",
|
||||
],
|
||||
},
|
||||
});
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay create-webhook")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay create-webhook returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
Ok(resp.json::<CreatedWebhook>().await?)
|
||||
}
|
||||
|
||||
/// Delete a webhook on the given store. Used by the Disconnect flow so
|
||||
/// that re-authorizing later doesn't leave behind a duplicate webhook
|
||||
/// pointing at this Keysat install.
|
||||
pub async fn delete_webhook(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
store_id: &str,
|
||||
webhook_id: &str,
|
||||
) -> Result<()> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{store_id}/webhooks/{webhook_id}",
|
||||
base_url.trim_end_matches('/')
|
||||
);
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay delete-webhook")?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay delete-webhook returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
// 404 is treated as success — the webhook is already gone.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke a BTCPay API key. Best-effort — failures are logged by the
|
||||
/// caller but don't block the local Disconnect from completing.
|
||||
pub async fn revoke_api_key(base_url: &str, api_key: &str) -> Result<()> {
|
||||
let url = format!("{}/api/v1/api-keys/current", base_url.trim_end_matches('/'));
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay revoke-api-key")?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay revoke-api-key returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List the payment methods configured on a store. Used by the
|
||||
/// post-connect "missing wallet" detection. Returns the raw JSON array
|
||||
/// because the per-method shape varies (onchain vs LN, BTC vs altcoins).
|
||||
/// Empty array → no payment methods configured.
|
||||
pub async fn list_payment_methods(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
store_id: &str,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{store_id}/payment-methods",
|
||||
base_url.trim_end_matches('/')
|
||||
);
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay list-payment-methods")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"BTCPay list-payment-methods returned {status}: {text}"
|
||||
));
|
||||
}
|
||||
let raw: serde_json::Value = resp.json().await?;
|
||||
Ok(raw
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Persistent BTCPay connection state.
|
||||
//!
|
||||
//! Runtime credentials (API key, store, webhook secret) live in the DB so that
|
||||
//! the operator can reconfigure BTCPay from the StartOS dashboard without
|
||||
//! editing env vars or restarting the container.
|
||||
//!
|
||||
//! Written on first connect (via the authorize flow) and on explicit
|
||||
//! reconnects. Read at startup to construct the `BtcpayClient`.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BtcpayConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub store_id: String,
|
||||
pub webhook_id: Option<String>,
|
||||
pub webhook_secret: String,
|
||||
}
|
||||
|
||||
/// Load the current BTCPay config. Returns `None` if the operator has not
|
||||
/// completed the authorize flow yet.
|
||||
pub async fn load(pool: &SqlitePool) -> Result<Option<BtcpayConfig>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT base_url, api_key, store_id, webhook_id, webhook_secret \
|
||||
FROM btcpay_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.context("loading btcpay_config")?;
|
||||
|
||||
Ok(row.map(|r| BtcpayConfig {
|
||||
base_url: r.get("base_url"),
|
||||
api_key: r.get("api_key"),
|
||||
store_id: r.get("store_id"),
|
||||
webhook_id: r.get("webhook_id"),
|
||||
webhook_secret: r.get("webhook_secret"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete the entire BTCPay config row. Used by the Disconnect flow.
|
||||
/// Subsequent calls to `load` return `None` until the operator
|
||||
/// re-authorizes.
|
||||
pub async fn clear(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query("DELETE FROM btcpay_config WHERE id = 1")
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("clearing btcpay_config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upsert the full config. Called by the authorize-callback path after the
|
||||
/// service has fetched/created everything it needs from BTCPay.
|
||||
pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO btcpay_config \
|
||||
(id, base_url, api_key, store_id, webhook_id, webhook_secret, connected_at) \
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(id) DO UPDATE SET \
|
||||
base_url = excluded.base_url, \
|
||||
api_key = excluded.api_key, \
|
||||
store_id = excluded.store_id, \
|
||||
webhook_id = excluded.webhook_id, \
|
||||
webhook_secret = excluded.webhook_secret, \
|
||||
connected_at = excluded.connected_at",
|
||||
)
|
||||
.bind(&cfg.base_url)
|
||||
.bind(&cfg.api_key)
|
||||
.bind(&cfg.store_id)
|
||||
.bind(cfg.webhook_id.as_deref())
|
||||
.bind(&cfg.webhook_secret)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("saving btcpay_config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a new in-flight authorize state token. The caller has already
|
||||
/// generated a cryptographically-random token.
|
||||
pub async fn record_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO btcpay_authorize_state (state_token, created_at) VALUES (?, ?)",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("recording btcpay authorize state")?;
|
||||
// Best-effort prune of rows older than 30 minutes.
|
||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||
let _ = sqlx::query("DELETE FROM btcpay_authorize_state WHERE created_at < ?")
|
||||
.bind(&cutoff)
|
||||
.execute(pool)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that `token` was issued recently and has not been consumed.
|
||||
/// Consumes (deletes) the token on success so a replay fails.
|
||||
pub async fn consume_authorize_state(pool: &SqlitePool, token: &str) -> Result<()> {
|
||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||
let row = sqlx::query(
|
||||
"SELECT state_token FROM btcpay_authorize_state \
|
||||
WHERE state_token = ? AND created_at >= ?",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(&cutoff)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if row.is_none() {
|
||||
return Err(anyhow!("unknown or expired authorize state token"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
||||
.bind(token)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! BTCPay Server integration.
|
||||
//!
|
||||
//! - [`client`] creates invoices via the BTCPay Greenfield API.
|
||||
//! - [`webhook`] verifies and parses incoming webhook calls from BTCPay.
|
||||
//!
|
||||
//! BTCPay's Greenfield API is documented at
|
||||
//! <https://docs.btcpayserver.org/API/Greenfield/v1/>.
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod webhook;
|
||||
@@ -0,0 +1,93 @@
|
||||
//! BTCPay webhook handling.
|
||||
//!
|
||||
//! BTCPay signs each webhook body with HMAC-SHA256 using the shared secret
|
||||
//! we configured, and sends the hex digest in the `BTCPay-Sig` header as
|
||||
//! `sha256=<hex>`. We verify in constant time before trusting anything.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Verify the `BTCPay-Sig` header matches the raw request body.
|
||||
///
|
||||
/// Returns `Ok(())` on success, `Err` on any mismatch. Callers must pass the
|
||||
/// raw, unmodified body — any reserialization will break the HMAC.
|
||||
pub fn verify_signature(secret: &str, header_value: &str, raw_body: &[u8]) -> Result<()> {
|
||||
let expected_hex = header_value
|
||||
.strip_prefix("sha256=")
|
||||
.ok_or_else(|| anyhow!("BTCPay-Sig header missing 'sha256=' prefix"))?;
|
||||
let expected =
|
||||
hex::decode(expected_hex).map_err(|_| anyhow!("BTCPay-Sig header is not hex"))?;
|
||||
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC takes any key size");
|
||||
mac.update(raw_body);
|
||||
let computed = mac.finalize().into_bytes();
|
||||
|
||||
if bool::from(computed.as_slice().ct_eq(&expected)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("BTCPay webhook signature mismatch"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The subset of webhook payload fields we care about. BTCPay sends many
|
||||
/// event types; we key off `invoiceId` and `type` / `status`.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct WebhookEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
#[serde(rename = "invoiceId")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default)]
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
impl WebhookEvent {
|
||||
/// BTCPay fires event types like `InvoiceSettled`, `InvoiceExpired`,
|
||||
/// `InvoiceInvalid`, `InvoiceProcessing`. We normalize to our internal
|
||||
/// status vocabulary.
|
||||
pub fn to_status(&self) -> Option<&'static str> {
|
||||
match self.event_type.as_str() {
|
||||
"InvoiceSettled" | "InvoicePaymentSettled" => Some("settled"),
|
||||
"InvoiceExpired" => Some("expired"),
|
||||
"InvoiceInvalid" => Some("invalid"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verifies_correct_signature() {
|
||||
let secret = "super-secret";
|
||||
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
let header = format!("sha256={sig}");
|
||||
|
||||
assert!(verify_signature(secret, &header, body).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_body() {
|
||||
let secret = "super-secret";
|
||||
let body = br#"{"type":"InvoiceSettled","invoiceId":"abc"}"#;
|
||||
let tampered = br#"{"type":"InvoiceSettled","invoiceId":"evil"}"#;
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
let header = format!("sha256={sig}");
|
||||
|
||||
assert!(verify_signature(secret, &header, tampered).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user