hub_trigger
Verification of Hub-originated JOB_REQUEST_FROM_HUB triggers.
The Hub starts a v9 interactive DAG run on a pod with no modeller in the loop. There is no Access Manager authorisation and no modeller keypair in that flow, so the trigger authenticates itself instead: the Hub RSA-hybrid-encrypts the flow spec to the pod's public key, signs the ciphertext with a Hub signing key, and the pod verifies that signature against the public halves the Hub publishes as a JWKS.
The message body is plain JSON, camelCase throughout — clear routing fields plus
a signedPayload blob:
{
"keyId": ..., "datasetOwner": ..., "datasetName": ..., "projectId": ...,
"taskId": ..., "taskRunMetadata": {...},
"signedPayload": {"encryptedRequest": <b64>, "signature": <b64>}
}
encryptedRequest decrypts to zstd(msgpack({"flow_spec_raw": {...}})). Note the
two layers differ deliberately: the outer body is plain JSON because the Hub
serialises it, while the inner payload uses the SDK's own msgpack+zstd framing so a
large flow spec does not become a large ciphertext. Only that inner payload is
encrypted and signed; routing is read from the clear outer fields.
That is deliberate — see accepted risk 6 in
sdk/docs/adr/0003-hub-originated-job-triggers.md — and rests on hub→Kinesis→
Lambda being an IAM-trusted path.
The order of operations in HubTriggerVerifier.verify is load-bearing and must
not be reordered: resolve keyId → verify the signature → only then decrypt
with the pod's private key → then check freshness. Unauthenticated ciphertext is
never fed to the pod's private key.
Module
Global variables
Base64Bytes- Bytes carried through JSON as base64. Decoding happens in validation, so every consumer downstream seesbytesand never has to remember to decode.
Classes
HubTriggerError
class HubTriggerError(*args, **kwargs):Base class for JOB_REQUEST_FROM_HUB rejections.
HubTriggerVerifier
class HubTriggerVerifier( jwk_client: PyJWKClient, freshness_seconds: int, unknown_key_id_ttl_seconds: int = 300,):Authenticates JOB_REQUEST_FROM_HUB messages.
Holds the JWKS client (which owns key fetching, caching and rotation) and the
negative cache for unknown kids. Build one per pod and reuse it, so the
caches are actually shared across triggers.
Arguments
jwk_client: Client for the Hub's JWKS endpoint. Used only for fetch/cache/rotation — the returnedPyJWK's raw key is handed to_RSAEncryption;jwt.decodeis never called, because these signatures are detached RSA-PSS over ciphertext rather than JWS.freshness_seconds: How old a trigger may be before it is rejected as stale.unknown_key_id_ttl_seconds: How long an unresolvablekidis remembered so it cannot force repeated JWKS refetches.
Static methods
from_config
def from_config() ‑> HubTriggerVerifier:Build a verifier from config.settings.
The JWKS URI and freshness window are read once here, so a later
config.refresh_configuration() does not affect a running pod; rebuild
the verifier (restart the pod) to pick up such a change.
Methods
verify
def verify( self, message: _BitfountMessage, pod_private_key: RSAPrivateKey,) ‑> VerifiedHubJobRequest:Authenticate a JOB_REQUEST_FROM_HUB and return its contents.
The step order here is load-bearing: the signature is checked before the ciphertext ever reaches the pod's private key, and freshness is checked last so a stale-but-authentic trigger is distinguishable from a forged one.
Arguments
message: The incomingJOB_REQUEST_FROM_HUBmessage.pod_private_key: The pod's RSA private key, which the payload was encrypted to.
Returns The verified request: clear routing fields, decrypted flow spec, and the Hub key to encrypt replies back to.
Raises
MalformedHubTrigger: If the body cannot be parsed at all.InvalidHubSignature: If thekeyIddoes not resolve, the signature does not verify, or the payload does not decrypt.StaleHubTrigger: If the envelope timestamp is outside the freshness window.
InvalidHubSignature
class InvalidHubSignature(*args, **kwargs):The trigger's signature did not verify against the Hub's signing keys.
Covers an unresolvable keyId, a signature made with the wrong key, tampered
ciphertext, and a payload the pod's private key cannot decrypt — all of which
mean the same thing to the caller: this did not come intact from the Hub.
Maps to Reason.INVALID_SIGNATURE.
Ancestors
MalformedHubTrigger
class MalformedHubTrigger(*args, **kwargs):The message body is not a well-formed hub trigger payload.
Distinct from InvalidHubSignature: nothing was verifiable in the first
place, so there is no signature verdict to report.
Ancestors
StaleHubTrigger
class StaleHubTrigger(*args, **kwargs):The trigger arrived outside the accepted freshness window.
Maps to Reason.STALE_REQUEST. This is staleness control for backlogged or
redelivered triggers (e.g. after a pod outage), not replay defence — the
envelope timestamp this is measured against is not integrity-protected.
Ancestors
VerifiedHubJobRequest
class VerifiedHubJobRequest(**data: Any):A JOB_REQUEST_FROM_HUB that verified, decrypted and is fresh.
Attributes
payload: The clear routing fields the run is dispatched on.flow_spec_raw: The v9 FlowSpec, as decrypted fromencrypted_request. This is the only integrity-protected part of the message.hub_public_key: The Hub key the signature verified against. Replies are encrypted to this same key — one Hub keypair serves both signing and reply-decryption, so a rotation makes in-flight replies undecryptable (accepted risk 4).
Create a new model by parsing and validating input data from keyword arguments.
Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.
self is explicitly positional-only to allow self as a field name.
Variables
- static
flow_spec_raw : dict[str, typing.Any]
- static
hub_public_key : cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey
- static
model_config
- static
payload : bitfount.federated.hub_trigger._JobRequestFromHubPayload