Webhook Security

πŸ“˜

Why Verify?

Subscribing to Benchling Webhooks requires hosting a publicly available endpoint to receive them. This introduces the potential for bad actors to attempt to impersonate Benchling or otherwise maliciously interact with the endpoint. To prevent this, Benchling includes a unique signature and timestamp with every webhook. By verifying this signature and timestamp, you can confirm the webhook was sent by Benchling.

For more information, check out the following resource: https://webhooks.fyi/security

Endpoint Signing Using JSON Web Key Sets

When your app is created in a tenant using the app manifest, Benchling creates an endpoint signing keypair (i.e. JWKS). Benchling generates webhook signatures using Elliptic Curve Signatures, which can be verified by your app using the JWKS published for your app. JWKS keysets can be obtained from a global endpoint using your app id:

https://apps.benchling.com/api/v1/apps/{app_definition_id}/jwks

Your app should query the JWKS endpoint for the current values at intervals of no more than 6 hours, since Benchling frequently rotates these keypairs (and the corresponding public keys). Below is an example of a JWKS:

❗️

Do NOT hardcode the initial JWKS values!

2004

A public key set

Verifying Webhooks Manually

Benchling recommends using a package or library suitable for your language to verify the signature. Some common libraries are listed below in the Sample Verification Libraries section. The general steps required to verify a signature are outlined below; for a more detailed walkthrough with example Python code, check out the tutorial at the end of this guide.

Verify timestamp

The webhooks-timestamp header includes a timestamp that corresponds to when Benchling attempted to send the webhook, used to prevent timestamp attacks. You should compare this timestamp against your system timestamp in UTC to make sure it's within your tolerance; Benchling recommends using a 5 minute tolerance for this comparison.

Creating signed_content String

Each webhook includes three headers with additional information that are used for verification:

  • Webhook-Id: the unique message identifier for the webhook message. This identifier is unique across all messages, but will be the same when the same webhook is being resent (e.g. due to a previous failure).
  • Webhook-Timestamp: timestamp in seconds since epoch.
  • Webhook-Signature: the Base64 encoded list of signatures (space delimited).

The first verification step involves concatenating the webhook id, timestamp, and payload values, separated by the full-stop character (i.e. .). In code, it will look something like this (where body is the raw body of the request):

signed_content = "${webhook_id}.${webhook-timestamp}.${body}"

🚧

Note: The signature is sensitive to any changes, so even a small change in the body will cause the signature to be completely different. This means that you should not alter the body in any way before verifying.

Getting webhook-signature

The webhook-signature header is composed of a list of signatures and their corresponding version identifiers, separated by a single space. Most often there is only a single signature, though due to keypair rotation there could be multiple signatures. It’s important to iterate through and attempt to validate each signature, since if any one of them are valid, the webhook is legitimate.

An example webhook-signature header may look like this:

`v1b``,``dYhtchoyr6js2AODL5VkpoPX5BN8SEiMPsBbKYEUK1RU``/+``lYEkYrcu3SYGzXAGnvsHXQvuwdpZFg``/``2ty8VO``/``uQ``==`` `
`v1bder``,``MEQCIHWIbXIaMq``+``o7NgDgy``+``VZKaD1``+ABcDhF``jD7AWymBFCtUAiBU``/+``lYEkYrcu3SYGzXAGnvsHXQvuwdpZFg``/``2ty8VO``/``uQ``==
v2bder,MEQCIHWIbXIaMq+o7MgDgy+VZKaD1+QTfEhIjD7AWymBFCtUAiBU/+lYEkYrcu3SYGzXAGnvsHXQvuwdpZFg/2ty8VO/uQ==
`

These example signatures represent the following:

  • v1b - a raw ECDSA signature value, in big-endian order
  • v1bder - a DER encoded ECDSA signature value
  • v2bder - another DER encoded ECDSA signature value

🚧

Note: Make sure to remove the version prefix and delimiter (e.g. v1b, or v1bder,) before verifying the signature.

The webhook signature header includes both raw ECDSA values (e.g. v1b) and DER encoded values (e.g. v1bder) for convenience; your choice of verification library will likely determine which signature encoding to use (see Sample Verification Libraries below). For the purposes of our example implementation, we’re using the DER encoded signature values.

Create public key and validate signature

You’ll need to convert the JWKS set queried from Benchling into a list of EllipticCurvePublicKeys which you can use to verify the signed_content. While the specifics will depend on your implementation, in code, this might look something like this:

pubkey = load_pem_public_key(jk.export_to_pem())

With your public key in hand, you can iterate over the webhook signatures. Here, we’re working with the DER encoded signatures:

for der_signature in der_signatures:
        raw_signature = base64.b64decode(der_signature)
        try:
            pubkey.verify(raw_signature, bytes(to_verify, "utf-8"), ec.ECDSA(hashes.SHA256()))
        except InvalidSignature:
            continue

For a more in-depth example of this process, check out the tutorial below.

Sample Verification Libraries

Below are some common cryptographic libraries useful for verifying ECDSA signatures:

Tutorial: Webhook Signature Validation in Python

import base64
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from jwcrypto import jwk
import requests
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.exceptions import InvalidSignature

MY_APP_ID = "app_foobarbaz"

class WebhookVerificationError(Exception):
    pass

def get_der_signatures_from_versioned_signatures(versioned_signatures):
    """
       Signature format is f"v{version_number}der,{signature}"
    """
    der_signatures = []
    for versioned_sig in versioned_signatures:
        _version, sig = versioned_sig.split(',')
        if  'der' in _version :
            der_signatures.append(sig)
    if der_signatures is []:
        assert False, 'Expected to find a der encoded signature'
    return der_signatures

# This should hit the public URL
def get_jwks_with_caching():
    return jwk.JWKSet.from_json(requests.get(f"https://benchling.com/apps/jwks/{MY_APP_ID}").text)

def __verify_timestamp(timestamp_header: str) -> datetime:
    webhook_tolerance = timedelta(minutes=5)
    now = datetime.now(tz=timezone.utc)
    try:
        timestamp = datetime.fromtimestamp(float(timestamp_header), tz=timezone.utc)
    except Exception:
        raise WebhookVerificationError("Invalid Signature Headers")
    if timestamp < (now - webhook_tolerance):
        raise WebhookVerificationError("Message timestamp too old")
    if timestamp > (now + webhook_tolerance):
        raise WebhookVerificationError("Message timestamp too new")
    return timestamp

def verify(data:dict, headers:dict)-> bool:
    msg_timestamp = headers["webhook-timestamp"]
    __verify_timestamp(msg_timestamp)
    to_verify = f'{headers["webhook-id"]}.{msg_timestamp}.{data}'
    signatures = headers["webhook-signature"].split(' ')
    der_signatures = get_der_signatures_from_versioned_signatures(signatures)
    jwks = get_jwks_with_caching()
    any_valid = False
    for jk in jwks:
        pubkey = load_pem_public_key(jk.export_to_pem())
        for der_signature in der_signatures:
            raw_signature = base64.b64decode(der_signature)
            try:
                pubkey.verify(raw_signature, bytes(to_verify, "utf-8"), ec.ECDSA(hashes.SHA256()))
            except InvalidSignature:
                continue
            any_valid = True
    if not any_valid:
        raise WebhookVerificationError("No matching signature found")
    return any_valid