mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
* feat: receive relay events via webhook callbacks instead of SSE Replace the SSE pull model with push-based webhook callbacks from channel-relay. Eliminates the reconnect loop, stream token auth, and SSE parser — events arrive via HTTP POST to /relay/events. - Add webhook handler with HMAC signature verification - Simplify RelayChannel to use mpsc from webhook handler - Remove SSE connect/reconnect/parse logic from RelayClient - Add register_callback() to RelayClient for callback URL registration - Update activation flow to create event channel and register callback - Wire relay webhook endpoint into web gateway * fix: address review feedback on webhook callback PR - Return 503 when relay event channel is full/closed (enables retry) - Reject malformed timestamps with 400 instead of proceeding - Allow relay activation without settings store (no-store/ephemeral mode) - Check installed_relay_extensions set in is_relay_channel for no-db mode - Fix staging test constructors for new RelayChannel signature * security: adapt relay client to new channel-relay auth model Adapts the relay integration to the hardened channel-relay security model: - Switch from X-API-Key header to Authorization: Bearer sk-agent-* for all relay API calls (chat-api token verification) - Remove register_callback() — PUT /callbacks endpoint removed - Remove event_callback_url from initiate_oauth() — parameter removed - Make signing_secret a required field in RelayConfig (new env var: CHANNEL_RELAY_SIGNING_SECRET) - Update integration tests for Bearer auth and removed endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: use server-side approval tokens, remove caller-supplied routing - Approval flow now calls POST /approvals to register server-side record, then embeds only the opaque approval_token in button value - Remove instance_id parameter from proxy_provider() — channel-relay no longer accepts it (uses verified identity) - Remove instance_id and user_id from initiate_oauth() — channel-relay derives them from the Bearer token - Add create_approval() to RelayClient Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: pass webhook_url during OAuth so callback_url is set on connection The channel-relay OAuth flow now accepts webhook_url to set the callback_url during connection creation. IronClaw computes its webhook URL from callback_base + webhook_path and passes it during initiate_oauth. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove webhook_url from OAuth initiation Channel-relay now derives the callback URL from chat-api's instance_url. IronClaw no longer supplies webhook_url during OAuth — the relay is the authority on where events get delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove all URL params from OAuth initiation IronClaw no longer supplies any URLs to channel-relay. The relay derives all URLs from the trusted instance_url in chat-api. initiate_oauth() takes no parameters. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: restore CSRF nonce for OAuth callback validation Re-add nonce generation and secret storage in auth_channel_relay. The nonce is passed to channel-relay as state_nonce param (not a URL). Channel-relay embeds it in the signed state and appends it to the redirect URL so IronClaw's callback handler can validate and activate. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: per-instance callback signing secrets relay_signing_secret() now prefers OPENCLAW_GATEWAY_TOKEN (per-instance) over the shared CHANNEL_RELAY_SIGNING_SECRET. A compromised instance can no longer forge callbacks to other instances on the same relay. CHANNEL_RELAY_SIGNING_SECRET is now optional in RelayConfig. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: clean per-instance callback secrets, no shared secrets, no fallbacks Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: pass team_id to get_signing_secret for workspace-scoped lookup Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove sender_id from create_approval — relay derives it Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: remove stale relay sender_id validation * fix: harden relay webhook activation lifecycle --------- Co-authored-by: Pierre <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
67 lines
1.9 KiB
Rust
67 lines
1.9 KiB
Rust
//! Shared relay webhook signature verification helpers.
|
|
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
/// Verify a relay callback HMAC signature.
|
|
pub fn verify_relay_signature(
|
|
secret: &[u8],
|
|
timestamp: &str,
|
|
body: &[u8],
|
|
signature: &str,
|
|
) -> bool {
|
|
verify_signature(secret, timestamp, body, signature)
|
|
}
|
|
|
|
fn verify_signature(secret: &[u8], timestamp: &str, body: &[u8], signature: &str) -> bool {
|
|
let mut mac = match HmacSha256::new_from_slice(secret) {
|
|
Ok(m) => m,
|
|
Err(_) => return false,
|
|
};
|
|
mac.update(timestamp.as_bytes());
|
|
mac.update(b".");
|
|
mac.update(body);
|
|
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
|
subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), signature.as_bytes()).into()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_signature(secret: &[u8], timestamp: &str, body: &[u8]) -> String {
|
|
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
|
mac.update(timestamp.as_bytes());
|
|
mac.update(b".");
|
|
mac.update(body);
|
|
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
|
|
}
|
|
|
|
#[test]
|
|
fn verify_valid_signature() {
|
|
let secret = b"test-secret";
|
|
let body = b"hello";
|
|
let ts = "1234567890";
|
|
let sig = make_signature(secret, ts, body);
|
|
assert!(verify_signature(secret, ts, body, &sig));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_wrong_secret_fails() {
|
|
let body = b"hello";
|
|
let ts = "1234567890";
|
|
let sig = make_signature(b"correct", ts, body);
|
|
assert!(!verify_signature(b"wrong", ts, body, &sig));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_tampered_body_fails() {
|
|
let secret = b"secret";
|
|
let ts = "1234567890";
|
|
let sig = make_signature(secret, ts, b"original");
|
|
assert!(!verify_signature(secret, ts, b"tampered", &sig));
|
|
}
|
|
}
|