Back


FairPlay Streaming (Part 1) - Intro, Structs, and Security Theory

Posted by Amir Mazzarella on September 17, 2018


I'm interrupting yet another blog post series. I know, annoying, but I've recently become interested in FairPlay Streaming (or FPS as it will be referred to in the rest of the post) since it's the DRM used in iTunes 4K streaming. FPS is an interesting DRM because unlike most DRM implementations, the SDK is available pretty freely. For DRMs like Widevine and PlayReady, the license server SDK is only available to approved partners and to those who have signed multiple NDAs. FPS has the SDK available to download to anyone who has a paid Apple Developer account. Naturally, I acquired the latest version of the SDK (at the time the latest is v4.2.0) from https://developer.apple.com/streaming/fps and began digging. The SDK provides a reference iOS app, a reference KSM (Key Security Module: a fancy term for a license server) implementation, example license requests and licenses, a developer certificate signed by Apple, and 40 pages of documentation. All very useful. So, let's stop beating around the bush and take a look at a license request:

AAAAAQAAAABdFkTq7BH5gxR1QeRu6yd0kmZIuYYewEcbohdYhRw92jHJOx3WAapOrUQVogdZqrmm2J9VE4WFbnNXFynfLx1G0lwT2irXXQD9NBPr2WykfQKVXFaff6tA8af7I0FBZ6ZT6r3xrSg99eB+fPSqL7rGTx1GD9+aIe6yen9gcnhTpBTBxFDFJejatqPxPPpXFxpVYZgKpR8DqgS8okVWyEGnFzbHcQAACoBtptrk7kAJF1R7uqUnuIIbnGCBkysPzzNykTT196jxaQIcBNoA6WNzWnQ6iHnSyre6cWAnds8eotHTwW3OUbKCRJzsc6COFyKA05WQBCr+WvlVT5c86/Y+COQM0G/Kd6CalMu625dF5/Wl9HAhPJPYW6URGeRRock1LtIKTNk0MoWabImFunmmAKnGJv2KrXwLH65VJRaG8ss3fmgG9GMTi9+QSJGiZEWOygyyA7dg2MWtISPRv2m6XJx7VG//0yqbxwLdYapvj8E+eYwRRKIegsB02XAtcEJE0Owy4ZI2xEHAuNaoovgFVYJlnrYWl3STeKklbU/+sJCuKqFiTllasjZ0atyld6ZbYGFfF+6cViiYjOqsbwMKNLx9D1myeamj7WWQ2NyhGFW2W9nL3LEFpr3Sz1NCaCSr2yeXSaHEFUylsgPzYjgfmHJah81dOuJGb5Mln7gihxQLoF7p4+5B2rUnzO4Cz2Weyyq9mCgldZBgA4nfcPYRNWb6Y9ZS1m6vwuw5FBAtpCez28JbFnpFGQNKNG5onqgOvKVxMixUvuNEhHpD8N8Sf54waPzwVB9OjrMR+z1T3SHO+xki/izHMWKezU1wkbnmmad+7hrJALnJ3vW7JxkfbTVre5wgTZeM2pxPjqziRwoHy9MGzCBADXJdPINLZHO+llgDVEPzxm4xL0ErHuctcRxtGhMjDFkNrGG9xBIqMYPtT8g5JT3fkofQDYmWRbcHG+1FsD4SJtDAcZaxWHWnULQhtaHyvrLSQlgRvA4p0LKzLasz8sDrCjZdEBBPOixC23Gp28SbfdGkXTCAxaN3GUbkmEMOdjxiJ09YEPhagU/opVVGRVIkDdPqzfRSed7hOMmHmPCtMEWXyXyzNI/xp5dfXMKDPIe7HjymAIivRibdeboIVEprPMpdpcM5Xb7HHWjPicxlmNKtET8Yq/QXWSdnXLKygrFFtRgqAj4I5DAYg95lobqSQTlwZmZr2jcfe1yU3sl2klh9QGGacqr7JpttaNNHISUl3/QFiHrkQxtkNtkfIwDm38yTJYPF2i3m2FbJ1G1o60TP0jLRHR4Pu2BdNn5IA6nniDmZvVzLFfHWQK4ICfKgB/kN37tMf+W9xOuQG91RuSFCty0SV5SXpU3k6eF5zIn7+QQhQQeJT6GuxZTr8UFmxBBeOncUKaC0GRlY6h1kUyxblquAmvHPMh++X+q4ZL52EWRs4XyWAamx9k0hLPq9xJ2I4FTQ5oJsDXQwJyioa9EtTtldnmyoqVfUFfmHanx/YCtpwueLDInpD7gNHs5JA8vypJdQ+HXET+6PpdqU37PBH0lsyA14LIwURH0XF39gK7ICFFM6iS1XF8Ax6l1rqiu9gL8xfSUOtgCqQYOtuiLQK32Jt8FQXuoPphNl59J3nXXsOdL8ECMyWTkswhAE3mE152poEAIg/8LthcSMVcrapY1AOUU6UGw/fVnIOoKbrr+FvSAangWKYhVwTUbUZZJK+lmlK30bn5tT+zbIX+U91YMzsPtzhK5iHZtNN8EKAlQCj++qgEeVw9BD5//92hs0V4FrKZv40qHqwlT/w6R7v9Czujf2TfHC/3vuthWayBkvodJv2rJY52smRFSshTJG0v4yfi3hS4pBqpb7xggb7OibK1lAv55W0Iwod/ZZJVFXEYBQU2zsPGhdZdzlP+PbLXmOT2pnY6F86M8iXLdiRWAgU6LhK6srMuCzL/42+oJkt4DXnVV5xT819DbTEVbV167zUwene9jytruahxFti4MFZ6qC12Od04t8ActtdjPXx6yec1jNarZ1aKyhympQnrzdgP5fmTPV44oqQZaPHpWv1UlT7+RSEBGwENUioETw5fI4jf4+omU3uLu5VNPbxxLAWVk3CsEUl9Pl8WyWbyaqyrurBRQozWCL76pA3flZzqHJlbyIxotnTDd3JN4n7jEpoWClCZpgrWfDkz7ZdHqtgd8TWu1DmJgM1a12JqqBKQpRSrYFG5lW0MxCJ/BIb4/T6Ek9/niTjptg9frad7ek1uCr5izSXxCtM6PNPXnh5Q3N+H/+AfflPQAzXzhQQz41IRgBKS+bVO8ICUcyvBtpfasTZGzxKNi2FUVhWap9ygn3giAd8RLwc8xR4J6oyOb/7RpKBvhGwFlYEKd9+SQM6yl9EhWiiFD10cSPErfLhzlsWk6mnu6z25dmUjSlUbRu+lpVRROx09qZxctYSyUE79iftCmQge74awtm39Fp3t0XyBmgfFg7deQMfT/lp/BgXtAbESZrhEJM1D1u1q5qg03IJX+mFUqbYHC4RjZ3rq8l1SOEIy3FvOtRHNUJhGugo7mcQY4navyW9sp/skKh0vKdmIkveIPiQJ0a946ACQ0Gm8022fjENV3JNFO6rHLyuUv1/AgVMskZ3vUf7YzManapIET5bb0CbZ0KDfUIq8hmmSfEG1wv05+MvVNXtORsnF4efPVBbPsQyE1HsqiENLJl6/C3nDdPXm2XPe2Ih2Ty/sQZGd3wfpkQp39g409aZ1PGRKvtudxaI8y2OYL1VFfhr2ER+YtQP0dtAIowOgj5LXRRK9TdkTzmf2uk3H5vAF5+JjAW+uGUB/JLf3tWE3Hz+ldMu7eLaakkaIv1J8JDOlKEcwMnLCfTi/MNyMlz9pBlbZ+LmB7PlMH9NGmkHbZ/AsKqDPbDl6iKBOeGHOpg0HUZqdc3UeN0OzzW1VayYDF8NTPqMStODt2kudUhlf8M0mJ0PDGmwlgjfoa6A7wUYihd97wkAUONjZEijcSfBgqsivR2ieIPQELHakbAGBtv9pkBEe2nWcijwnbQAAJlPrxIP/E85HF02Ue67fWTmV7JG2+FffnTN235y7B2LLjHxwDqNJKS80aULernfHWwm3PIi8y4Fx7jjyXk2Fhw7Iffuu5bAZquCIx0TAeBu8ijp6sa9FXWhQVqQ1kJd//JgDyji3lBUJChEZg25DYFj+X66bIqlE6murY9gPLcxK2fAsBCDV/F95wqUoRKXskgaIqOsorhtJnckAP4vbeoWhg5jiXkWGkmz5QPttbyOgqdJg/Upb8Qfh8FBBwKvT4Z3/eNMSbzB8AA+rVz60oTona4BrsIVpyu2n76t+N7nSdRErPGNDkCNsfgec1tImYRtdkjxWaJTJCnnpjIZdgGSEjL57ddlVF6iTSCUPMJSdw0PRwSJMPDNhJQwElmOTtyWqFE8/mQkdvn3Wf2D300R7Xfs8kzIzUAsxrsI1A6P1UuLVmXEUy7LP9CRa0fPDbvkehZnUM4aT8KuoxTD7DvRBINq8sroGBjXkA03mNz1pdjvw2Az8raf/J+8xLUlnjMiq6xpguX2rvrnsiq4/Uf3bAt8VjQm64Hc5/empmlWUCvLUdbzk/X1hV7kmAoXE2JLt0NGwSGeknlX6Q/tugb7TWOJNIBOTsUrpXr+8WsEC+ikV9LmZ9kKRShJLkd07uGQzW2Qxb3qR71FVhwE+pNx73Q2+6A79aoAfpD7E0HmT8ig7Cau2wW8bNSOic7fIPct+vbG5OBq/CSEmqhkur3mEiXyR6mdCuLRtW+FszkgEEO7AJY2530mpY=

Uh oh, looks like some arbitrary base64 encoded bytes. Not entirely arbitrary though, as the document says that all license requests/responses follow C++ structs. The documentation also calls license requests "SPC messages" (SPC = Server Playback Context), so that's what I'll call them for the rest of the post. Here's the SPC struct implemented with Construct in Python:

SPC = Struct(
    'version' / Int32ub,
    'reserved' / Int32ub,
    'iv' / Bytes(16),
    'encrypted_key' / Bytes(128),
    'cert_sha1_sum' / Bytes(20),
    'payload_length' / Int32ub,
    'payload' / Bytes(this.payload_length)
)

With names like encrypted_key and payload, it sure seems like the SPC is encrypted. And that it is. Before the FPS library even generates an SPC, it has to be given an "Application Certificate". The Application Certificate is a signed X.509 certificate with a 1024-bit RSA public key embedded inside that the SPC is encrypted against. Luckily, Apple provides a signed developer cert with a respective private key, as well as example SPCs that are encrypted against it. So, let's see what the parsed encrypted SPC looks like:

Container:
    version = 1
    reserved = 0
    iv = b"]\x16D\xea\xec\x11\xf9\x83\x14uA\xe4n\xeb't"
    encrypted_key = b'\x92fH\xb9\x86\x1e\xc0G\x1b\xa2\x17X\x85\x1c=\xda1\xc9;\x1d\xd6\x01\xaaN\xadD\x15\xa2\x07Y\xaa\xb9\xa6\xd8\x9fU\x13\x85\x85nsW\x17)\xdf/\x1dF\xd2\\\x13\xda*\xd7]\x00\xfd4\x13\xeb\xd9l\xa4}\x02\x95\\V\x9f\x7f\xab@\xf1\xa7\xfb#AAg\xa6S\xea\xbd\xf1\xad(=\xf5\xe0~|\xf4\xaa/\xba\xc6O\x1dF\x0f\xdf\x9a!\xee\xb2z\x7f`rxS\xa4\x14\xc1\xc4P\xc5%\xe8\xda\xb6\xa3\xf1<\xfaW\x17\x1a'
    cert_sha1_sum = b'Ua\x98\n\xa5\x1f\x03\xaa\x04\xbc\xa2EV\xc8A\xa7\x176\xc7q'
    payload_length = 2688
    payload = b'm\xa6\xda\xe4\xee@\t\x17T{\xba\xa5\'\xb8\x82\x1b\x9c`\x81\x93+\x0f\xcf3r\x914\xf5\xf7\xa8\xf1i\x02\x1c\x04\xda\x00\xe9csZt:\x88y\xd2\xca\xb7\xbaq`\'v\xcf\x1e\xa2\xd1\xd3\xc1m\xceQ\xb2\x82D\x9c\xecs\xa0\x8e\x17"\x80\xd3\x95\x90\x04*\xfeZ\xf9UO\x97<\xeb\xf6>\x08\xe4\x0c\xd0o\xcaw\xa0\x9a\x94\xcb\xba\xdb\x97E\xe7\xf5\xa5\xf4p!<\x93\xd8[\xa5\x11\x19\xe4Q\xa1\xc95.\xd2\nL\xd942\x85\x9al\x89\x85\xbay\xa6\x00\xa9\xc6&\xfd\x8a\xad|\x0b\x1f\xaeU%\x16\x86\xf2\xcb7~h\x06\xf4c\x13\x8b\xdf\x90H\x91\xa2dE\x8e\xca\x0c\xb2\x03\xb7`\xd8\xc5\xad!#\xd1\xbfi\xba\\\x9c{To\xff\xd3*\x9b\xc7\x02\xdda\xaao\x8f\xc1>y\x8c\x11D\xa2\x1e\x82\xc0t\xd9p-pBD\xd0\xec2\xe1\x926\xc4A\xc0\xb8\xd6\xa8\xa2\xf8\x05U\x82e\x9e\xb6\x16\x97t\x93x\xa9%mO\xfe\xb0\x90\xae*\xa1bNYZ\xb26tj\xdc\xa5w\xa6[`a_\x17\xee\x9cV(\x98\x8c\xea\xaco\x03\n4\xbc}\x0fY\xb2y\xa9\xa3\xede\x90\xd8\xdc\xa1\x18U\xb6[\xd9\xcb\xdc\xb1\x05\xa6\xbd\xd2\xcfSBh$\xab\xdb\'\x97I\xa1\xc4\x15L\xa5\xb2\x03\xf3b8\x1f\x98rZ\x87\xcd]:\xe2Fo\x93%\x9f\xb8"\x87\x14\x0b\xa0^\xe9\xe3\xeeA\xda\xb5\'\xcc\xee\x02\xcfe\x9e\xcb*\xbd\x98(%u\x90`\x03\x89\xdfp\xf6\x115f\xfac\xd6R\xd6n\xaf\xc2\xec9\x14\x10-\xa4\'\xb3\xdb\xc2[\x16zE\x19\x03J4nh\x9e\xa8\x0e\xbc\xa5q2,T\xbe\xe3D\x84zC\xf0\xdf\x12\x7f\x9e0h\xfc\xf0T\x1fN\x8e\xb3\x11\xfb=S\xdd!\xce\xfb\x19"\xfe,\xc71b\x9e\xcdMp\x91\xb9\xe6\x99\xa7~\xee\x1a\xc9\x00\xb9\xc9\xde\xf5\xbb\'\x19\x1fm5k{\x9c M\x97\x8c\xda\x9cO\x8e\xac\xe2G\n\x07\xcb\xd3\x06\xcc @\rr]<\x83Kds\xbe\x96X\x03TC\xf3\xc6n1/A+\x1e\xe7-q\x1cm\x1a\x13#\x0cY\r\xaca\xbd\xc4\x12*1\x83\xedO\xc89%=\xdf\x92\x87\xd0\r\x89\x96E\xb7\x07\x1b\xedE\xb0>\x12&\xd0\xc0q\x96\xb1Xu\xa7P\xb4!\xb5\xa1\xf2\xbe\xb2\xd2BX\x11\xbc\x0e)\xd0\xb2\xb3-\xab3\xf2\xc0\xeb\n6]\x10\x10O:,B\xdbq\xa9\xdb\xc4\x9b}\xd1\xa4]0\x80\xc5\xa3w\x19F\xe4\x98C\x0ev<b\'OX\x10\xf8Z\x81O\xe8\xa5UFER$\r\xd3\xea\xcd\xf4Ry\xde\xe18\xc9\x87\x98\xf0\xad0E\x97\xc9|\xb34\x8f\xf1\xa7\x97_\\\xc2\x83<\x87\xbb\x1e<\xa6\x00\x88\xafF&\xddy\xba\x08TJk<\xca]\xa5\xc39]\xbe\xc7\x1dh\xcf\x89\xcce\x98\xd2\xad\x11?\x18\xab\xf4\x17Y\'g\\\xb2\xb2\x82\xb1E\xb5\x18*\x02>\x08\xe40\x18\x83\xdee\xa1\xba\x92A9pffk\xda7\x1f{\\\x94\xde\xc9v\x92X}@a\x9ar\xaa\xfb&\x9bmh\xd3G!%%\xdf\xf4\x05\x88z\xe4C\x1bd6\xd9\x1f#\x00\xe6\xdf\xcc\x93%\x83\xc5\xda-\xe6\xd8V\xc9\xd4mh\xebD\xcf\xd22\xd1\x1d\x1e\x0f\xbb`]6~H\x03\xa9\xe7\x889\x99\xbd\\\xcb\x15\xf1\xd6@\xae\x08\t\xf2\xa0\x07\xf9\r\xdf\xbbL\x7f\xe5\xbd\xc4\xeb\x90\x1b\xddQ\xb9!B\xb7-\x12W\x94\x97\xa5M\xe4\xe9\xe1y\xcc\x89\xfb\xf9\x04!A\x07\x89O\xa1\xae\xc5\x94\xeb\xf1Af\xc4\x10^:w\x14)\xa0\xb4\x19\x19X\xea\x1ddS,[\x96\xab\x80\x9a\xf1\xcf2\x1f\xbe_\xea\xb8d\xbev\x11dl\xe1|\x96\x01\xa9\xb1\xf6M!,\xfa\xbd\xc4\x9d\x88\xe0T\xd0\xe6\x82l\rt0\'(\xa8k\xd1-N\xd9]\x9el\xa8\xa9W\xd4\x15\xf9\x87j|\x7f`+i\xc2\xe7\x8b\x0c\x89\xe9\x0f\xb8\r\x1e\xceI\x03\xcb\xf2\xa4\x97P\xf8u\xc4O\xee\x8f\xa5\xda\x94\xdf\xb3\xc1\x1fIl\xc8\rx,\x8c\x14D}\x17\x17\x7f`+\xb2\x02\x14S:\x89-W\x17\xc01\xea]k\xaa+\xbd\x80\xbf1}%\x0e\xb6\x00\xaaA\x83\xad\xba"\xd0+}\x89\xb7\xc1P^\xea\x0f\xa6\x13e\xe7\xd2w\x9du\xec9\xd2\xfc\x10#2Y9,\xc2\x10\x04\xdea5\xe7jh\x10\x02 \xff\xc2\xed\x85\xc4\x8cU\xca\xda\xa5\x8d@9E:Pl?}Y\xc8:\x82\x9b\xae\xbf\x85\xbd \x1a\x9e\x05\x8ab\x15pMF\xd4e\x92J\xfaY\xa5+}\x1b\x9f\x9bS\xfb6\xc8_\xe5=\xd5\x833\xb0\xfbs\x84\xaeb\x1d\x9bM7\xc1\n\x02T\x02\x8f\xef\xaa\x80G\x95\xc3\xd0C\xe7\xff\xfd\xda\x1b4W\x81k)\x9b\xf8\xd2\xa1\xea\xc2T\xff\xc3\xa4{\xbf\xd0\xb3\xba7\xf6M\xf1\xc2\xff{\xee\xb6\x15\x9a\xc8\x19/\xa1\xd2o\xda\xb2X\xe7k&DT\xac\x852F\xd2\xfe2~-\xe1K\x8aA\xaa\x96\xfb\xc6\x08\x1b\xec\xe8\x9b+Y@\xbf\x9eV\xd0\x8c(w\xf6Y%QW\x11\x80PSl\xec<h]e\xdc\xe5?\xe3\xdb-y\x8eOjgc\xa1|\xe8\xcf"\\\xb7bE` S\xa2\xe1+\xab+2\xe0\xb3/\xfe6\xfa\x82d\xb7\x80\xd7\x9dUy\xc5?5\xf46\xd3\x11V\xd5\xd7\xae\xf3S\x07\xa7{\xd8\xf2\xb6\xbb\x9a\x87\x11m\x8b\x83\x05g\xaa\x82\xd7c\x9d\xd3\x8b|\x01\xcbmv3\xd7\xc7\xac\x9esX\xcdj\xb6uh\xac\xa1\xcajP\x9e\xbc\xdd\x80\xfe_\x993\xd5\xe3\x8a*A\x96\x8f\x1e\x95\xaf\xd5IS\xef\xe4R\x10\x11\xb0\x10\xd5"\xa0D\xf0\xe5\xf28\x8d\xfe>\xa2e7\xb8\xbb\xb9T\xd3\xdb\xc7\x12\xc0YY7\n\xc1\x14\x97\xd3\xe5\xf1l\x96o&\xaa\xca\xbb\xab\x05\x14(\xcd`\x8b\xef\xaa@\xdd\xf9Y\xce\xa1\xc9\x95\xbc\x88\xc6\x8bgL7w$\xde\'\xee1)\xa1`\xa5\t\x9a`\xadg\xc3\x93>\xd9tz\xad\x81\xdf\x13Z\xedC\x98\x98\x0c\xd5\xadv&\xaa\x81)\nQJ\xb6\x05\x1b\x99V\xd0\xccB\'\xf0Ho\x8f\xd3\xe8I=\xfex\x93\x8e\x9b`\xf5\xfa\xdaw\xb7\xa4\xd6\xe0\xab\xe6,\xd2_\x10\xad3\xa3\xcd=y\xe1\xe5\r\xcd\xf8\x7f\xfe\x01\xf7\xe5=\x003_8PC>5!\x18\x01)/\x9bT\xef\x08\tG2\xbc\x1bi}\xab\x13dl\xf1(\xd8\xb6\x15EaY\xaa}\xca\t\xf7\x82 \x1d\xf1\x12\xf0s\xccQ\xe0\x9e\xa8\xc8\xe6\xff\xed\x1aJ\x06\xf8F\xc0YX\x10\xa7}\xf9$\x0c\xeb)}\x12\x15\xa2\x88P\xf5\xd1\xc4\x8f\x12\xb7\xcb\x879lZN\xa6\x9e\xee\xb3\xdb\x97fR4\xa5Q\xb4n\xfaZUE\x13\xb1\xd3\xda\x99\xc5\xcbXK%\x04\xef\xd8\x9f\xb4)\x90\x81\xee\xf8k\x0bf\xdf\xd1i\xde\xdd\x17\xc8\x19\xa0|X;u\xe4\x0c}?\xe5\xa7\xf0`^\xd0\x1b\x11&k\x84BL\xd4=n\xd6\xaej\x83M\xc8%\x7f\xa6\x15J\x9b`p\xb8F6w\xae\xaf%\xd5#\x84#-\xc5\xbc\xebQ\x1c\xd5\t\x84k\xa0\xa3\xb9\x9cA\x8e\'j\xfc\x96\xf6\xca\x7f\xb2B\xa1\xd2\xf2\x9d\x98\x89/x\x83\xe2@\x9d\x1a\xf7\x8e\x80\t\r\x06\x9b\xcd6\xd9\xf8\xc45]\xc94S\xba\xacr\xf2\xb9K\xf5\xfc\x08\x152\xc9\x19\xde\xf5\x1f\xed\x8c\xccjv\xa9 D\xf9m\xbd\x02m\x9d\n\r\xf5\x08\xab\xc8f\x99\'\xc4\x1b\\/\xd3\x9f\x8c\xbdSW\xb4\xe4l\x9c^\x1e|\xf5Al\xfb\x10\xc8MG\xb2\xa8\x844\xb2e\xeb\xf0\xb7\x9c7O^m\x97=\xed\x88\x87d\xf2\xfe\xc4\x19\x19\xdd\xf0~\x99\x10\xa7\x7f`\xe3OZgS\xc6D\xab\xed\xb9\xdcZ#\xcc\xb69\x82\xf5TW\xe1\xafa\x11\xf9\x8bP?Gm\x00\x8a0:\x08\xf9-tQ+\xd4\xdd\x91<\xe6\x7fk\xa4\xdc~o\x00^~&0\x16\xfa\xe1\x94\x07\xf2K\x7f{V\x13q\xf3\xfaWL\xbb\xb7\x8bi\xa9$h\x8b\xf5\'\xc2C:R\x84s\x03\',\'\xd3\x8b\xf3\r\xc8\xc9s\xf6\x90em\x9f\x8b\x98\x1e\xcf\x94\xc1\xfd4i\xa4\x1d\xb6\x7f\x02\xc2\xaa\x0c\xf6\xc3\x97\xa8\x8a\x04\xe7\x86\x1c\xea`\xd0u\x19\xa9\xd77Q\xe3t;<\xd6\xd5V\xb2`1|53\xea1+N\x0e\xdd\xa4\xb9\xd5!\x95\xff\x0c\xd2bt<1\xa6\xc2X#~\x86\xba\x03\xbc\x14b(]\xf7\xbc$\x01C\x8d\x8d\x91"\x8d\xc4\x9f\x06\n\xac\x8a\xf4v\x89\xe2\x0f@B\xc7jF\xc0\x18\x1bo\xf6\x99\x01\x11\xed\xa7Y\xc8\xa3\xc2v\xd0\x00\x02e>\xbcH?\xf1<\xe4qt\xd9G\xba\xed\xf5\x93\x99^\xc9\x1bo\x85}\xf9\xd37m\xf9\xcb\xb0v,\xb8\xc7\xc7\x00\xea4\x92\x92\xf3F\x94-\xea\xe7|u\xb0\x9bs\xc8\x8b\xcc\xb8\x17\x1e\xe3\x8f%\xe4\xd8Xp\xec\x87\xdf\xba\xee[\x01\x9a\xae\x08\x8ctL\x07\x81\xbb\xc8\xa3\xa7\xab\x1a\xf4U\xd6\x85\x05jCY\tw\xff\xc9\x80<\xa3\x8byAP\x90\xa1\x11\x986\xe46\x05\x8f\xe5\xfa\xe9\xb2*\x94N\xa6\xba\xb6=\x80\xf2\xdc\xc4\xad\x9f\x02\xc0B\r_\xc5\xf7\x9c*R\x84J^\xc9 h\x8a\x8e\xb2\x8a\xe1\xb4\x99\xdc\x90\x03\xf8\xbd\xb7\xa8Z\x189\x8e%\xe4Xi&\xcf\x94\x0f\xb6\xd6\xf2:\n\x9d&\x0f\xd4\xa5\xbf\x10~\x1f\x05\x04\x1c\n\xbd>\x19\xdf\xf7\x8d1&\xf3\x07\xc0\x00\xfa\xb5s\xebJ\x13\xa2v\xb8\x06\xbb\x08V\x9c\xae\xda~\xfa\xb7\xe3{\x9d\'Q\x12\xb3\xc649\x026\xc7\xe0y\xcdm"f\x11\xb5\xd9#\xc5f\x89L\x90\xa7\x9e\x98\xc8e\xd8\x06HH\xcb\xe7\xb7]\x95Qz\x894\x82P\xf3\tI\xdc4=\x1c\x12$\xc3\xc36\x12P\xc0If9;rZ\xa1D\xf3\xf9\x90\x91\xdb\xe7\xddg\xf6\x0f}4G\xb5\xdf\xb3\xc93#5\x00\xb3\x1a\xec#P:?U.-Y\x97\x11L\xbb,\xffBE\xad\x1f<6\xef\x91\xe8Y\x9dC8i?\n\xba\x8cS\x0f\xb0\xefD\x12\r\xab\xcb+\xa0`c^@4\xdecs\xd6\x97c\xbf\r\x80\xcf\xca\xda\x7f\xf2~\xf3\x12\xd4\x96x\xcc\x8a\xae\xb1\xa6\x0b\x97\xda\xbb\xeb\x9e\xc8\xaa\xe3\xf5\x1f\xdd\xb0-\xf1X\xd0\x9b\xae\x07s\x9f\xde\x9a\x99\xa5Y@\xaf-G[\xceO\xd7\xd6\x15{\x92`(\\M\x89.\xdd\r\x1b\x04\x86zI\xe5_\xa4?\xb6\xe8\x1b\xed5\x8e$\xd2\x019;\x14\xae\x95\xeb\xfb\xc5\xac\x10/\xa2\x91_K\x99\x9fd)\x14\xa1$\xb9\x1d\xd3\xbb\x86C5\xb6C\x16\xf7\xa9\x1e\xf5\x15Xp\x13\xeaM\xc7\xbd\xd0\xdb\xee\x80\xef\xd6\xa8\x01\xfaC\xecM\x07\x99?"\x83\xb0\x9a\xbbl\x16\xf1\xb3R:\';|\x83\xdc\xb7\xeb\xdb\x1b\x93\x81\xab\xf0\x92\x12j\xa1\x92\xea\xf7\x98H\x97\xc9\x1e\xa6t+\x8bF\xd5\xbe\x16\xcc\xe4\x80A\x0e\xec\x02X\xdb\x9d\xf4\x9a\x96'

The SPC looks pretty standard. The payload seems to be AES encrypted by a key that is in the SPC under encrypted_key. The documentation confirms that key is then encrypted RSA-OAEP against the application cert. Since the SPC is encrypted against a cert we have the private key to, let's decrypt it. But before we do, let's define the struct for the decrypted payload so we can jump straight to parsing it. SPC and CKC (Content Key Context: fancy term for license) payloads are composed of what's called TLLV (Tag Length Length Value: similar to ASN.1 (tag length value) blocks) blocks. Those blocks each contain an 8-byte tag used for identifying what kind of block it is, a 4-byte integer that tells how long the entire block is, a 4-byte integer that tells how long the value of the block is, and padding to make the block length a multiple of 16. The padding isn't standard; it's just random bytes. Padding is calculated by subtracting the block length by the value length. Here's the struct for a TLLV block:

TLLV = GreedyRange(Struct(
    'tag' / Bytes(8),
    Embedded(Switch(this.tag, {
        b'\x3D\x1A\x10\xB8\xBF\xFA\xC2\xEC': SK_R1,
        b'\x58\xB3\x81\x65\xAF\x0E\x3D\x5A': ENCRYPTED_CK,
    }, default=RAW_TLLV_BLOCK))
))

And a RAW_TLLV_BLOCK:

RAW_TLLV_BLOCK = Struct(
    'tag_name' / Switch(this._.tag, {
        # SPC Tags
        b'\xB3\x49\xD4\x80\x9E\x91\x06\x87': Computed('SK_R1_INTEGRITY'),
        b'\x89\xC9\x0F\x12\x20\x41\x06\xB2': Computed('AR_SEED'),
        b'\x71\xB5\x59\x5A\xC1\x52\x11\x33': Computed('R2'),
        b'\x19\xF9\xD4\xE5\xAB\x76\x09\xCB': Computed('RETURN_REQUEST'),
        b'\x1B\xF7\xF5\x3F\x5D\x5D\x5A\x1F': Computed('ASSET_ID'),
        b'\x47\xAA\x7A\xD3\x44\x05\x77\xDE': Computed('TRANSACTION_ID'),
        b'\x67\xB8\xFB\x79\xEC\xCE\x1A\x13': Computed('PROTOCOL_VERSIONS_SUPPORTED'),
        b'\x5D\x81\xBC\xBC\xC7\xF6\x17\x03': Computed('PROTOCOL_VERSION_USED'),
        b'\xAB\xB0\x25\x6A\x31\x84\x39\x74': Computed('STREAMING_INDICATOR'),
        b'\xEB\x8E\xFD\xF2\xB2\x5A\xB3\xA0': Computed('MEDIA_PLAYBACK_STATE'),

        # CKC Tags
        b'\x47\xAC\xF6\xA4\x18\xCD\x09\x1A': Computed('CONTENT_KEY_DURATION'),
        b'\x2E\x52\xF1\x53\x0D\x8D\xDB\x4A': Computed('HDCP_ENFORCEMENT')
    }, default=Computed('unknown')),
    'block_length' / Int32ub,
    'value_length' / Int32ub,
    'value' / Bytes(this.value_length),
    'padding' / Bytes(this.block_length - this.value_length)
)

In Construct, Computed refers to something that isn't in the stream, it's just for the parser. i.e., I'm using Computed to determine what kind of tag it is by a string instead of checking the arbitrary 8 bytes.

Those tag names look weird, I know, but the documentation has a table with all the tag names and their byte values, so I just ported that over:

FPS Documentation

Before we define the functions of each tag, let's now take a look at the parsed decrypted payload:

ListContainer:
    Container:
        tag = b'\xb3I\xd4\x80\x9e\x91\x06\x87'
        tag_name = SK_R1_INTEGRITY
        block_length = 64
        value_length = 16
        value = b'T\xa1k\xe0\x13~\xf2Y\xab>O\xc7\x96\x90\x82_'
        padding = b'X\xaa6J\x88[\xbb\x15=8\xeb\xddF\xe4oJ\x97\xe92z\xf9\x9b\xe2\xa6\x83\xde*\x19\xba\xebT%\x1a\xf4\x8b\x8d\x95;d\xbf[\xf8}\x01A%a\xdd'
    Container:
        tag = b'\xf9\x11\xf0M\xa5K\xf5\x99'
        tag_name = unknown
        block_length = 128
        value_length = 121
        value = b'\xd9\xa2\xd8N\xbf\x8e\x07\xfaO\x890&\x1f\xf9dcCvk|\xe0\\8\x88\x89\xa8\xed\xd8\rKCRj#\x93)\x1ec\x03\xef\x14\x8a\x13/-\xdex\xcdt\x073\xb7\xb0\r\\wPY\xd6\x8b51\xbeU@0\xbe\x013\x84\xff\x122\xc84\xb1\xa3\xe6\x17\x9bW\x0bn\x9e\x1d\x86\x9fu\xe3\xd1\xe6[\x10E\x1ae`\xea\xd4\xf7\xa2v\xee\xe4\x9e\xf2\xff\x0e\xefMa\x1e+\x8d\x9dnpf\xf1Ms'
        padding = b'r\x0fr\\\x1a\x08G'
    Container:
        tag = b'\xba\x08\xcct\xda\xc9\x17m'
        tag_name = unknown
        block_length = 32
        value_length = 25
        value = b'\xfbJv\x13\xe3\xf8H;\xd0\x1e\x1a\xae\x7f\\\xe0\x90,\x00\xc8\xcbg[b$\x85'
        padding = b'I\xd4\xe2\x12gh\xea'
    Container:
        tag = b'=\x1a\x10\xb8\xbf\xfa\xc2\xec'
        tag_name = SK_R1
        block_length = 256
        value_length = 112
        iv = b'OE\xd8\\\xe2bs\x10\x1a\x97\xf30\x81\xc1\xd0J'
        payload = b'\x93\xb2\xdd\x03U\xe3cr\x9d\x92\xa4ZE\xce\x8d%\x8b\x0c\x08\xaae\x1c\td\x97k\xf0\x94M(%\xf3\xac\x8d\xde~\xd21O\xa0\xef?\xb4[\x97\xa2&\xe8\xc56m\xef\xe5\xf1\xe1+\xd7\xb7!\x98\xa4\xa8\xf2e:\x0e\xf0\xde\x8c7\xa4|<@\xf0\x12\xe1\\\x8bY=\xf1-K\x01`:\x975~j\xe0\xa1\x1c\xa3\xe3'
        padding = b'\xbax\xb2\xde"\xa2\x9d\xbc\xffz\xf3Ni\xbeE\xd7\xae<\xfa\x15V\x80h\x06\xc0\x88\x94g\xc3\x066)f\xe6\xcc\n\xf3k\x91,\x85"\x0cy\x13\xb6\x97\x87\xda\x98\xca\xa6Z\xddA#\xd1\x81iMK\x9e\xea\x04A\xfabQr \xf6\x1e%\xe1KZ\x10\xc3\x9eV\x85\x8a\xe86\xdc.\xb1o\xd5\x90\x12m\xe8\xdf\xc3\xaay\x954wa\r\xde\x90\xc5\x066&R\xe4\xb0G\xc3b?\x95\x1b\xc5\xccLj\xf9\x16\xb7\x84\xca\xb5\xed\xd2\xe3\xb1\xdb\xbbg(\xdc\xef6@\xedh}\xbf\xd4'
    Container:
        tag = b'\xc1\xcd\xe5\x9b\x14b\x9a*'
        tag_name = unknown
        block_length = 32
        value_length = 1
        value = b'B'
        padding = b"'\xcaA}\xbdV\xe6\x1d\xdf\xb8B\x92\xb0\xf5H\xd2\xa8\x19\xf2\xa9|\xf4T\xfa\xa2\x14\xd1$\x87\xb9\xf4"
    Container:
        tag = b'\x85$(\x9du\xb9\xe9\x0e'
        tag_name = unknown
        block_length = 48
        value_length = 1
        value = b'w'
        padding = b'\n`3`\x83q\xe78\x07\xa0J\xa1"\xd1Sbo\xd1y\xc2\x90X\xa3\xfd\xc7=\tNG_\x96K\xa7j\x0f\xdb\x1e\xed\xdf\n\xfaL\xdf\xa8U\xafX'
    Container:
        tag = b'\x89\xc9\x0f\x12 A\x06\xb2'
        tag_name = AR_SEED
        block_length = 208
        value_length = 16
        value = b"\xf3\xc6\x9d\x1e\x8c\xc4'Zm2\x86\xd32a>\x13"
        padding = b'C\xee=\xe0\xd4\x04\xab\xf46\xea\xaf-\xe2\xa3\xfc\x07\x98\x94Xai\x96$\'W\xea\x9dO\xdd\xa23\xafll\x81Q+"\x15w\xa3\xc5Q\x97aT\xf3v\xa6|\x902M*\xe7\xa1\x055\x81b\xc9\xe0\xe8\xe5\xf8\xa2\x08A\x7f\xe94\'>\x9d\x9foU\xe9\xf0$E\xbc\xfe(\x7fNm\x81\xd9\xcfY\x0b\x86(\xbd\x0bw\xc1}wR\x8dV@f\xe8\t\xe9\x13\x84\xa1\xa7to\x1dT\xd9\xb7\xbb\xd1\x17\xaf\x9bt\xed\xd7,n\xf5}\x18 \xd0W\xa1\xb2\xfdz\xd8\x9eEUD\xc10\x03\x87T\xe3\xdcWN\x03a\x91\xbf#\x80\xfda~\xfcx\xb4t\xcf\xa2\xed=\x07\xc9l\xa7?2\xfd\x90I\xa7\x9e?P\xcd\x8a\xd4{\x83l%\x9b\xb5F'
    Container:
        tag = b'\x13\r\x99L\xb8\x94\xb9\xe3'
        tag_name = unknown
        block_length = 32
        value_length = 19
        value = b'\xa5\x13T\xe5\x1a\xa0[R\xdf\x0ck\xa5\x0f\x94\x8d\\e\xf56'
        padding = b'\xe2\xddh\xd085\xcd/\xabj\xbdU\xfe'
    Container:
        tag = b'\x9b}>\xc9L\xd3\xce\x82'
        tag_name = unknown
        block_length = 32
        value_length = 19
        value = b'\xc0\x9d\x91 \xe5\x11\x11\xbd\xa2\xde\xdd0\xcf\t\xce\x178;\xf8'
        padding = b'$\xc5\x9e\x94\xf6\xf24\xea\xf7\x80\xb6\x91\x01'
    Container:
        tag = b'\xb7\xe3\x9a\x12\xd2\x1b-\xb4'
        tag_name = unknown
        block_length = 48
        value_length = 19
        value = b'o\xc3)\x9ae\xcc\xe8F\x8c\xac\xd1\xff1&\x0caOYP'
        padding = b'^Ip:E\xab&\xf06\x03\xdd\xffF(p\xf3\xa6:\xe3\x05\xb7\xb5\xbb\xd2\x06V?j}'
    Container:
        tag = b'q\xb5YZ\xc1R\x113'
        tag_name = R2
        block_length = 176
        value_length = 21
        value = b'\x11\xf7\xbea,\xa9^\xf5\xe0\x07\xceQ\x89j\xe4P,\xa3\xd8\x80\x1b'
        padding = b"_\x99\x19j\xcep\xda1S<g\x88\xa1\x9a\x1e\xa1\xa1\x90\x0e\x9dR\xd0\x92\x8a\xcf\x10 7\xe8Y\x18\xab\x96\x84\xebq\x1ap\x06\x80\xca\x83\x9c\x99\xb5C\x08\x12\xac\xab\x05\xc2\xe9YR4\x1f\xe7:\x0f\xeet\x0bW\xb6\x136Nq\xbfX\x1fN(\x9d?\xa9\x98\x92\x9c'\xde\xc3\xe4\xdeX\xbb\xccG-\xb9\x11;\xfe\xbb\x1a9\xfa\x1f\x1c\x99<\xb0\xd0\xb4*\x978]1\xb0\xc6\xe5\xf9|\x18\x02\\\xbbmjG\xf3\xb8J\x0e\xd1\xdd\xa5*O\x80\xbbt%\x0f\xb8\x7f\\+\xfc)/\x94!\xbf\x0eC6\xae\x07\x07\xe5\x17\xbc"
    Container:
        tag = b'v\xc1\x9e\xc7\x17%\xc7\x14'
        tag_name = unknown
        block_length = 80
        value_length = 38
        value = b'\x07\x17\x05\xd8\xcb\xeb0.\xc2\x01\x16\xeanQ\xc7\xf9"\xef%x\x08O\xb1\xbaGC8e\xb0\xfa\x98\xee?\xb3\x84\xb3,!'
        padding = b'Q\x1f\x90\t\xca\xbd\xeeY\xb2YT\xdd\xc3\xeaN\xa2\x06\x9c,\xaf\xc9\xce\xfcCsu\xee\xc9\xf4\xa2\xfc2$O\xbf\x90;\x0c7\xa4\xcb\x06'
    Container:
        tag = b'\x1b\xf7\xf5?]]Z\x1f'
        tag_name = ASSET_ID
        block_length = 128
        value_length = 18
        value = b'\xaa\xbb\xcc\xdd\xee\xff\xaa\xbb\xcc\xdd\xee\xff\xaa\xbb\xcc\xdd\xee\xff'
        padding = b"rU6>)\xd1RSE\xab\xf4\xc5\x88\xa1\xf7\xa4\x02\xa5w\x17\xe1\x96\xae\xc84\xf2\x93=\xb5\xfc$X\xc7\x94\x1b\xbeso\xa9\xda\xa27\xd21#\x8f\xf8\xe2\x13\x9f\xbcy\xbb\xd3\xf5u]i0\xa6\r\x8ahmk\x11\xef\xacg\x0f\xd0\xf0\x12\xa05\xbd\xba\x08\xb1\x86\x8b(\xc6'\xa8\x83T\xb4Z\xbfAxO\xb22\x1ea\x85\xe6\xfaV\x8cGn\x90\xaf\xb9\x15\xb6\xff"
    Container:
        tag = b'f\xc8#\xf3y\xb8{\xb5'
        tag_name = unknown
        block_length = 48
        value_length = 6
        value = b'\xc9\x9f\xb9\x1d%}'
        padding = b'\xdf\xb6\x80\x98\xd5z\xe2\xe0\xc9\xa5 m\x1bU\xb4\x9f\xe5\xd8D\x97_\x1dL\xee\x96\xd4dc\xe4\xe4\xadM\x0f\x9byR\x83\xa4\xf1w\xf3+'
    Container:
        tag = b'\x18\xd4,_\x8eTZK'
        tag_name = unknown
        block_length = 16
        value_length = 6
        value = b'\x96\xf8\xb2\xc4\xf7\xa4'
        padding = b'&\n\x13$6\xf0\x93\xa7\x054'
    Container:
        tag = b'G\xaaz\xd3D\x05w\xde'
        tag_name = TRANSACTION_ID
        block_length = 112
        value_length = 8
        value = b'\x14s\xe5\xccS\xe1\xe5\xd6'
        padding = b'\x01x\xe7\xe8\x9b\xc6[y\xe7\xf9-&\x11\xbdKW\x91Z4[O\x07\xd3\xcb\xc60\xf6\xd5o7l\xe0\xfb\xc6\x05\xc9\x93\x1a\x0c\x91\xe4\x00\x01\xf7\xde\x0cr-R\xe8M\xc6\x86\x82\x94x;I\xa9\xfa\x98G\x94\x04!\x8d1\xd0\n\xeb\xbd2\x10\xab/\x0cO\xb9\x1cf\x08Ev\xa6\x0fe\x08s\xbf\xd1\x17\xa51\xe2M\xe8\xa1\xd7\x8fE\x1b\x85\xb6\x1f'
    Container:
        tag = b'\x80\xfc\xc5(^\xd8\xcb\xae'
        tag_name = unknown
        block_length = 80
        value_length = 48
        value = b'a\xf31V\x1c\xbe\x7f\xccL(5\xe6O\x8a\xf8\xe0I\xf02\xbf\xee\xdd_\x7f\xc7\xe7\x94?\xe9.\xa5\xc9\xfaKG\r\xd1\xaf\xc5\xab\x1a\x19\x9b\xbc\xf2\xe3c\xe9'
        padding = b'\x02\x85g\x98\xea\xc8\xec\xfe\xe4\xee9\xb4P|\xfeE\xa2\t\x7f\xac\xb5P\xf2\xda!J:Q3\xe4\xb5\x19'
    Container:
        tag = b']\x81\xbc\xbc\xc7\xf6\x17\x03'
        tag_name = PROTOCOL_VERSION_USED
        block_length = 192
        value_length = 4
        value = b'\x00\x00\x00\x01'
        padding = b'R\x90,\xf9\xeeq\xe0i\xc5*<\x9a8\x87\x9bV#\x01\x1b\x18\xf7$0\x15\xbe\xe6A\xb9q\x14{\x01<\xa8P\x7f\x07Xq\x9b\r\x8f\xac\xc0 \xec\xdd\xbb\xda\x88\xc1\xfa\x11[\x9b\xac\t\xc0\x86\x9f\x9acX\xf2\x85\xde\x80\xb8\x12\x84\t$?XdU$\xbb5A\xaf\xc1W\xf2\xdc\xc8\x03\x86\xb7"\xa7\x02\xff\xc8\xbf8\x87\x04\xeb\x93V\xef\xb4\xb6^C\\|\r\n\x96\x9b\xfawxEm\xf1y\x94\xfc\x16\xbe\xcd\xe1\xa6o+f\xf3m.&\x17PK+\x135\x8bc\x19\xbb\x1bN\xc9\xe3z\xd8\x9f2`\xa6\xa8+\\\x1e#\xdc\xe8\x9fTu\xe7\x83\xce\xbc\x89\x99j\xa8"\x01zO\t\xa2%\x89|{\xb5\xd5\x8d\xb2\xe5Wi'
    Container:
        tag = b'\xfeq\xba\xef\x123ss'
        tag_name = unknown
        block_length = 48
        value_length = 14
        value = b'\x01/+\x02\xb76/\xd2\x81\xca\xf3\x9b\xd12'
        padding = b"8\xd2N=q\x81\x89\xb4Ak\xf3P'(<m\xea\xed\x87\xe2\x15\xb0Dw\x8a\rq\x85#\xf1\x14z\x12\xf3"
    Container:
        tag = b'g\xb8\xfby\xec\xce\x1a\x13'
        tag_name = PROTOCOL_VERSIONS_SUPPORTED
        block_length = 160
        value_length = 4
        value = b'\x00\x00\x00\x01'
        padding = b'\xb0r\x9c_\xf6\xd1-\xa7\x8e\x962$I{\xcb\x84+\xd2>\xbb\xcf\xe0r\xf1\xabz\xd7\x84\x90\x15+!\xdbY\xf9\xf4KCX\xee\xb6\xbfvO\\\x96|\xa2\xf10&8dYp+l\\1\xfb\xee\x06^q]\xe4\xe7\x9a\xaf\xbe\rX\xdfH\x06\xb7\xb4l\xc2@g8"\x87\xfcs\x91\xdcM\xc1\xfch]\xc5\xec\x08E-\x18\x1fC|\xb9\xe6B\xe1\xa3n\x19\xb6\x89tH\x8f\x0b6\x86ah{T\x0eMQe\x9fA3\xbe\x96\x85\x83H\x99U\n"\xe8=\xa1:6\xd3\xca\xd3\xb0e%\xcd\x95\xc1\xde\xc1\x9f\xf8?'
    Container:
        tag = b'\xe0?v+\xa8L\xc8\xe0'
        tag_name = unknown
        block_length = 16
        value_length = 0
        value = b''
        padding = b'\xb3\x8b\xaa\xda\x9dt\xfai\xd5\x93\xad\x86\xef\xd3n\x1e'
    Container:
        tag = b'\x90\xac\x15=\xac\xff\x04\x16'
        tag_name = unknown
        block_length = 80
        value_length = 16
        value = b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\n'
        padding = b'\xe4\x8c\x01\x8bw\x0c\xe5@R\x07\x86\xc6\xef\x82\xd1\x0f7I\xa5\x88\x98\xca}\x04z\xfa1\xfb\n\x1eK2\xa6\xab\x9f\xe6Oc\xb3f\xd4\xf4v\x8a\x0f\xec\xe9s\xbf\xd1u\xa5\xecnM\xb7i\xd7\x19u\xed\xe2\xb6\x18'
    Container:
        tag = b'\xe1\xfcM(\xc0\xd7\xcb\xe5'
        tag_name = unknown
        block_length = 32
        value_length = 1
        value = b'a'
        padding = b'^1i\x97\xbe\x92\x08LV\x18\xe6M\x8au3IH2\x83-\xdd\xdd"\xc7\x91W?z.p\xc3'
    Container:
        tag = b'\x82\xc2\xa5\x9e\x98\x9c\xd8\x17'
        tag_name = unknown
        block_length = 64
        value_length = 16
        value = b'1\x9f\xceg\x17\x8d\x11?\xdf\xb9\x8f\xcc\xe6c\xbf\xfc'
        padding = b'Y\xc7\xeb\xf1\x8a\xf5\x0f\xd7Y\xa1\xd3\xc42\x7f\x80t\xe8q\xf5<)W;5L\x9bK-\x10O\x15V\x82\xb1\x13\x15\x99\x90\xf3\xbe\xee\x88bi\xcf*6['
    Container:
        tag = b'\x19\xf9\xd4\xe5\xabv\t\xcb'
        tag_name = RETURN_REQUEST
        block_length = 96
        value_length = 56
        value = b'\x1b\xf7\xf5?]]Z\x1fG\xaaz\xd3D\x05w\xde\xf9\x11\xf0M\xa5K\xf5\x99\xba\x08\xcct\xda\xc9\x17m\x13\r\x99L\xb8\x94\xb9\xe3f\xc8#\xf3y\xb8{\xb5\x18\xd4,_\x8eTZK'
        padding = b'q\xc8O\x97\xc0q\xa7C\xff\x9b\xbf\x95n\x04\xf4TV\xf9\x89\xb6sC\xb5\xf1\r\xfc\x96\xc0\xd6w\nkmf\x14\x94\x8b\x16\x95d'
    Container:
        tag = b'\xda\xaa\x02\xb9B\x14Y\x01'
        tag_name = unknown
        block_length = 64
        value_length = 26
        value = b'y\xcf\xd6\x8eji\xdaU\xfd\xdb\xc5\t\xf4G.W\x19\xed\xc6\xb57\x1d\x88\xa1\xf0%'
        padding = b'\x14\x10$\xd8\x9d\xf4h\xb4\xf1T+@tQ\x8b^i3\x9e\xb1b\xfc\xfe\x90\xba3\x103\x99A\xe2\x07\x84\xb3\xd7(\xd1\xab'

As you can see, it's a lot of blocks. Some of the tags aren't even defined in the documentation. The important ones are SK_R1, R2, and AR_SEED for reasons I'll discuss later. So what does a license server do with this information and how does it form a CKC? Before we talk about that, you may notice that there is no asymmetric public key or signature anywhere in the SPC payload, which is where FPS's interesting security theory comes in. Besides encrypting the SPC payload against the application cert, there is no asymmetric crypto involved in FPS. This could be considered a flaw, but it all depends on what FPS did to replace the need for asymmetric crypto. The documentation outlines how a license server processes an SPC and forms a CKC. What it does is it parses the SPC for the SK_R1 block and the AR_SEED block. The SK_R1 block in an SPC contains the SK (session key) and R1 (random 1) values, hence the name SK_R1. However, it's encrypted. If it weren't, it would be far too easy to break FPS.

Two things aren't provided in the FPS SDK: The "D Function" and an example ASk (application secret key). Those are provided in the deployment package which is only given to partners. Those two things are the core of FPS security. The SK_R1 block is encrypted against a key called the DASk (derived application secret key) which is computed from the ASk using the D Function. Now, this is where I'm stumped. How does the client get the ASk? It needs it to derive the DASk used for SK_R1 encryption. My theory is that it's in the application certificate, as there are two unknown ASN.1 tags with variable lengths. Figuring out DASk and ASk derivation are the only two things that prevent me from making a fully fledged FPS client. Well, I lied. I implemented DASk derivation since a kind soul leaked the function from the deployment package onto GitHub. It's still useless without ASk derivation, though. For testing, Apple provides an example DASk that the example SPCs are encrypted against, which is good. The example DASk is d87ce7a26081de2e8eb8acef3a6dc179. For completion, though, I'll outline DASk derivation. There's a tag in the SPC called R2, which is a random 21-byte value and the base for DASk derivation. A custom hash is calculated from the R2 value, and then the resulting hash is encrypted AES ECB using the ASk as the AES key. The resulting encrypted bytes are the DASk. Here's an implementation of the R2 hash function in Python:

def DeriveR2Hash(R2):
    pad = bytearray(64)
    PRIME = 813416437

    if len(R2) == 0:
        return 0

    for i in range(len(R2)):
        pad[i] = R2[i]

    pad[len(R2)] = 0x80

    MBlock = []
    for i in range(14):
        MBlock.append(
            (pad[4 * i] << 24) ^ 
            (pad[4 * i + 1] << 16) ^ 
            (pad[4 * i + 2] << 8) ^ 
            (pad[4 * i + 3])
        )

    for i in range(1, 7):
        MBlock[0] = uint32(MBlock[0])
        MBlock[0] += MBlock[i]

    MBlock[1] = 0
    for i in range(7):
        MBlock[1] = uint32(MBlock[1])
        MBlock[1] += MBlock[i+7]

    for i in range(2):
        for r in range(16):
            if MBlock[i] & 1 == 1:
                MBlock[i] >>= 1
            else:
                MBlock[i] = (3 * MBlock[i] + 1) % PRIME

    for i in range(4):
        pad[56+i] = uint8(MBlock[0] >> (8 * i))
        pad[60+i] = uint8(MBlock[1] >> (8 * i))

    return hashlib.sha1(bytes(pad)).digest()[:16]

Very complicated. FPS's security lies not in asymmetric crypto (well, partly since SPC is encrypted asymmetrically), but in derivation. I think this is so because it is mathematically impossible to white-box an asymmetric key. If you are unaware, white-boxing is a concept in which a symmetric key is obfuscated to the point where an attacker who has full control over the system they are trying to reverse engineer cannot deobfuscate. Note that it can only be applied to symmetric keys. By keeping everything symmetric, FPS can safely rely on white-boxing to obfuscate important keys, which is a brilliant tactic and I do laud them for it. However, if one were to figure out the ASk and DASk derivation, FPS's security would be broken. That's why it's both risky and rewarding. FPS doesn't have to worry about the near-impossible task of obfuscating an asymmetric private key, but they do have to worry about keeping their derivation functions obfuscated. With that out of the way, let's decrypt the SK_R1 block and look at its structure. The encrypted SK_R1 TLLV block has a special structure, slightly different than other TLLV blocks. Its struct is as follows:

SK_R1 = Struct(
    'tag_name' / Computed('SK_R1'),
    'block_length' / Int32ub,
    'value_length' / Int32ub,
    'iv' / Bytes(16),
    'payload' / Bytes(96),
    'padding' / Bytes(this.block_length - this.value_length)
)

The SK_R1 block is encrypted AES CBC with the DASk as the AES key and the IV as the iv value in the struct. After decrypting the payload, you have another struct. The decrypted SK_R1 payload struct is:

DECRYPTED_SK_R1 = Struct(
    'SK' / Bytes(16),
    'HU' / Bytes(20),
    'R1' / Bytes(44),
    'integrity' / Bytes(16)
)

The SK value is used for encrypting the content keys in a CKC. The HU value is the device ID of the device that made the SPC. The R1 value is used in deriving the CKC encryption/decryption key. The integrity value is used as a pseudo-signature for verifying the integrity of the SPC message as a whole. Remember how I said the SK_R1, R2, and AR_SEED TLLV blocks are important? Now that you know the purpose of the SK_R1 and R2 blocks let's go over how a CKC is structured and encrypted.

CKC = Struct(
    'version' / Int32ub,
    'reserved' / Int32ub,
    'iv' / Bytes(16),
    'payload_length' / Int32ub,
    'payload' / Bytes(this.payload_length)
)

The CKC is encrypted against an AES CBC key derived from the AR_SEED and R1 blocks from the SPC. Apple provides an example CKCs paired with the example SPCs so that we can decrypt the CKC. Before we do that, I'll go over CKC encryption key derivation, which is documented. Here's a small Python snippet I wrote for CKC decryption:

ar_key = hashlib.sha1(parsed_sk_r1_block.R1).digest()[:16]
aes_cipher = AES.new(ar_key, AES.MODE_ECB)
ckc_key = aes_cipher.encrypt(ar_seed)

aes_cipher = AES.new(ckc_key, AES.MODE_CBC, iv=license.iv)
decrypted_payload = aes_cipher.decrypt(license.payload)
ckc_blocks = TLLV.parse(decrypted_payload)

ar_seed is the value of the AR_SEED TLLV block from the SPC. As you can see, the derivation is done by first truncating the SHA1 hash of the R1 value from the decrypted SK_R1 payload to 16 bytes, and then encrypting the ar_seed with that resulting hash in AES ECB. The resulting encrypted bytes are the key used to encrypt/decrypt the CKC payload. The decrypted CKC payload is also a bunch of TLLV blocks. Here's a parsed decrypted CKC payload:

ListContainer:
    Container:
        tag = b'X\xb3\x81e\xaf\x0e=Z'
        tag_name = ENCRYPTED_CK
        block_length = 32
        value_length = 32
        iv = b'\xd5\xfb\xd6\xb8.\xd9>N\xf9\x8a\xe4\t1\xee3\xb7'
        encrypted_ck = b'=VC\x97\x87\x8bpC\xe1T1\xf1\xf8k\xc5b'
        padding = b''
    Container:
        tag = b'\xeat\xc4d]^\xfe\xe9'
        tag_name = unknown
        block_length = 64
        value_length = 44
        value = b"'R\x00\x8e\x1c\x11\xe2$\xe8\xeb\x07\xee\xc4\xa0\x9d\x17D\ncr\xd5\xdc!\t\xe5P\xec\xac\x98`a?\x8bz\x8b\xe6\xb4Zi\x83-\x9e\x8c\xe7"
        padding = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
    Container:
        tag = b'\xe6\xd0\xa3\xe7,\x92\xa4\xef'
        tag_name = unknown
        block_length = 16
        value_length = 8
        value = b'\x00\x00\x00\x00\x00\x00\x00\x01'
        padding = b'\x00\x00\x00\x00\x00\x00\x00\x00'
    Container:
        tag = b'\x1b\xf7\xf5?]]Z\x1f'
        tag_name = ASSET_ID
        block_length = 128
        value_length = 18
        value = b'\xaa\xbb\xcc\xdd\xee\xff\xaa\xbb\xcc\xdd\xee\xff\xaa\xbb\xcc\xdd\xee\xff'
        padding = b"rU6>)\xd1RSE\xab\xf4\xc5\x88\xa1\xf7\xa4\x02\xa5w\x17\xe1\x96\xae\xc84\xf2\x93=\xb5\xfc$X\xc7\x94\x1b\xbeso\xa9\xda\xa27\xd21#\x8f\xf8\xe2\x13\x9f\xbcy\xbb\xd3\xf5u]i0\xa6\r\x8ahmk\x11\xef\xacg\x0f\xd0\xf0\x12\xa05\xbd\xba\x08\xb1\x86\x8b(\xc6'\xa8\x83T\xb4Z\xbfAxO\xb22\x1ea\x85\xe6\xfaV\x8cGn\x90\xaf\xb9\x15\xb6\xff"
    Container:
        tag = b'G\xaaz\xd3D\x05w\xde'
        tag_name = TRANSACTION_ID
        block_length = 112
        value_length = 8
        value = b'\x14s\xe5\xccS\xe1\xe5\xd6'
        padding = b'\x01x\xe7\xe8\x9b\xc6[y\xe7\xf9-&\x11\xbdKW\x91Z4[O\x07\xd3\xcb\xc60\xf6\xd5o7l\xe0\xfb\xc6\x05\xc9\x93\x1a\x0c\x91\xe4\x00\x01\xf7\xde\x0cr-R\xe8M\xc6\x86\x82\x94x;I\xa9\xfa\x98G\x94\x04!\x8d1\xd0\n\xeb\xbd2\x10\xab/\x0cO\xb9\x1cf\x08Ev\xa6\x0fe\x08s\xbf\xd1\x17\xa51\xe2M\xe8\xa1\xd7\x8fE\x1b\x85\xb6\x1f'
    Container:
        tag = b'\xf9\x11\xf0M\xa5K\xf5\x99'
        tag_name = unknown
        block_length = 128
        value_length = 121
        value = b'\xd9\xa2\xd8N\xbf\x8e\x07\xfaO\x890&\x1f\xf9dcCvk|\xe0\\8\x88\x89\xa8\xed\xd8\rKCRj#\x93)\x1ec\x03\xef\x14\x8a\x13/-\xdex\xcdt\x073\xb7\xb0\r\\wPY\xd6\x8b51\xbeU@0\xbe\x013\x84\xff\x122\xc84\xb1\xa3\xe6\x17\x9bW\x0bn\x9e\x1d\x86\x9fu\xe3\xd1\xe6[\x10E\x1ae`\xea\xd4\xf7\xa2v\xee\xe4\x9e\xf2\xff\x0e\xefMa\x1e+\x8d\x9dnpf\xf1Ms'
        padding = b'r\x0fr\\\x1a\x08G'
    Container:
        tag = b'\xba\x08\xcct\xda\xc9\x17m'
        tag_name = unknown
        block_length = 32
        value_length = 25
        value = b'\xfbJv\x13\xe3\xf8H;\xd0\x1e\x1a\xae\x7f\\\xe0\x90,\x00\xc8\xcbg[b$\x85'
        padding = b'I\xd4\xe2\x12gh\xea'
    Container:
        tag = b'\x13\r\x99L\xb8\x94\xb9\xe3'
        tag_name = unknown
        block_length = 32
        value_length = 19
        value = b'\xa5\x13T\xe5\x1a\xa0[R\xdf\x0ck\xa5\x0f\x94\x8d\\e\xf56'
        padding = b'\xe2\xddh\xd085\xcd/\xabj\xbdU\xfe'
    Container:
        tag = b'f\xc8#\xf3y\xb8{\xb5'
        tag_name = unknown
        block_length = 48
        value_length = 6
        value = b'\xc9\x9f\xb9\x1d%}'
        padding = b'\xdf\xb6\x80\x98\xd5z\xe2\xe0\xc9\xa5 m\x1bU\xb4\x9f\xe5\xd8D\x97_\x1dL\xee\x96\xd4dc\xe4\xe4\xadM\x0f\x9byR\x83\xa4\xf1w\xf3+'
    Container:
        tag = b'\x18\xd4,_\x8eTZK'
        tag_name = unknown
        block_length = 16
        value_length = 6
        value = b'\x96\xf8\xb2\xc4\xf7\xa4'
        padding = b'&\n\x13$6\xf0\x93\xa7\x054'

The only one of importance is the ENCRYPTED_CK block, which, as you can probably guess, contains the encrypted content key. The rest of the blocks are blocks from the SPC untouched, as the documentation states that a KSM must include all SPC blocks in a CKC. Decrypting the content key is trivial. All you have to do is decrypt the ENCRYPTED_CK block value AES ECB with the SPC session key as the AES key. The resulting bytes are the content key. Funnily enough, the content key in the example SPC and CKC Apple provides is 3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b.

Now you know all about FPS! Except ASk derivation, of course. I may cover it in the future, and when I do, I'd be able to write my own FPS client that can generate valid SPCs and derive content keys. Thanks for reading!


   FAIRPLAY    FPS    DRM   

 Share on: Twitter / Facebook / Google+ / Email