207 lines
6.1 KiB
Python
207 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""
|
|||
|
|
PlayReady public key hash revocation checker.
|
|||
|
|
|
|||
|
|
- Fetches Microsoft's revocation XML.
|
|||
|
|
- Extracts the PlayReady Silverlight Runtime revocation list.
|
|||
|
|
- Prints all 32-byte public key hashes (Base64).
|
|||
|
|
- If a hash is provided (argv[1] or prompted), reports whether it's revoked.
|
|||
|
|
- If 'bgroupcert.dat' in directory, extract the hash and reports whether it's revoked.
|
|||
|
|
|
|||
|
|
Compatible with Python 3.8+.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import base64
|
|||
|
|
import struct
|
|||
|
|
import sys
|
|||
|
|
import uuid
|
|||
|
|
from typing import Iterable, List, Optional
|
|||
|
|
|
|||
|
|
import requests
|
|||
|
|
import xmltodict
|
|||
|
|
|
|||
|
|
# --- Configuration ----------------------------------------------------------------
|
|||
|
|
|
|||
|
|
REV_URL = "https://go.microsoft.com/fwlink/?LinkId=110086"
|
|||
|
|
|
|||
|
|
# GUID for PlayReady Silverlight Runtime (as raw bytes like original code)
|
|||
|
|
GUID_PR_SILVERLIGHT_RUNTIME = bytes([
|
|||
|
|
0x4E, 0x9D, 0x8C, 0x8A, 0xB6, 0x52, 0x45, 0xA7,
|
|||
|
|
0x97, 0x91, 0x69, 0x25, 0xA6, 0xB4, 0x79, 0x1F
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
# If construct is available, we’ll use it to parse the binary blob declaratively.
|
|||
|
|
try:
|
|||
|
|
from construct import Struct as CStruct, Bytes, Int32ub
|
|||
|
|
_HAS_CONSTRUCT = True
|
|||
|
|
except Exception:
|
|||
|
|
_HAS_CONSTRUCT = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- Helpers ----------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _expected_guid_le_hex(guid_bytes: bytes) -> str:
|
|||
|
|
"""
|
|||
|
|
The source blob compares the first 16 bytes against UUID(...).bytes_le.
|
|||
|
|
Keep that exact behavior to match the original script.
|
|||
|
|
"""
|
|||
|
|
return uuid.UUID(guid_bytes.hex()).bytes_le.hex()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_revocation_blob_with_construct(blob: bytes) -> Optional[List[bytes]]:
|
|||
|
|
"""
|
|||
|
|
Parse a revocation list blob using construct (if installed).
|
|||
|
|
Format per original code:
|
|||
|
|
[0:16] GUID
|
|||
|
|
[16:20] unknown / reserved
|
|||
|
|
[20:24] entries_count (u32 big-endian)
|
|||
|
|
[24: ] entries_count * 32-byte hashes
|
|||
|
|
"""
|
|||
|
|
if not _HAS_CONSTRUCT:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
RevHeader = CStruct(
|
|||
|
|
"type_guid" / Bytes(16),
|
|||
|
|
"reserved" / Bytes(4),
|
|||
|
|
"entries_count" / Int32ub,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# First parse header to get count
|
|||
|
|
if len(blob) < 24:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
header = RevHeader.parse(blob[:24])
|
|||
|
|
|
|||
|
|
# Verify GUID (same logic/endianness as original)
|
|||
|
|
if blob[:16].hex() != _expected_guid_le_hex(GUID_PR_SILVERLIGHT_RUNTIME):
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
# Extract entries
|
|||
|
|
start = 24
|
|||
|
|
end = start + (header.entries_count * 32)
|
|||
|
|
if end > len(blob):
|
|||
|
|
# Truncated blob; be defensive
|
|||
|
|
end = min(len(blob), start + ((len(blob) - start) // 32) * 32)
|
|||
|
|
|
|||
|
|
entries = [blob[i:i+32] for i in range(start, end, 32)]
|
|||
|
|
return entries
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_revocation_blob_manually(blob: bytes) -> List[bytes]:
|
|||
|
|
"""
|
|||
|
|
Manual parser (no construct). Mirrors the original byte slicing exactly.
|
|||
|
|
"""
|
|||
|
|
if len(blob) < 24:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
# GUID check with the same endianness trick as original
|
|||
|
|
if blob[:16].hex() != _expected_guid_le_hex(GUID_PR_SILVERLIGHT_RUNTIME):
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
# entries_count is big-endian u32 at offset 20
|
|||
|
|
entries_count = struct.unpack_from(">I", blob, 20)[0]
|
|||
|
|
cursor = 24
|
|||
|
|
out: List[bytes] = []
|
|||
|
|
for _ in range(entries_count):
|
|||
|
|
if cursor + 32 > len(blob):
|
|||
|
|
break
|
|||
|
|
out.append(blob[cursor:cursor+32])
|
|||
|
|
cursor += 32
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _iter_revocation_lists(xml_root: dict) -> Iterable[bytes]:
|
|||
|
|
"""
|
|||
|
|
Yields each decoded revocation list (ListData) as raw bytes.
|
|||
|
|
Handles the cases where Revocation is a dict or a list of dicts.
|
|||
|
|
"""
|
|||
|
|
rev = xml_root.get("RevInfo", {}).get("Revocation")
|
|||
|
|
if rev is None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
items = rev if isinstance(rev, list) else [rev]
|
|||
|
|
for item in items:
|
|||
|
|
data_b64 = item.get("ListData")
|
|||
|
|
if not data_b64:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
yield base64.b64decode(data_b64)
|
|||
|
|
except Exception:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fetch_revocation_index(url: str = REV_URL) -> dict:
|
|||
|
|
resp = requests.get(url, timeout=20)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
# xmltodict produces nested OrderedDict-like structures; dict() is fine
|
|||
|
|
return xmltodict.parse(resp.text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _collect_public_key_hashes(xml_root: dict) -> List[str]:
|
|||
|
|
"""
|
|||
|
|
Returns Base64-encoded 32-byte public key hashes from the
|
|||
|
|
PlayReady Silverlight Runtime revocation list.
|
|||
|
|
"""
|
|||
|
|
hashes_b64: List[str] = []
|
|||
|
|
|
|||
|
|
for blob in _iter_revocation_lists(xml_root):
|
|||
|
|
# Try construct first (if available), then fallback
|
|||
|
|
entries = _parse_revocation_blob_with_construct(blob) if _HAS_CONSTRUCT else None
|
|||
|
|
if entries is None:
|
|||
|
|
entries = _parse_revocation_blob_manually(blob)
|
|||
|
|
|
|||
|
|
if not entries:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
for entry in entries:
|
|||
|
|
hashes_b64.append(base64.b64encode(entry).decode("ascii"))
|
|||
|
|
|
|||
|
|
return hashes_b64
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- CLI --------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def main(argv: List[str]) -> int:
|
|||
|
|
try:
|
|||
|
|
xml_root = _fetch_revocation_index(REV_URL)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"Failed to fetch or parse revocation index: {e}", file=sys.stderr)
|
|||
|
|
return 2
|
|||
|
|
|
|||
|
|
publickey_hashes = _collect_public_key_hashes(xml_root)
|
|||
|
|
|
|||
|
|
# Print the list (like the original)
|
|||
|
|
if publickey_hashes:
|
|||
|
|
print("\n".join(publickey_hashes))
|
|||
|
|
|
|||
|
|
# Determine the input hash
|
|||
|
|
pubkey_hash: Optional[str] = None
|
|||
|
|
if len(argv) > 1 and argv[1]:
|
|||
|
|
pubkey_hash = argv[1].strip()
|
|||
|
|
print()
|
|||
|
|
print("Provided hash:", pubkey_hash)
|
|||
|
|
|
|||
|
|
if not pubkey_hash:
|
|||
|
|
bcert = open("bgroupcert.dat", "rb").read()
|
|||
|
|
pubkey_hash = base64.b64encode(bcert[0x48:0x68]).decode()
|
|||
|
|
print()
|
|||
|
|
print("Calculated hash:", pubkey_hash)
|
|||
|
|
|
|||
|
|
if not pubkey_hash:
|
|||
|
|
print("No hash provided.")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
if pubkey_hash in publickey_hashes:
|
|||
|
|
print("one of hashes in CRL match the input hash.")
|
|||
|
|
print("PlayReady Cert is revoked.")
|
|||
|
|
return 0
|
|||
|
|
else:
|
|||
|
|
print("PlayReady Cert is valid.")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main(sys.argv))
|