mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <wpfleger96@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
162 lines
5.3 KiB
Rust
162 lines
5.3 KiB
Rust
//! Webhook secret management helpers.
|
|
//!
|
|
//! Secrets are stored inside the workflow definition JSON under the key
|
|
//! `"_webhook_secret"`. This keeps the secret co-located with the definition
|
|
//! so that the `definition_hash` covers it — the hash **must** be computed
|
|
//! *after* calling `inject_secret`, otherwise the stored hash will never
|
|
//! match the stored definition.
|
|
//!
|
|
//! # Hash-ordering contract
|
|
//!
|
|
//! ```text
|
|
//! 1. Build / update the definition JSON.
|
|
//! 2. Call inject_secret(&mut def, &secret) ← secret is now part of def
|
|
//! 3. Compute definition_hash over def ← hash covers the secret
|
|
//! 4. Persist def + hash to the database
|
|
//! ```
|
|
//!
|
|
//! Reversing steps 2 and 3 (the previous bug) means the hash is computed over
|
|
//! a definition that does *not* yet contain `_webhook_secret`, so every
|
|
//! subsequent comparison fails.
|
|
|
|
/// Generate a new random webhook secret.
|
|
///
|
|
/// The secret is a UUID v4 rendered as a hyphenated string, which gives 122
|
|
/// bits of randomness — sufficient for an HMAC-style bearer token.
|
|
pub fn generate_webhook_secret() -> String {
|
|
uuid::Uuid::new_v4().to_string()
|
|
}
|
|
|
|
/// Inject `secret` into `def` under the key `"_webhook_secret"`.
|
|
///
|
|
/// If `def` is not a JSON object the call is a no-op (the definition is
|
|
/// malformed and will fail validation elsewhere).
|
|
pub fn inject_secret(def: &mut serde_json::Value, secret: &str) {
|
|
if let Some(map) = def.as_object_mut() {
|
|
map.insert(
|
|
"_webhook_secret".to_string(),
|
|
serde_json::Value::String(secret.to_string()),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Extract the webhook secret from `def`, if present.
|
|
///
|
|
/// Returns `None` when the key is absent or its value is not a string.
|
|
pub fn extract_secret(def: &serde_json::Value) -> Option<String> {
|
|
def.get("_webhook_secret")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
}
|
|
|
|
/// Return a copy of `def` with `"_webhook_secret"` removed.
|
|
///
|
|
/// Use this before returning a definition to API callers — the secret must
|
|
/// never be embedded in a response body (it is returned once, at creation
|
|
/// time, via a dedicated `webhook_secret` field).
|
|
pub fn strip_secret(def: &serde_json::Value) -> serde_json::Value {
|
|
match def.as_object() {
|
|
Some(map) => {
|
|
let filtered: serde_json::Map<String, serde_json::Value> = map
|
|
.iter()
|
|
.filter(|(k, _)| k.as_str() != "_webhook_secret")
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect();
|
|
serde_json::Value::Object(filtered)
|
|
}
|
|
None => def.clone(),
|
|
}
|
|
}
|
|
|
|
/// Compare `provided` against `stored` in constant time.
|
|
///
|
|
/// Returns `true` only when the two strings are identical. The XOR-fold
|
|
/// ensures that the comparison does not short-circuit on the first differing
|
|
/// byte, preventing timing-oracle attacks.
|
|
///
|
|
/// Note: a length mismatch is revealed immediately (not constant-time), but
|
|
/// an attacker who can observe response latency already knows the expected
|
|
/// length from the generation algorithm (UUID v4 → always 36 bytes), so
|
|
/// leaking the length check provides no additional information.
|
|
pub fn verify_secret(provided: &str, stored: &str) -> bool {
|
|
if provided.len() != stored.len() {
|
|
return false;
|
|
}
|
|
let mut result = 0u8;
|
|
for (a, b) in provided.bytes().zip(stored.bytes()) {
|
|
result |= a ^ b;
|
|
}
|
|
result == 0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn generate_is_nonempty() {
|
|
let s = generate_webhook_secret();
|
|
assert!(!s.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn generate_is_unique() {
|
|
let a = generate_webhook_secret();
|
|
let b = generate_webhook_secret();
|
|
assert_ne!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn inject_and_extract_roundtrip() {
|
|
let mut def = serde_json::json!({"name": "my-workflow"});
|
|
let secret = "test-secret-abc";
|
|
inject_secret(&mut def, secret);
|
|
assert_eq!(extract_secret(&def), Some(secret.to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn inject_noop_on_non_object() {
|
|
let mut def = serde_json::json!("not-an-object");
|
|
inject_secret(&mut def, "secret");
|
|
assert_eq!(extract_secret(&def), None);
|
|
}
|
|
|
|
#[test]
|
|
fn strip_removes_secret() {
|
|
let mut def = serde_json::json!({"name": "wf", "steps": []});
|
|
inject_secret(&mut def, "supersecret");
|
|
let stripped = strip_secret(&def);
|
|
assert!(stripped.get("_webhook_secret").is_none());
|
|
assert_eq!(stripped.get("name").and_then(|v| v.as_str()), Some("wf"));
|
|
}
|
|
|
|
#[test]
|
|
fn strip_preserves_other_fields() {
|
|
let def = serde_json::json!({"a": 1, "_webhook_secret": "s", "b": 2});
|
|
let stripped = strip_secret(&def);
|
|
assert!(stripped.get("_webhook_secret").is_none());
|
|
assert_eq!(stripped.get("a").and_then(|v| v.as_i64()), Some(1));
|
|
assert_eq!(stripped.get("b").and_then(|v| v.as_i64()), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_secret_matches() {
|
|
assert!(verify_secret("hello-world", "hello-world"));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_secret_rejects_wrong() {
|
|
assert!(!verify_secret("hello-world", "hello-WORLD"));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_secret_rejects_different_length() {
|
|
assert!(!verify_secret("short", "longer-string"));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_secret_empty_strings() {
|
|
assert!(verify_secret("", ""));
|
|
}
|
|
}
|