Skip to content

Commit 625deb9

Browse files
lovasoacursoragent
andauthored
Add and document sqlpage.hmac function (#1033)
* feat: Add sqlpage.hmac function for cryptographic signing Co-authored-by: contact <[email protected]> * feat: Add sqlpage.hmac function for secure data signing Co-authored-by: contact <[email protected]> * Test HMAC function with RFC vectors and update tests Co-authored-by: contact <[email protected]> * feat: Add sqlpage.hmac() function for secure signatures Co-authored-by: contact <[email protected]> * feat: Add base64 output option to hmac function Co-authored-by: contact <[email protected]> * Refactor hmac function for cleaner output formatting Co-authored-by: contact <[email protected]> * Add webhook HMAC signature validation tests Co-authored-by: contact <[email protected]> * Refactor HMAC function and update SQL examples for clarity and consistency - Changed function parameters to remove Option types for data and key in the HMAC function. - Improved SQL documentation and examples for HMAC usage, including clearer descriptions and updated error handling. - Enhanced test cases for webhook HMAC validation to ensure accurate signature checks and responses. - Removed obsolete test file for HMAC with null values. * Update HMAC validation logic to handle NULL values in SQL queries - Modified conditions in SQL queries to check for NULL values alongside signature mismatches. - Enhanced documentation on NULL handling for HMAC checks to improve clarity and portability. * remove debug logging from ci --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent b029661 commit 625deb9

File tree

14 files changed

+372
-1
lines changed

14 files changed

+372
-1
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ jobs:
8484
env:
8585
DATABASE_URL: ${{ matrix.db_url }}
8686
RUST_BACKTRACE: 1
87-
RUST_LOG: sqlpage=debug
8887

8988
windows_test:
9089
runs-on: windows-latest

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# CHANGELOG.md
22

3+
## v0.38.0
4+
- Added a new `sqlpage.hmac()` function for cryptographic HMAC (Hash-based Message Authentication Code) operations.
5+
- Create and verify secure signatures for webhooks (Shopify, Stripe, GitHub, etc.)
6+
- Generate tamper-proof tokens for API authentication
7+
- Secure download links and temporary access codes
8+
- Supports SHA-256 (default) and SHA-512 algorithms
9+
- Output formats: hexadecimal (default) or base64 (e.g., `sha256-base64`)
10+
- See the [function documentation](https://sql-page.com/functions.sql?function=hmac) for detailed examples
11+
312
## v0.37.1
413
- fixed decoding of UUID values
514
- Fixed handling of NULL values in `sqlpage.link`. They were encoded as the string `'null'` instead of being omitted from the link's parameters.

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ actix-web-httpauth = "0.8.0"
6161
rand = "0.9.0"
6262
actix-multipart = "0.7.2"
6363
base64 = "0.22"
64+
hmac = "0.12"
65+
sha2 = "0.10"
6466
rustls-acme = "0.14"
6567
dotenvy = "0.15.7"
6668
csv-async = { version = "1.2.6", features = ["tokio"] }
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
-- HMAC function documentation and examples
2+
INSERT INTO
3+
sqlpage_functions (
4+
"name",
5+
"introduced_in_version",
6+
"icon",
7+
"description_md"
8+
)
9+
VALUES
10+
(
11+
'hmac',
12+
'0.38.0',
13+
'shield-lock',
14+
'Creates a unique "signature" for some data using a secret key.
15+
This signature proves that the data hasn''t been tampered with and comes from someone who knows the secret.
16+
17+
### What is HMAC used for?
18+
19+
[**HMAC**](https://en.wikipedia.org/wiki/HMAC) (Hash-based Message Authentication Code) is commonly used to:
20+
- **Verify webhooks**: Use HMAC to ensure only a given external service can call a given endpoint in your application.
21+
The service signs their request with a secret key, and you verify the signature before processing the data they sent you.
22+
Used for instance by [Stripe](https://docs.stripe.com/webhooks?verify=verify-manually), and [Shopify](https://shopify.dev/docs/apps/build/webhooks/subscribe/https#step-2-validate-the-origin-of-your-webhook-to-ensure-its-coming-from-shopify).
23+
- **Secure API requests**: Prove that an API request comes from an authorized source
24+
- **Generate secure tokens**: Create temporary access codes for downloads or password resets
25+
- **Protect data**: Ensure data hasn''t been modified during transmission
26+
27+
### How to use it
28+
29+
The `sqlpage.hmac` function takes three inputs:
30+
1. **Your data** - The text you want to sign (like a message or request body)
31+
2. **Your secret key** - A password only you know (keep this safe!)
32+
3. **Algorithm** (optional) - The hash algorithm and output format:
33+
- `sha256` (default) - SHA-256 with hexadecimal output
34+
- `sha256-base64` - SHA-256 with base64 output
35+
- `sha512` - SHA-512 with hexadecimal output
36+
- `sha512-base64` - SHA-512 with base64 output
37+
38+
It returns a signature string. If someone changes even one letter in your data, the signature will be completely different.
39+
40+
### Example: Verify a Webhooks signature
41+
42+
When Shopify sends you a webhook (like when someone places an order), it includes a signature. Here''s how to verify it''s really from Shopify.
43+
This supposes you store the secret key in an [environment variable](https://en.wikipedia.org/wiki/Environment_variable) named `WEBHOOK_SECRET`.
44+
45+
```sql
46+
SET body = sqlpage.request_body();
47+
SET secret = sqlpage.environment_variable(''WEBHOOK_SECRET'');
48+
SET expected_signature = sqlpage.hmac($body, $secret, ''sha256'');
49+
SET actual_signature = sqlpage.header(''X-Webhook-Signature'');
50+
51+
-- redirect to an error page and stop execution if the signature does not match
52+
SELECT
53+
''redirect'' as component,
54+
''/error.sql?err=bad_webhook_signature'' as link
55+
WHERE $actual_signature != $expected_signature OR $actual_signature IS NULL;
56+
57+
-- If we reach here, the signature is valid - process the order
58+
INSERT INTO orders (order_data) VALUES ($body);
59+
60+
SELECT ''json'' as component, ''jsonlines'' as type;
61+
SELECT ''success'' as status;
62+
```
63+
64+
### Example: Time-limited links
65+
66+
You can create links that will be valid only for a limited time by including a signature in them.
67+
Let''s say we have a `download.sql` page we want to link to,
68+
but we don''t want it to be accessible to anyone who can find the link.
69+
Sign `file_id|expires_at` with a secret. Accept only if not expired and the signature matches.
70+
71+
#### Generate a signed link
72+
73+
```sql
74+
SET expires_at = datetime(''now'', ''+1 hour'');
75+
SET token = sqlpage.hmac(
76+
$file_id || ''|'' || $expires_at,
77+
sqlpage.environment_variable(''DOWNLOAD_SECRET''),
78+
''sha256''
79+
);
80+
SELECT ''/download.sql?file_id='' || $file_id || ''&expires_at='' || $expires_at || ''&token='' || $token AS link;
81+
```
82+
83+
#### Verify the signed link
84+
85+
```sql
86+
SET expected = sqlpage.hmac(
87+
$file_id || ''|'' || $expires_at,
88+
sqlpage.environment_variable(''DOWNLOAD_SECRET''),
89+
''sha256''
90+
);
91+
SELECT ''redirect'' AS component, ''/error.sql?err=expired'' AS link
92+
WHERE $expected != $token OR $token IS NULL OR $expires_at < datetime(''now'');
93+
94+
-- serve the file
95+
```
96+
97+
### Important Security Notes
98+
99+
- **Keep your secret key safe**: If your secret leaks, anyone can forge signatures and access protected pages
100+
- **The signature is case-sensitive**: Even a single wrong letter means the signature won''t match
101+
- **NULL handling**: Always use `IS DISTINCT FROM`, not `=` to check for hmac matches.
102+
- `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) != $signature` will not redirect if `$signature` is NULL (the signature is absent).
103+
- `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) IS DISTINCT FROM $signature` checks for both NULL and non-NULL values (but is not available in all SQL dialects).
104+
- `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) != $signature OR $signature IS NULL` is the most portable solution.
105+
'
106+
);
107+
108+
INSERT INTO
109+
sqlpage_function_parameters (
110+
"function",
111+
"index",
112+
"name",
113+
"description_md",
114+
"type"
115+
)
116+
VALUES
117+
(
118+
'hmac',
119+
1,
120+
'data',
121+
'The input data to compute the HMAC for. Can be any text string. Cannot be NULL.',
122+
'TEXT'
123+
),
124+
(
125+
'hmac',
126+
2,
127+
'key',
128+
'The secret key used to compute the HMAC. Should be kept confidential. Cannot be NULL.',
129+
'TEXT'
130+
),
131+
(
132+
'hmac',
133+
3,
134+
'algorithm',
135+
'The hash algorithm and output format. Optional, defaults to `sha256` (hex output). Supported values: `sha256`, `sha256-base64`, `sha512`, `sha512-base64`. Defaults to `sha256`.',
136+
'TEXT'
137+
);

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ super::function_definition_macro::sqlpage_functions! {
3232
hash_password(password: Option<String>);
3333
header((&RequestInfo), name: Cow<str>);
3434
headers((&RequestInfo));
35+
hmac(data: Cow<str>, key: Cow<str>, algorithm: Option<Cow<str>>);
3536

3637
user_info_token((&RequestInfo));
3738
link(file: Cow<str>, parameters: Option<Cow<str>>, hash: Option<Cow<str>>);
@@ -738,10 +739,83 @@ async fn headers(request: &RequestInfo) -> String {
738739
serde_json::to_string(&request.headers).unwrap_or_default()
739740
}
740741

742+
/// Computes the HMAC (Hash-based Message Authentication Code) of the input data
743+
/// using the specified key and hashing algorithm.
744+
async fn hmac<'a>(
745+
data: Cow<'a, str>,
746+
key: Cow<'a, str>,
747+
algorithm: Option<Cow<'a, str>>,
748+
) -> anyhow::Result<Option<String>> {
749+
use hmac::{Hmac, Mac};
750+
use sha2::{Sha256, Sha512};
751+
752+
let algorithm = algorithm.as_deref().unwrap_or("sha256");
753+
754+
// Parse algorithm and output format (e.g., "sha256" or "sha256-base64")
755+
let (hash_algo, output_format) = if let Some((algo, format)) = algorithm.split_once('-') {
756+
(algo, format)
757+
} else {
758+
(algorithm, "hex")
759+
};
760+
761+
let result = match hash_algo.to_lowercase().as_str() {
762+
"sha256" => {
763+
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
764+
.map_err(|e| anyhow!("Invalid HMAC key: {e}"))?;
765+
mac.update(data.as_bytes());
766+
mac.finalize().into_bytes().to_vec()
767+
}
768+
"sha512" => {
769+
let mut mac = Hmac::<Sha512>::new_from_slice(key.as_bytes())
770+
.map_err(|e| anyhow!("Invalid HMAC key: {e}"))?;
771+
mac.update(data.as_bytes());
772+
mac.finalize().into_bytes().to_vec()
773+
}
774+
_ => {
775+
anyhow::bail!(
776+
"Unsupported HMAC algorithm: {hash_algo}. Supported algorithms: sha256, sha512"
777+
)
778+
}
779+
};
780+
781+
// Convert to requested output format
782+
let output = match output_format.to_lowercase().as_str() {
783+
"hex" => result.into_iter().fold(String::new(), |mut acc, byte| {
784+
write!(&mut acc, "{byte:02x}").unwrap();
785+
acc
786+
}),
787+
"base64" => base64::Engine::encode(&base64::engine::general_purpose::STANDARD, result),
788+
_ => {
789+
anyhow::bail!(
790+
"Unsupported output format: {output_format}. Supported formats: hex, base64"
791+
)
792+
}
793+
};
794+
795+
Ok(Some(output))
796+
}
797+
741798
async fn client_ip(request: &RequestInfo) -> Option<String> {
742799
Some(request.client_ip?.to_string())
743800
}
744801

802+
#[tokio::test]
803+
async fn test_hmac() {
804+
// Test vector from RFC 4231 - HMAC-SHA256
805+
let result = hmac(
806+
Cow::Borrowed("The quick brown fox jumps over the lazy dog"),
807+
Cow::Borrowed("key"),
808+
Some(Cow::Borrowed("sha256")),
809+
)
810+
.await
811+
.unwrap()
812+
.unwrap();
813+
assert_eq!(
814+
result,
815+
"f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
816+
);
817+
}
818+
745819
/// Returns the ID token claims as a JSON object.
746820
async fn user_info_token(request: &RequestInfo) -> anyhow::Result<Option<String>> {
747821
let Some(claims) = &request.oidc_claims else {

tests/requests/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,5 @@ async fn test_large_form_field_roundtrip() -> actix_web::Result<()> {
119119
);
120120
Ok(())
121121
}
122+
123+
mod webhook_hmac;

tests/requests/webhook_hmac.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
use actix_web::{http::StatusCode, test};
2+
use sqlpage::webserver::http::main_handler;
3+
4+
use crate::common::get_request_to;
5+
6+
#[actix_web::test]
7+
async fn test_webhook_hmac_invalid_signature() -> actix_web::Result<()> {
8+
// Set up environment variable for webhook secret
9+
std::env::set_var("WEBHOOK_SECRET", "test-secret-key");
10+
11+
let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#;
12+
let invalid_signature = "96a5f6f65c85a2d4d1f3a37813ab2c0b44041bdc17691fbb0884e3eb52b7c54b";
13+
14+
let req = get_request_to("/tests/webhook_hmac_validation.sql")
15+
.await?
16+
.insert_header(("content-type", "application/json"))
17+
.insert_header(("X-Webhook-Signature", invalid_signature))
18+
.set_payload(webhook_body)
19+
.to_srv_request();
20+
21+
let resp = main_handler(req).await?;
22+
23+
// Should redirect to error page when signature is invalid
24+
assert!(
25+
resp.status() == StatusCode::FOUND || resp.status() == StatusCode::SEE_OTHER,
26+
"Expected redirect (302 or 303) for invalid signature, got: {}",
27+
resp.status()
28+
);
29+
30+
let location = resp
31+
.headers()
32+
.get("location")
33+
.expect("Should have Location header")
34+
.to_str()
35+
.unwrap();
36+
assert_eq!(location, "/error.sql?err=bad_webhook_signature");
37+
Ok(())
38+
}
39+
40+
#[actix_web::test]
41+
async fn test_webhook_hmac_valid_signature() -> actix_web::Result<()> {
42+
// Set up environment variable for webhook secret
43+
std::env::set_var("WEBHOOK_SECRET", "test-secret-key");
44+
45+
let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#;
46+
let valid_signature = "260b3b5ead84843645588af82d5d2c3fe24c598a950d36c45438c3a5f5bb941c";
47+
48+
let req = get_request_to("/tests/webhook_hmac_validation.sql")
49+
.await?
50+
.insert_header(("content-type", "application/json"))
51+
.insert_header(("X-Webhook-Signature", valid_signature))
52+
.set_payload(webhook_body)
53+
.to_srv_request();
54+
55+
let resp = main_handler(req).await?;
56+
57+
// Should return success when signature is valid
58+
assert_eq!(resp.status(), StatusCode::OK, "200 resp for signed req");
59+
assert!(!resp.headers().contains_key("location"), "no redirect");
60+
61+
assert_eq!(
62+
test::read_body_json::<serde_json::Value, _>(resp).await,
63+
serde_json::json! ({"msg": "Webhook signature is valid !"})
64+
);
65+
Ok(())
66+
}
67+
68+
#[actix_web::test]
69+
async fn test_webhook_hmac_missing_signature() -> actix_web::Result<()> {
70+
// Set up environment variable for webhook secret
71+
std::env::set_var("WEBHOOK_SECRET", "test-secret-key");
72+
73+
let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#;
74+
75+
// Don't include the X-Webhook-Signature header
76+
let req = get_request_to("/tests/webhook_hmac_validation.sql")
77+
.await?
78+
.insert_header(("content-type", "application/json"))
79+
.set_payload(webhook_body)
80+
.to_srv_request();
81+
82+
let resp = main_handler(req).await?;
83+
84+
let location = resp
85+
.headers()
86+
.get("location")
87+
.expect("Should have Location header")
88+
.to_str()
89+
.unwrap();
90+
assert_eq!(location, "/error.sql?err=bad_webhook_signature");
91+
92+
Ok(())
93+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Test HMAC with base64 output format
2+
-- Redirect if hash doesn't match expected value
3+
SELECT 'redirect' as component, '/error.sql' as link
4+
WHERE sqlpage.hmac('The quick brown fox jumps over the lazy dog', 'key', 'sha256-base64') != '97yD9DBThCSxMpjmqm+xQ+9NWaFJRhdZl0edvC0aPNg=';
5+
6+
SELECT 'text' as component, 'It works ! HMAC SHA-256 base64 output is correct' as contents;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Redirect if default algorithm doesn't match sha256
2+
SELECT 'redirect' as component, '/error.sql' as link
3+
WHERE sqlpage.hmac('test data', 'test key') != sqlpage.hmac('test data', 'test key', 'sha256');
4+
5+
SELECT 'text' as component, 'It works ! HMAC default algorithm is SHA-256' as contents;

0 commit comments

Comments
 (0)