Webhook Handling & Signature Verification
External services (SmartThings, Yale, iParcelBox) send webhooks for device events. These are verified, queued, and processed asynchronously. Verification differs per vendor: SmartThings uses RSA-SHA256, Yale uses HMAC-SHA256, and iParcelBox is unsigned — authenticated instead by an unguessable per-box URL token (see iParcelBox Webhook).
Key Files
crates/bf_notify/src/lib.rs- Webhook endpointscrates/bf_notify/src/signature.rs- SmartThings verificationcrates/bf_auth/src/handlers/webhook.rs- Yale verificationcrates/bf_notify/src/iparcelbox.rs- iParcelBox webhook handler (no signature)
Webhook Flow
┌─────────────────────────────────────────────────────────────────────────┐
│ External Service │
│ (SmartThings / Yale) │
└─────────────────────────────────────────────────────────────────────────┘
│
│ POST /webhooks/{service}
│ + Signature headers
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ bf_notify Worker │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Verify Signature │
│ ├─ SmartThings: RSA-SHA256 (RFC HTTP Signatures) │
│ ├─ Yale: HMAC-SHA256 │
│ └─ iParcelBox: none — unguessable per-box URL token + MAC check │
│ │
│ 2. Parse Webhook Payload │
│ │
│ 3. Queue for Async Processing │
│ └─ Cloudflare Queue: SMART_EVENTS │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│
│ Queue message
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Queue Consumer (#[event(queue)]) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Lookup Property/Device IDs │
│ └─ bf_resident /service/lookup/* │
│ │
│ 2. Forward to User DO │
│ └─ bf_user::proxy::update_device_state_from_smart() │
│ │
│ 3. Process Side Effects │
│ ├─ Create access log │
│ ├─ Create notification │
│ └─ Send FCM push │
│ │
└─────────────────────────────────────────────────────────────────────────┘
SmartThings Signature Verification
SmartThings Enterprise uses RFC HTTP Signatures with RSA-SHA256.
Authorization Header Format
Authorization: Signature keyId="smartthings",
algorithm="rsa-sha256",
headers="(request-target) date digest",
signature="BASE64_SIGNATURE"
Verification Process
// crates/bf_notify/src/signature.rs
pub async fn verify_smartthings_signature(
req: &Request,
body: &[u8],
env: &Env,
) -> Result<bool> {
// 1. Parse Authorization header
let auth_header = req.headers().get("Authorization")?
.ok_or("Missing Authorization header")?;
let params = parse_signature_header(&auth_header)?;
// 2. Fetch public key (cached in KV)
let public_key = get_smartthings_public_key(env, ¶ms.key_id).await?;
// 3. Build signing string
let signing_string = build_signing_string(req, body, ¶ms.headers)?;
// 4. Verify RSA-SHA256 signature
verify_rsa_sha256_signature(&public_key, &signing_string, ¶ms.signature)?;
// 5. Validate Date header freshness
let date_header = req.headers().get("Date")?
.ok_or("Missing Date header")?;
validate_timestamp(&date_header, 300)?; // 5 minute tolerance
Ok(true)
}
fn build_signing_string(
req: &Request,
body: &[u8],
headers: &[String],
) -> Result<String> {
let mut lines = Vec::new();
for header in headers {
match header.as_str() {
"(request-target)" => {
let method = req.method().to_string().to_lowercase();
let path = req.url()?.path();
lines.push(format!("(request-target): {} {}", method, path));
}
"date" => {
let date = req.headers().get("Date")?.ok_or("Missing Date")?;
lines.push(format!("date: {}", date));
}
"digest" => {
// SHA-256 of body, base64 encoded
let digest = compute_sha256_base64(body);
lines.push(format!("digest: SHA-256={}", digest));
}
other => {
let value = req.headers().get(other)?.ok_or(format!("Missing {}", other))?;
lines.push(format!("{}: {}", other.to_lowercase(), value));
}
}
}
Ok(lines.join("\n"))
}
Public Key Caching
const ST_KEY_CACHE_PREFIX: &str = "st_pubkey:";
const ST_KEY_CACHE_TTL_SECS: u64 = 3600; // 1 hour
async fn get_smartthings_public_key(env: &Env, key_id: &str) -> Result<Vec<u8>> {
let cache_key = format!("{}{}", ST_KEY_CACHE_PREFIX, key_id);
let kv = env.kv("CACHE")?;
// Check cache
if let Some(cached) = kv.get(&cache_key).bytes().await? {
return Ok(cached);
}
// Fetch from SmartThings
let url = format!("https://key.smartthings.com/{}", key_id);
let response = reqwest::get(&url).await?;
let pem = response.text().await?;
// Parse X.509 certificate to extract public key
let cert = x509_parser::parse_x509_certificate(pem.as_bytes())?;
let public_key_der = cert.public_key().raw.to_vec();
// Cache
kv.put(&cache_key, &public_key_der)?
.expiration_ttl(ST_KEY_CACHE_TTL_SECS)
.execute()
.await?;
Ok(public_key_der)
}
Yale Signature Verification
Yale uses HMAC-SHA256 with a shared API key.
X-Signature Header Format
X-Signature: t=1705123456,v=HMAC_HEX_SIGNATURE
Verification Process
// crates/bf_auth/src/handlers/webhook.rs
const TIMESTAMP_TOLERANCE_SECS: i64 = 300; // 5 minutes
pub async fn handle_yale_webhook(
headers: HeaderMap,
State(env): State<Env>,
body: Bytes,
) -> Response {
// 1. Get signature header
let signature_header = headers.get("X-Signature")
.and_then(|v| v.to_str().ok())
.ok_or("Missing X-Signature header")?;
// 2. Parse timestamp and signature
let (timestamp, signature) = parse_signature_header(signature_header)?;
// 3. Validate timestamp freshness
verify_timestamp(timestamp)?;
// 4. Get API key from environment
let api_key = env.var("YALE_API_KEY")?.to_string();
// 5. Verify signature
let body_str = String::from_utf8_lossy(&body);
verify_signature(&api_key, timestamp, &body_str, &signature)?;
// 6. Process webhook
process_yale_webhook(&body).await
}
fn parse_signature_header(header: &str) -> Result<(i64, String)> {
// Format: "t=1705123456,v=abc123..."
let mut timestamp = None;
let mut signature = None;
for part in header.split(',') {
let (key, value) = part.split_once('=')
.ok_or("Invalid signature format")?;
match key {
"t" => timestamp = Some(value.parse::<i64>()?),
"v" => signature = Some(value.to_string()),
_ => {}
}
}
Ok((
timestamp.ok_or("Missing timestamp")?,
signature.ok_or("Missing signature")?,
))
}
fn verify_timestamp(timestamp_ms: i64) -> Result<()> {
let timestamp_secs = timestamp_ms / 1000;
let now = chrono::Utc::now().timestamp();
let diff = (now - timestamp_secs).abs();
if diff > TIMESTAMP_TOLERANCE_SECS {
return Err("Timestamp too old".into());
}
Ok(())
}
fn verify_signature(
api_key: &str,
timestamp: i64,
body: &str,
expected_signature: &str,
) -> Result<()> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
// Build payload: timestamp.body
let payload = format!("{}.{}", timestamp, body);
// Create HMAC
let mut mac = Hmac::<Sha256>::new_from_slice(api_key.as_bytes())?;
mac.update(payload.as_bytes());
// Decode expected signature
let expected_bytes = hex::decode(expected_signature)?;
// Constant-time comparison
mac.verify_slice(&expected_bytes)
.map_err(|_| "Invalid signature")?;
Ok(())
}
iParcelBox Webhook (Unsigned, URL-Token Auth)
iParcelBox boxes POST status updates to a registered webhook URL, but — unlike SmartThings (RSA) and Yale (HMAC) — the payload carries no signature or HMAC. This mirrors the vendor's Home Assistant integration, which authenticates purely by an unguessable UUID in the webhook path. We do the same and add a second check on the box's MAC.
Authentication Model
- Per-box URL token. At provision time a UUID
parcelbox_webhook_tokenis generated and stored onexternal_device_id, and the URL…/webhooks/iparcelbox/{token}is registered with the box viasetWebhook. The token is the only secret — an attacker cannot guess it, and it never appears in any signed header. - MAC cross-check. The POSTed JSON includes a top-level
"device"field (the box's MAC address). After resolvingtoken → device, we verify thatdeviceequals the storedparcelbox_device_id. A mismatch is treated as unauthenticated. - No timestamp / replay window. There is no signed timestamp to validate (the box doesn't sign), so unlike SmartThings/Yale there is no 5-minute freshness check. The webhook is full-state and idempotent, so replays are harmless.
Verification Process
// crates/bf_notify/src/lib.rs — router
// .post_async("/webhooks/iparcelbox/:token", handle_iparcelbox_webhook)
// crates/bf_notify/src/iparcelbox.rs (no HMAC, lighter than yale.rs)
async fn handle_iparcelbox_webhook(
mut req: Request,
ctx: RouteContext<()>,
) -> worker::Result<Response> {
let token = ctx.param("token").ok_or("Missing token")?;
let body = req.bytes().await?;
let payload: ParcelBoxStatusPayload = serde_json::from_slice(&body)?;
// 1. Resolve token → device (unknown token → 404, don't leak existence)
let device = mesh.resident()
.get_device_by_parcelbox_webhook_token(token)
.await?
.ok_or_else(|| Response::error("Not found", 404))?;
// 2. Cross-check the payload MAC against the resolved device
if payload.device != device.parcelbox_device_id {
return Response::error("Not found", 404);
}
// 3. Queue for async processing (same path as other vendors)
let queued = QueuedSmartEvent {
source: EventSource::ParcelBox,
external_device_id: payload.device.clone(),
raw_payload: serde_json::to_value(&payload)?,
received_at: chrono::Utc::now().timestamp(),
// ...
};
ctx.env.queue("SMART_EVENTS")?.send(&queued).await?;
Response::ok("OK")
}
Payload & Event Mapping
The webhook body is the same JSON as the cloud getStatus response (top-level result / device / licence plus the full data object: boxStatus, parcelCount, lockStatus, lidStatus, connected, battery, etc.). bf_notify stays a thin transform: it emits a full StateUpdated { state: DeviceStateData::ParcelBox { … } } for the state projection, plus a semantic SmartEvent derived from the boxStatus transition. Because transition detection needs the prior state, the comparator that raises the semantic event lives in bf_user (which already loads prior state in update_device_state_only).
New boxStatus (+ delta) | Semantic SmartEvent |
|---|---|
deliveryRequested | ParcelBoxDeliveryRequested |
allowDelivery / count increased | ParcelBoxDelivered { parcel_count } |
Clearing | ParcelBoxEmptied |
Locked (from non-locked) | ParcelBoxLocked |
Error | ParcelBoxError { reason } (reason from message) |
connected false ↔ true | DeviceOffline / DeviceOnline |
battery crossing the low threshold raises the generic BatteryLow { battery }. See smart-device-abstraction.md for the cloud API and state shape.
Queue Processing
Webhook Entry Point
// crates/bf_notify/src/lib.rs
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: Context) -> worker::Result<Response> {
Router::new()
.post_async("/webhooks/smartthings", handle_smartthings_webhook)
.post_async("/webhooks/yale", handle_yale_webhook)
.post_async("/webhooks/bf-smart", handle_bf_smart_webhook)
.run(req, env)
.await
}
async fn handle_smartthings_webhook(
mut req: Request,
ctx: RouteContext<()>,
) -> worker::Result<Response> {
let body = req.bytes().await?;
// Verify signature
if !verify_smartthings_signature(&req, &body, &ctx.env).await? {
return Response::error("Invalid signature", 401);
}
// Parse payload
let event: SmartThingsWebhookPayload = serde_json::from_slice(&body)?;
// Queue for async processing
let queued = QueuedSmartEvent {
source: "smartthings".to_string(),
external_property_id: event.location_id.clone(),
external_device_id: event.device_id.clone(),
event_type: event.event_type.clone(),
raw_payload: serde_json::to_value(&event)?,
received_at: chrono::Utc::now().timestamp(),
};
ctx.env.queue("SMART_EVENTS")?.send(&queued).await?;
Response::ok("OK")
}
Queue Consumer
#[event(queue)]
pub async fn queue(
batch: MessageBatch<serde_json::Value>,
env: Env,
_ctx: Context,
) -> Result<()> {
for message in batch.messages()? {
let body = message.body();
if let Ok(smart_event) = serde_json::from_value::<QueuedSmartEvent>(body.clone()) {
process_queued_smart_event(&smart_event, &env).await?;
}
}
Ok(())
}
async fn process_queued_smart_event(
event: &QueuedSmartEvent,
env: &Env,
) -> Result<()> {
// 1. Create mesh client
let mesh = Mesh::new("bf_notify", env)?;
// 2. Lookup internal IDs
let lookup = match event.source.as_str() {
"smartthings" => {
mesh.resident()
.lookup_by_smartthings_ids(&LookupBySmartthingsIdsRequest {
smartthings_property_id: event.external_property_id.clone(),
smartthings_device_id: event.external_device_id.clone(),
})
.await?
}
"yale" => {
mesh.resident()
.lookup_by_yale_id(&LookupByYaleIdRequest {
yale_id: event.external_device_id.clone(),
})
.await?
}
"parcel_box" => {
// Already resolved at ingest via the URL token; re-resolve by MAC
mesh.resident()
.get_device_by_parcelbox_device_id(&event.external_device_id)
.await?
}
_ => return Err("Unknown source".into()),
};
// 3. Transform to SmartEvent
let smart_event = transform_to_smart_event(&event.raw_payload, &event.source)?;
// 4. Forward to User DO
let update_request = UpdateDeviceStateFromSmartRequest {
device_id: lookup.device_id,
device_name: lookup.device_name,
device_type: lookup.device_type,
event: smart_event,
source: event.source.clone(),
};
bf_user::proxy::update_device_state_from_smart(
env,
&lookup.property_id,
update_request,
).await?;
Ok(())
}
Property/Device ID Resolution
Lookup Service Endpoints
// crates/bf_resident/src/handlers/service.rs
/// Lookup by SmartThings location + device IDs
pub async fn lookup_by_smartthings_ids(
State(env): State<Env>,
Json(req): Json<LookupBySmartthingsIdsRequest>,
) -> Result<Json<LookupByExternalIdResponse>> {
let mut conn = env.d1("DB")?.into();
let device = sqlx_d1::query_as!(
DeviceWithProperty,
r#"
SELECT d.id, d.name, d.device_type, p.id as property_id, p.address
FROM devices d
JOIN properties p ON d.property_id = p.id
JOIN external_device_ids e ON d.id = e.device_id
WHERE e.smartthings_id = ?
"#,
req.smartthings_device_id
)
.fetch_optional(&mut conn)
.await?
.ok_or_else(|| AppError::NotFound("Device not found".into()))?;
Ok(Json(LookupByExternalIdResponse {
property_id: device.property_id,
device_id: device.id,
property_address: device.address,
device_name: device.name,
device_type: device.device_type.parse()?,
}))
}
/// Lookup by Yale lock/device ID
pub async fn lookup_by_yale_id(
State(env): State<Env>,
Json(req): Json<LookupByYaleIdRequest>,
) -> Result<Json<LookupByExternalIdResponse>> {
let mut conn = env.d1("DB")?.into();
let device = sqlx_d1::query_as!(
DeviceWithProperty,
r#"
SELECT d.id, d.name, d.device_type, p.id as property_id, p.address
FROM devices d
JOIN properties p ON d.property_id = p.id
JOIN external_device_ids e ON d.id = e.device_id
WHERE e.yale_id = ?
"#,
req.yale_id
)
.fetch_optional(&mut conn)
.await?
.ok_or_else(|| AppError::NotFound("Device not found".into()))?;
Ok(Json(LookupByExternalIdResponse {
property_id: device.property_id,
device_id: device.id,
property_address: device.address,
device_name: device.name,
device_type: device.device_type.parse()?,
}))
}
Security Considerations
Constant-Time Comparison
Both verification methods use constant-time comparison to prevent timing attacks:
// HMAC verify_slice uses constant-time comparison internally
mac.verify_slice(&expected_bytes)?;
// RSA verification also uses constant-time comparison
signature::UnparsedPublicKey::new(&RSA_PKCS1_2048_8192_SHA256, public_key)
.verify(signing_string.as_bytes(), &signature)?;
Timestamp Validation
Both systems validate timestamp freshness (5 minutes) to prevent replay attacks.
Key Caching
SmartThings public keys are cached in KV with 1-hour TTL:
- Reduces latency
- Handles key rotation gracefully
- Falls back to fresh fetch if key not found
See Also
- smart-device-abstraction.md - Device abstraction
- notification-system.md - Event processing
- realtime-events.md - Side effects