resources/lib/my5.py — My5 Cognito auth + Cassie key extractor + resolver
Role
Resolves both My5 live channels (Channel 5, 5 Star, 5 USA, 5 Action, 5 Select) and on-demand episodes. The interesting part is that every request to Cassie (My5's backend) is HMAC-signed AND the response is AES-CBC-encrypted — and both keys are XOR-obfuscated inside C5's live player JavaScript. The addon extracts them at runtime, caches them in memory, and re-extracts if a 403 "Failed to authenticate HMAC" comes back (which happens when C5 rotates the keys, approximately weekly).
The module is also responsible for optional Channel5 user sign-in via
AWS Cognito (the My5 web app's auth backend). When a session is active,
the access token is attached to Cassie requests as
Authorization: Bearer … so premium content unlocks. Free
content continues to play anonymously — see
the auth section below.
Constants
Cassie API roots. The desktop platform ID is what the C5 web player sends — important because the keys are bound to this platform. Changing the platform ID without re-extracting keys would 403.
AWS Cognito user-pool client ID and IDP endpoint. Same client_id the
My5 web app uses for its hosted-UI OAuth implicit flow; the addon calls
InitiateAuth with USER_PASSWORD_AUTH directly,
which Cognito also accepts for this client. Config source is
https://cassie.channel5.com/platform/my5desktopng/config.json
→ userServiceSettings.userServiceSdkConfig.apiUrl
(hosted at userservice-api.channel5.com for user-profile
calls; the addon skips that and only talks to the IDP endpoint).
Where the Cognito session dict
({access_token, id_token, refresh_token, expires_at, email})
is persisted as JSON. Sibling to itv_session used by
itvx.py.
The Akamai-hosted player script that holds the obfuscated key blob. This is the file we fetch on every fresh-key extraction.
Module-level cache. Lives for the addon's Python interpreter lifetime (which is short-lived in the plugin case — every directory-listing action spawns a new interpreter — but long-lived for the service), then re-extracted on next call.
Crypto primitives
URL-safe base64 (+ → -, / → _,
strips padding on encode, re-pads on decode). Cassie uses URL-safe base64
for signatures and IVs.
Two import paths — pycrypto (legacy) or pycryptodomex
(newer). Whatever's installed. Kodi on OSMC ships PyCryptodome.
AWS Cognito user authentication
My5's web app authenticates users via AWS Cognito (user pool
my5userpool in eu-west-2). The browser flow is
hosted-UI OAuth implicit — the resulting localStorage.My5_SessionId
is the Cognito access_token. Channel5's corona/cassie clients attach it
as Authorization: Bearer on requests that need
a signed-in user. The addon mirrors this by calling Cognito's
InitiateAuth (USER_PASSWORD_AUTH flow) directly
with the same client_id the website uses, saving the access token to
my5_session.
JSON-backed load / save / wipe of the session dict at
SESSION_FILE. _save_session lazily
os.makedirs(SESSION_DIR) so a fresh addon profile dir works
without a preflight. All three swallow IOError and return {}
/ log + continue — never raise.
POST a JSON body to the Cognito IDP endpoint with the
X-Amz-Target: header. Returns
the parsed JSON response. On HTTP error, reads the AWS error body and
raises Exception("My5: Cognito
so callers can pattern-match on substring ("Incorrect", "NotAuthorized")
to produce user-friendly Kodi notifications.
Wired to ?action=my5_login (settings UI button — see
default.py's dispatch table). Reads
credentials from the addon's my5_email/my5_password
settings if not passed as args. Calls Cognito
InitiateAuth(USER_PASSWORD_AUTH), lower-cases the email
(Cognito is case-sensitive on the sub). On success persists
{access_token, id_token, refresh_token, expires_at, email}
to my5_session and pops a success notification.
Failure modes are individually surfaced: missing creds (warning),
Incorrect/NotAuthorized (invalid-credentials error), MFA /
new-device / SRP / password-reset challenge (not-supported error pointing
the user at channel5.com to complete the challenge, after which direct
USER_PASSWORD_AUTH will work), or no AccessToken in the
response (unexpected-response error). Never raises.
Wired to ?action=my5_logout. Best-effort
GlobalSignOut against Cognito using the saved access_token
(network error / expired token / already-revoked are fine — logged at
LOGDEBUG and ignored), then unconditionally removes
my5_session locally so the user appears signed out.
Uses the saved refresh_token to call
InitiateAuth(REFRESH_TOKEN_AUTH) and get a new access token.
Updates expires_at on the session dict and persists it.
Cognito's refresh response sometimes omits a new RefreshToken;
the old one is kept (reusable until revoked by GlobalSignOut). On any
failure the local session is cleared so _get_access_token
next call returns "" (caller will see anonymous).
Top-level accessor. Returns the cached access token if it has > 60 s
of life left, otherwise calls _refresh_session() to renew.
Returns "" when no session exists (user not signed in) or
refresh failed — i.e. always returns a string, never raises.
Cassie's free-to-air content doesn't require auth, but some premium
content returns 401/403 to an anonymous request. The strategy in
_fetch_cassie below is:
if a Cognito session exists, attempt the request with
Authorization: Bearer ; if that gets
401 or a non-HMAC 403, retry without the header. Free-to-air content
keeps playing even when the saved session has expired and refresh has
failed.
The key heist — _extract_cassie_keys
This is the wild part. Steps:
- Fetch the live C5 player JS from
PLAYER_JS_URL. - Find the 6-character XOR key — looks like
})('XXXXXX')in the source. - Find the obfuscated config blob — a long string literal (≥3000 characters) returned by a closure. Try double-quotes first, fall back to single-quotes.
- URL-decode the blob (it's
%xx-escaped). - XOR-decode character-by-character against the 6-char key cyclically — this unwraps the obfuscation.
- Scan the decoded text for 24-character base64 strings (22 alphanumeric
+ 1 or 2
=padding characters) that decode to exactly 16 bytes. - Take keys[1] as AES, keys[0] as HMAC (verified empirically — comment at line 108).
= pad
The regex at line 97 —
r'(? —
requires at least one = pad character. Why:
- Standard base64 of a 16-byte buffer is 22 chars +
==(24 total). - The C5 player JS at various points strips one trailing
=, yielding 22 chars + 1=. - Loosening the regex to
={0,2}matches ~130 unrelated JS method names (e.g."getTextTrackContainer+") which happen to match the loose b64-alphabet pattern and decode to 16 bytes by coincidence — they would corrupt the cache and 403 every request.
Plus the negative-lookbehind/lookahead
(? / (?![A-Za-z0-9+/]) ensures
we don't match a substring of a longer base64 blob.
base64.b64decode()
Line 101: c_padded = c + "=" * ((4 - len(c) % 4) % 4).
base64.b64decode silently accepts misaligned 22-char strings
and returns 16 bytes if they happen to align — but the alignment is
unpredictable. Always re-pad to a multiple of 4 before decoding.
Cached accessor. Extracts fresh if force_refresh=True or
the cache is empty. Always returns in the order (AES, HMAC).
URL signing + decryption
Constructs the signed Cassie URL:
- Path =
CASSIE_BASE_{VOD|LIVE}_URL/./ .json - Append
?timestamp=. - HMAC-SHA256 the URL-with-timestamp string using the HMAC key as bytes.
- URL-safe base64-encode the signature, append as
&auth=.
The signature is bound to the timestamp, so requests expire after ~1 hour (C5's TTL).
Performs the signed fetch in two stages:
- Call
_get_access_token()from the Cognito auth section. If a non-empty token comes back, attempt the request withAuthorization: Bearer. This unlocks premium / signed-in-only content. - If the Bearer attempt gets a 401 or a non-HMAC 403 (token rejected / expired), retry the request without the header so free-to-air content keeps playing even when the saved session has gone stale.
The recursive helper _attempt(extra_headers) handles
HMAC-key rotation transparently: on a 403 with body
"Failed to authenticate HMAC" it re-extracts keys
(_get_cassie_keys(force_refresh=True)), rebuilds URL, retries
once. Any other HTTP error or a non-HMAC 403 re-raises. The successful
response is JSON with iv + data fields, both
URL-safe base64 — passed to _decrypt_cassie().
AES-CBC decrypt with PKCS7 unpadding. Returns the parsed JSON dict that contains the stream manifest URL + license server URL.
Iterates data["assets"], picks the first one where
asset["drm"] == "widevine", takes its keyserver
(license URL) and the first rendition's url (manifest URL).
Throws "My5: no widevine stream found" if no Widevine asset is
available — purely-free content isn't supported by this resolver path.
Resolvers
Called by default.py:_resolve_channel
for the Channel 5 / 5 Star / 5 USA / 5 Action / 5 Select channels. Fetches
Cassie in live mode, extracts the Widevine stream + keyserver, hands them
to setup_dash_item() with the
standard My5 license headers.
Always swallows exceptions and logs a warning — never raises. The
calling _resolve_channel does the same, so a key-extraction
failure on a live channel doesn't crash the plugin.
Helper used by resolve_vod when the caller knows the episode's
friendly name (a slug) but Cassie needs the GUID (e.g.
C5XXXXXX). Walks my5_vod.list_seasons +
list_episodes looking for a matching f_name,
falls back to corona's episodes/next.json endpoint.
VOD playback. Three resolution paths:
- If
standalone=True: callmy5_vod.get_standalone_episode_idto translate the showf_nameinto a single-episode GUID. - If
episode_iddoesn't start with "C": it's a slug, run_resolve_episode_guidto get the GUID. - Otherwise:
episode_idis already the GUID, use directly.
Then fetch Cassie, extract stream + license, hand to
setup_dash_item().
Notable details
stream_url = stream_url.replace("subtitles=off", "subtitles=on")at lines 407 & 469 — C5's manifests ship with subtitles disabled by default for direct stream URLs; flipping the query param re-enables them before Kodi opens the manifest.- License headers shape:
"User-Agent=— Cassie's widevine endpoint sniffs both UA + Referer and rejects Roku/ Android UAs, hence the desktop-Chrome UA from&Referer=https://www.channel5.com/&Content-Type=application/octet-stream" get_ua(). license_payload=Noneis passed explicitly sosetup_dash_item()uses the{SSM}placeholder path (not the pre-built-payload path My5 used to need).