Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Atmos Download (Future Feature Request… if possible) #155

Open
CoolJoe72 opened this issue Aug 18, 2023 · 77 comments
Open

Atmos Download (Future Feature Request… if possible) #155

CoolJoe72 opened this issue Aug 18, 2023 · 77 comments
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@CoolJoe72
Copy link

So it would be awesome to enable the Audible Dolby Atmos option for downloading
I'm not sure if something would need to be added to the api or even what codec format they are using but it seems it can be downloaded in the iOS app and some select android devices.

Digging around the api found this in /1.0/library/B0C66LN3JW but it showed the standard stereo available_codecs

"asset_details": [ "is_spatial": true,"name": "Dolby"]

This is all new to me and I'm not even sure what I'm looking for.

@mkb79
Copy link
Owner

mkb79 commented Aug 19, 2023

I'm absolutely low on time this week. But you can try out yourself if you can download and decrypt the atmos audio.

You have to make a POST request to https://api.audible.de/1.0/content/B004UVB7KC/licenserequest. B004UVB7KC must be replaced with your ASIN.

The body for the request above is

{
  "use_adaptive_bit_rate" : true,
  "supported_media_features" : {
    "codecs" : [
      "mp4a.40.2",
      "mp4a.40.42",
      "ec+3"
    ],
    "drm_types" : [
      "Mpeg",
      "Hls",
      "HlsCmaf",
      "FairPlay"
    ]
  },
  "response_groups" : "content_reference,chapter_info,last_position_heard,pdf_url,certificate",
  "consumption_type" : "Streaming",
  "spatial" : true
}

I does not know why the iOS Audible app makes a Streaming request but it download and saves the audio file.

Edit:
The iOS Audible app makes a download request. It looks so

{
  "quality" : "High",
  "response_groups" : "chapter_info,content_reference,last_position_heard,pdf_url",
  "consumption_type" : "Download",
  "supported_media_features" : {
    "codecs" : [
      "mp4a.40.2",
      "mp4a.40.42",
      "ec+3"
    ],
    "drm_types" : [
      "Mpeg",
      "Adrm",
      "FairPlay"
    ]
  },
  "spatial" : true
}

Edit:
In both cases a m3u8 playlist file is provided for downloading using the FairPlay format. After that, the client made a request to https://api.audible.de/1.0/content/B004UVB7KC/drmlicense with a licenseChallenge body to receive the license. I does not know how to decrypt FairPlay DRM.

@CoolJoe72
Copy link
Author

Well it was worth a shot, and thank you all your time you put into it. I wasn't expecting a response other than yeah that would be neat and maybe marked as a future option once it was figured out in like 6 months or more.

Looks like I'll have to do some research now I know what direction to go in, when I get some free time.

Thank you.

@devnoname120
Copy link
Contributor

devnoname120 commented Sep 3, 2023

Audible for Android also supports Dolby Atmos. Does it also download a FairPlay format? Because to the best of my knowledge FairPlay is only available on Apple devices so it may be using another (weaker?) DRM.

@mkb79
Copy link
Owner

mkb79 commented Sep 4, 2023

@devnoname120

The iOS and Android Audible apps are requesting the same API to make a license request. Therefore, you just have to find out which request body the Android app uses.

For iOS this body is sent:

{
  "quality" : "High",
  "response_groups" : "chapter_info,content_reference,last_position_heard,pdf_url",
  "consumption_type" : "Download",
  "supported_media_features" : {
    "codecs" : [
      "mp4a.40.2",
      "mp4a.40.42",
      "ec+3"
    ],
    "drm_types" : [
      "Mpeg",
      "Adrm",
      "FairPlay"
    ]
  },
  "spatial" : true
}

I do not have an Android device to decrypt the HTTPS traffic sent from the Audible app to the API. So you can using the script below to play around to make a licenserequest and test some codecs and drm_types and check which response the API will give:

import json

from audible import Authenticator, Client


auth_file_path = "..."  # FILL OUT
asin = "..."  # FILL OUT


auth = Authenticator.from_file(auth_file_path)


with Client(auth) as client:

    body = {
        "quality": "High",
        "response_groups": "chapter_info,content_reference,last_position_heard,pdf_url",
        "consumption_type": "Download",
        "supported_media_features": 
            {
                "codecs": [
                    "mp4a.40.2",
                    "mp4a.40.42",
                    "ec+3"
                ],
                "drm_types": [
                    "Mpeg",
                    "Adrm",
                    "FairPlay"
                ]
            },
        "spatial": True
    }

    lr = client.post(
        f"content/{asin}/licenserequest",
        body=body,
    )
    print(json.dumps(lr, indent=4))

Known drm_types are Mpeg, PlayReady, Hls, Dash, FairPlay, Widevine, HlsCmaf, Adrm. And known codecs are ec+3, ac-4, mp4a.40.42, mp4a.40.2. Maybe these helps a little bit?!

@mkb79
Copy link
Owner

mkb79 commented Sep 4, 2023

If you remove FairPlay from supported drm_types it will download the book in AAXC (Adrm mode)! I does not know if it using Dolby Atmos. But it uses the mp4a.40.2 codec in booth cases. Tried it with asin 3844535306 and german marketplace .

Edit:
Oh it seams these audiobook is not available in Dolby Atmos.

Edit:
Tried it again with an Atmos title. It will only download with ec+3 and only as FairPlay protected title. Maybe you have more luck.

@devnoname120
Copy link
Contributor

@mkb79 I can't test right now. Did you try with ac-4 and WideVine? This seems to me to be the combo that will most likely work for Android content.

@mkb79
Copy link
Owner

mkb79 commented Sep 4, 2023

I've tried it with the codecs ec+3 and ac-4. Drm_types where set to Mpeg, PlayReady, Hls, Dash, Widevine, HlsCmaf, Adrm (only removing FairPlay). This will give me a HTTP 404 error with the message: Unable to retrieve asset details from Sable(AssetInfos), for marketplaceId:AN7V1F1VY261K, asin:B0BGYDYQ38, acr:null, skuLite:OR_ORIG_002267, version:LATEST, aaaClientId:ApolloEnv:AudibleApiExternalRouterService/EU/Prod. Maybe B0BGYDYQ38 is not available on Android devices in Dolby Atmos or they are using some other codecs/drm_types there.

@mkb79
Copy link
Owner

mkb79 commented Sep 4, 2023

I've documented some new API endpoints related to the FairPlay DRM.

Steps to download Dolby Atmos titles:

  1. The client makes a licenserequest.
  2. It receives the response containing a URI to a m3u8 file
  3. The client makes multiple GET requests to this URI with the User-Agent: AppleCoreMedia/1.0.0.20G75 (iPhone; U; CPU OS 16_6 like Mac OS X; de_de) and receives content from type `application/vnd.apple.mpegurl
  4. Client requests the FairPlay certificate.
  5. Client makes a GET request to dpm.demdex.net and receives JSON content (Edit: these step may be not iOS App related)
  6. Client make a POST request to drmlicense endpoint
  7. The client request build a new URI from the URI from 2 and the filename taken from the m3u8 file and receives the audio/mp4 file.

Edit:
I can’t replay step 6 currently. I does not know how to build the license challenge. If I follow the other steps from above, leaving step 6 out, I receive a 403 status error. So step 6 must be the import part. Maybe the challenge is build using the cert from point 4 and other things?

@mkb79
Copy link
Owner

mkb79 commented Sep 4, 2023

@devnoname120
I’ve found out how I can extract the uri from the m3u8 file and download the Dolby Atmos mp4 file. Now I've to find out how to decrypt these file.

@devnoname120
Copy link
Contributor

devnoname120 commented Sep 4, 2023

@mkb79 What's the output of file dolby_atmos_file.mp4?

Note: step 5 is probably irrelevant as dpm.demdex.net is an endpoint used by Adobe Experience Platform Identity Service.

@mkb79
Copy link
Owner

mkb79 commented Sep 4, 2023

The m3u8 playlist contains the mp4 file location. With these information you can build the correct URI to the mp4 file. These file must be downloaded using a special Range header specify the byte range like bytes=0-1368458706 for asin B0BGYDYQ38. These audiobook has a size of 1368458706 bytes. If you forgot the Range header I've got an 403 error. So this header is mandatory.

After downloading you have a mp4 file which is encrypted via SAMPLE-AES. Now you need the key for decryption.

@devnoname120
Copy link
Contributor

Here is the full licenserequest JSON body definition on Android:

{
  "supported_media_features" : {
    "drm_types": [
      "adrm",
      "hls",
      "play_ready",
      "mpeg",
      "dash",
      "widevine"
    ],
    "codecs": [
       "mp4a.40.2", // AAC_LC
       "mp4a.40.42", // XHE_AAC
       "ec+3", // EC_PLUS_3
       "ac-4", // AC_4
    ],
    "chapter_titles_type": "flat/tree",
  },
  "spatial" : true, // true/false,
  "consumption_type" : "streaming/download",
  "rights_validation": "ownership/radio/aycl",
  "quality" : "low/normal/high/extreme",
  "version": "[version]",
  "acr": "CR![id_of_28_characters]", // Some kind of id for license?
  "use_adaptive_bit_rate": true, // true/false
  "playback_start_ms": 123,
  "playback_end_ms":  456,
  "response_groups" : "content_reference,chapter_info,pdf_url,last_position_heard,ad_insertion",
  "file_version": "[version]"
}

Not sure whether it differs from the one you see on iOS or not. Just posting this for my future reference I'll dig more into the Atmos stuff on Android.

@mkb79
Copy link
Owner

mkb79 commented Sep 4, 2023

Thank you very much. That seams a licenserequest for streaming purposes. use_adaptive_bit_rate is typically for streaming requests. acr is the amazon content reference. This value is unique for each book/asin/codec?/version combination.

Important for me is the response for a download license request for an Dolby Atmos book.

@devnoname120
Copy link
Contributor

Unfortunately I don't have an Android device that supports Dolby Atmos… Neither do I own a Dolby Atmos book.

@devnoname120
Copy link
Contributor

@mkb79 Can you retry with this user agent?

Audible, Android, 3.58.0, samsung, SM-S906B, g0sxeea, 13, 1.0, WIFI

This corresponds to a Samsung Galaxy S22 (it has native Dolby Atmos support).

I cobbled up this user agent by looking at how the Audible app constructs it and filling it with the information I was able to find on the internet. I'm not 100% sure I didn't make any mistakes while building it though.

@mkb79
Copy link
Owner

mkb79 commented Sep 4, 2023

@devnoname120 Specifying an User-Agent makes no different.

But I'm checked the downloaded book B0BGYDYQ38 with MediaInfo

General
Complete name                            : audio.mp4
Format                                   : MPEG-4
Format profile                           : Base Media / Version 1
Codec ID                                 : mp41 (iso8/isom/mp41/dash/cmfc)
File size                                : 1.27 GiB
Duration                                 : 3 h 57 min
Overall bit rate mode                    : Constant
Overall bit rate                         : 769 kb/s
Encoded date                             : 2023-03-22 12:22:54 UTC
Tagged date                              : 2023-03-22 12:22:54 UTC

Audio
ID                                       : 1
Format                                   : E-AC-3
Format/Info                              : Enhanced AC-3
Commercial name                          : Dolby Digital Plus
Codec ID                                 : enca / ec-3
Duration                                 : 3 h 57 min
Bit rate mode                            : Constant
Channel(s)                               : 6 channels
Channel layout                           : L R C LFE Ls Rs
Sampling rate                            : 48.0 kHz
Compression mode                         : Lossy
Service kind                             : Complete Main
Encoded date                             : 2023-03-22 12:22:54 UTC
Tagged date                              : 2023-03-22 12:22:54 UTC
Encryption                               : Encrypted

So downloading Dolby Atmos titles is no problem. But decrypting FPS is the next step. I know how I can receive the FairPlay cert. The drmlicense request will then receive the license which can be used for decryption.

@devnoname120
Copy link
Contributor

@mkb79 I'm not familiar with FairPlay but it's a tough nut to crack. I'll try to trick the Audible app into thinking that my phone supports Dolby Atmos and dump the resulting HTTP requests. That will be for another time though!

@devnoname120
Copy link
Contributor

For my future reference here is the list of available Dolby Atmos audiobooks: https://www.audible.com/public-collections/1998b1ba-07e8-470f-8581-f97365772fe0

@Mbucari
Copy link

Mbucari commented Oct 16, 2023

@devnoname120 @mkb79

Widevine is only available on android devices. For you to be able to request Widevine DRM, your audible client must be registered as an android device. Currently, audible-cli only registers as an iPhone. To register as an Android device, use the following registration body:

Android Registration Body Change the registration body to the following:
body = {
    "requested_token_type": [
        "bearer",
        "mac_dms",
        "website_cookies",
        "store_authentication_cookie",
    ],
    "cookies": {"website_cookies": [], "domain": f".amazon.{domain}"},
    "registration_data": {
        "domain": "DeviceLegacy",
        "app_version": "141028",
        "device_serial": serial,
        "device_type": "A10KISP2GWF0E4",
        "device_name": (
            "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_"
            "STRATEGY_1ST%Audible for Android"
        ),
        "os_version": "google/sdk_gphone64_x86_64/emu64xa:14/UPB5.230623.003/10615560:userdebug/dev-keys",
        "software_version": "130050002",
        "device_model": "sdk_gphone64_x86_64",
        "app_name": "com.audible.application",
    },
    "auth_data": {
    "use_global_authentication": "true",
        "client_id": build_client_id(serial),
        "authorization_code": authorization_code,
        "code_verifier": code_verifier.decode(),
        "code_algorithm": "SHA-256",
        "client_domain": "DeviceLegacy",
    },
    "requested_extensions": ["device_info", "customer_info"],
}

I don't own an android Device that supports Dolby Atmos, but I was able to modify the Audible apk to allow downloading Atmos files. You can download it here.

Steps to download Dolby Atmos titles using Zero-G as an example:

  1. The client makes a licenserequest.
    a. Request URL: https://api.audible.com/1.0/content/B07K4VYQ5X/licenserequest
    b. Request Body

    {
      "supported_media_features": {
        "drm_types": [
          "Widevine",
          "Adrm",
          "Mpeg"
        ],
        "codecs": [
          "mp4a.40.2",
          "mp4a.40.42",
          "ec+3",
          "ac-4"
        ],
        "chapter_titles_type": "Tree"
      },
      "spatial": true,
      "consumption_type": "Download",
      "quality": "High",
      "response_groups": "content_reference,chapter_info,pdf_url,ad_insertion"
    }
  2. It receives the response containing a URI to a Dash MPD file

    Example MPD File Zero-G MPD File
    <?xml version='1.0' encoding='utf-8'?>
    <MPD minBufferTime='PT20S' type='static' mediaPresentationDuration='PT4H8M52.693S' profiles='urn:mpeg:dash:profile:isoff-main:2011'
    xmlns='urn:mpeg:dash:schema:mpd:2011'
    xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' xsi:schemaLocation='urn:mpeg:DASH:schema:MPD:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd'>
    <Period id='0' duration='PT4H8M52.693S'>
    <AdaptationSet id='0' contentType='audio' lang='und' subsegmentAlignment='true'>
    <ContentProtection xmlns:cenc='urn:mpeg:cenc:2013' cenc:default_KID='27bde860-e27a-902c-9fda-9aa043c4fc11' schemeIdUri='urn:mpeg:dash:mp4protection:2011' value='cenc'/>
    <ContentProtection xmlns:cenc='urn:mpeg:cenc:2013' schemeIdUri='urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'>
    <cenc:pssh>
    AAAAsXBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAJESECe96GDiepAsn9qaoEPE/BESEFvn1UjnJquHS2LOtGVV88cSEBwnhnUCfvJndrBf6z7afIsaB0F1ZGlibGUiUGNpZDoNCko3M29ZT0o2a0N5ZjJwcWdROFQ4RVE9PSxXK2ZWU09jbXE0ZExZczYwWlZYenh3PT0sSENlR2RRSis4bWQyc0YvclB0cDhpdz09
    </cenc:pssh>
    </ContentProtection>
    <ContentProtection xmlns:cenc='urn:mpeg:cenc:2013' schemeIdUri='urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95'>
    <cenc:pssh>
    AAAAVHBzc2gBAAAAmgTweZhAQoarkuZb4IhflQAAAAMnvehg4nqQLJ/amqBDxPwRW+fVSOcmq4dLYs60ZVXzxxwnhnUCfvJndrBf6z7afIsAAAAA
    </cenc:pssh>
    </ContentProtection>
    <Representation id='0' mimeType='audio/mp4' codecs='ac-4.02.02.00' bandwidth='324598' audioSamplingRate='48000'>
    <AudioChannelConfiguration schemeIdUri='urn:mpeg:mpegB:cicp:ChannelConfiguration' value='2'/>
    <SupplementalProperty schemeIdUri='tag:dolby.com,2016:dash:virtualized_content:2016' value='1'/>
    <BaseURL>../../../../base/or_orig_000434/47096871/cenc/g1/or_orig_000434_48_320-ac4.mp4?ss_sec=20&amp;use_token_based_signing=true</BaseURL>
    <SegmentBase timescale='48000' indexRange='1118-10281' indexRangeExact='true'>
    <Initialization range='0-1117'/>
    </SegmentBase>
    </Representation>
    </AdaptationSet>
    </Period>
    </MPD>
  3. Client makes a GET request for the first Initialization bytes of the file. (Found in the MPD file. Value is range='0-1117' in the example.) These bytes are the mp4 file's ftyp and moov boxes.

  4. Client make a POST request to drmlicense endpoint
    Body:

    {
      "consumption_type": "Download",
      "drm_type": "Widevine",
      "licenseChallenge": "..."
    }

    licenseChallenge is the Base64 encoded bytes returned from ExoMediaDrm.KeyRequest.getData()

  5. Client downloads the rest of the mp4 file.

I don't have any widevine experience, but a good place to start reverse engineering Audible's implementation is in com.audible.widevinecdm.WidevineL3CdmFactory.

@mkb79 mkb79 pinned this issue Oct 16, 2023
@mkb79
Copy link
Owner

mkb79 commented Oct 16, 2023

@Mbucari

Thank you very much for sharing your findings. Since I don't have an Android device, I unfortunately couldn't find out the exact registration body. This will help a lot.

Now we have two possible approaches (Widevine or FairPlay) to decrypt Atmos titles. Maybe some of them is successful.

@devnoname120
Copy link
Contributor

devnoname120 commented Oct 16, 2023 via email

@Mbucari
Copy link

Mbucari commented Oct 17, 2023

Hey @Mbucari and thank you for the payloads.

You're welcome @devnoname120. FYI, my patched apk removed cert pinning so you can use Http Toolkit to get all https traffic.

I updated my local fork of audible-cli in order to support Android auth, but (at least on my side) they use a different format of certificates (not PEM) for payload signatures and I haven't fixed that part entirely yet.

I forgot to mention this. On Android the private key is formatted like so (Asn.1 values)

PrivateKeyInfo SEQUENCE (3 elem)
  version Version INTEGER 0
  privateKeyAlgorithm AlgorithmIdentifier SEQUENCE (2 elem)
    algorithm OBJECT IDENTIFIER 1.2.840.113549.1.1.1 rsaEncryption (PKCS #1)
    parameters ANY NULL
  privateKey PrivateKey OCTET STRING
    SEQUENCE (9 elem)
      INTEGER 0
      INTEGER (RSA Modulus)
      INTEGER (RSA Public Exponent)
      INTEGER (RSA D)
      INTEGER (RSA P)
      INTEGER (RSA Q)
      INTEGER (RSA DP)
      INTEGER (RSA DQ)
      INTEGER (RSA InverseQ)

The PrivateKey octet string is equivalent to the PEM delivered by iPhone registration.

I additionally dumped a L3 Widevine CDM so I should be ready to decrypt the actual resulting Dolby Atmos payload — unless there are more protections on top of Widevine. I wanted to do a PR or at least a complete PoC before updating you, but to avoid duplicate work it I could maybe release my findings before doing a PoC when I get the chance to.

At the moment I have no idea what any of that means, but it sounds very impressive! Do you have some links to widevine documentation that could help explain this? My searches only yielded high-level info which seems pretty useless for reversing.

@Mbucari
Copy link

Mbucari commented Oct 17, 2023

@devnoname120 I really wish I could be more helpful with the encryption key, but I don't have python code for it.

You can use this JavaScript parser to decode the base64 and get the integer values: https://lapo.it/asn1js/

And you can see my c# Asn.1 decoder here: https://github.com/rmcrackan/AudibleApi/blob/a630d6f04b2840d68b532a782eab3f46ec14aac0/AudibleApi/Cryptography/PrivateKey.cs#L54C1-L54C1

@devnoname120
Copy link
Contributor

devnoname120 commented Oct 30, 2023

Quick update: I made good progress. I have a PoC that authenticates, gets the Atmos content license object, extracts the pssh from the MPD, sets up a new Widevine L3 session with my dumped CDM keys, gets a new challenge from the CDM, and sends a Widevine L3 license request to Audible with that challenge.

Currently Audible refuses to grant my L3 license request. I double-checked my license request and it looks correct — I think that the CDM keys that I extracted are just not approved by Audible. Next step is getting my hand on a rooted physical Android physical in order to extract new Widevine CDM keys and move on to the next step.

@Mbucari
Copy link

Mbucari commented Nov 6, 2023

@devnoname120 I have a couple of old android devices that I could root and, with your instruction, dump CDM keys. Hell, I'd be willing to gift one of them to you if you'd like.


Can you explain what these keys are? If you dumped a working CDM and we use them in our audible decryptors, won't they just be revoked? And when they revoked, would we have to buy a new device to get a new, valid CDM?

@devnoname120
Copy link
Contributor

@Mbucari That would be great, thanks! Getting my hand on CDM keys would be enough. These keys would only be for my personal use. In order to get the keys you would need to follow these instructions: https://github.com/lollolong/dumper

Can you explain what these keys are?

They are used to simulate a Widevine L3 device. From that simulated device we create a challenge that is sent to Audible's server, which in turn returns personalized decryption keys that the simulator deciphers and then returns back to us. I'm not in my sharpest mental state right now so I hope it makes sense.

If you dumped a working CDM and we use them in our audible decryptors, won't they just be revoked? And when they revoked, would we have to buy a new device to get a new, valid CDM?

You're right, if we included dumped keys they would be banned pretty fast. Users would have to dump their own keys or use a Widevine proxy service such as https://getwvkeys.cc/ or one based on pywidevine's remote CDM feature. I don't know any good proxies yet

@devnoname120
Copy link
Contributor

@Mbucari Do you have any updates on the Widevine CDM dumps? 🙏

@Mbucari
Copy link

Mbucari commented Dec 4, 2023

Do you have any updates on the Widevine CDM dumps? 🙏

Not yet, sorry. Thanks for the reminder. I'll get to it this week.

@mkb79
Copy link
Owner

mkb79 commented Apr 17, 2024

@devnoname120
Thank you very much. This helps a lot. I could convert the private key to the right format now. But when I try to make a licenserequest to an Atmos title, I've received an 404er error. Which user-agent do you use?

Update:
Maybe the reason for my issue is, that I only changed the body of the registration request to an Android device. These seams to be not enough or the device does not support Atmos natively. I'll change the login form to an Android device now and then I'll see, if this works.

@mkb79
Copy link
Owner

mkb79 commented Apr 18, 2024

@devnoname120

I'm writing my packages using Pythonista on my iOS device most of the time. On Pythonista I can't use the cryptography package. So I rewrote your code to convert the Android private key. If you are interested, the code can be found below.

def base64_der_to_pkcs1(base64_key):
    import base64

    import rsa
    from pyasn1.codec.der import decoder
    from pyasn1.type import univ, namedtype


    class PrivateKeyAlgorithm(univ.Sequence):
        componentType = namedtype.NamedTypes(
            namedtype.NamedType("algorithm", univ.ObjectIdentifier()),
            namedtype.NamedType("parameters", univ.Any()),
        )
    
    
    class PrivateKeyInfo(univ.Sequence):
        componentType = namedtype.NamedTypes(
            namedtype.NamedType("version", univ.Integer()),
            namedtype.NamedType("pkalgo", PrivateKeyAlgorithm()),
            namedtype.NamedType("key", univ.OctetString()),
        )

    der_pk = base64.b64decode(base64_key)
    (priv, _) = decoder.decode(der_pk, asn1Spec=PrivateKeyInfo())

    key = rsa.PrivateKey.load_pkcs1(priv["key"], format="DER")
    return key.save_pkcs1().decode("utf-8")

@devnoname120
Copy link
Contributor

devnoname120 commented Apr 18, 2024

@mkb79 Here is a list of Android devices that support true Dolby Atmos (≠ just has the Dolby Atmos equalizer app).

For example for the OnePlus 8 the USER_AGENT would look like something like that:

Mozilla/5.0 (Linux; Android 11; ONEPLUS IN2013 Build/NMF26F; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.96 Mobile Safari/537.36

@szescxz
Copy link

szescxz commented Apr 18, 2024

Also note that I was able to do licenserequest on an unsupported device with the Frida hook above.
As long as supported_media_features.drm_types includes Widevine, supported_media_features.codecs includes ec+3 and/or ac-4 (although I never found any players supporting ac-4 playback), and spatial is set to true, this should be sufficient to request for the spatial audio manifest (compared to normal ones).

BTW, I think the User-Agent string on my side is something like

Dalvik/2.1.0 (Linux; U; Android 11; REDACTED_DEVICE_MODEL_NAME Build/REDACTED_BUILD_NUMBER); com.audible.application 3.73.0 b:154017

@devnoname120
Copy link
Contributor

devnoname120 commented Apr 18, 2024

@mkb79 And the release-keys can be found in the build.props of the device (here OnePlus 8):

OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys

And with the rest of the information from the build.props of that device the registration body would look like:

body = {
    "requested_token_type": [
        "bearer",
        "mac_dms",
        "website_cookies",
        "store_authentication_cookie",
    ],
    "cookies": {"website_cookies": [], "domain": f".amazon.{domain}"},
    "device_metadata": {
        "device_os_family": "android",
        "device_serial": "9f25e8f5e3d8ed7b9415",
        "device_type": "A10KISP2GWF0E4",
        "manufacturer": "OnePlus",
        "model": "IN2013",
        "os_version": "30",
        "product": "OnePlus8",
    },
    "registration_data": {
        "domain": "DeviceLegacy",
        "app_version": "139018",
        "device_serial": "9f25e8f5e3d8ed7b9415",
        "device_type": "A10KISP2GWF0E4",
        "device_name": (
            "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_STRATEGY_1ST%Audible for Android"
        ),
        "os_version": "OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys",
        "software_version": "110090009",
        "device_model": "IN2013",
        "app_name": "com.audible.application",
    },
    "auth_data": {
        "client_id": build_client_id(serial),
        "authorization_code": authorization_code,
        "code_verifier": code_verifier.decode(),
        "code_algorithm": "SHA-256",
        "client_domain": "DeviceLegacy",
    },
    "requested_extensions": ["device_info", "customer_info"],
}

Note: I generated a random device_serial of 20 characters.


@mkb79 Also, it may have changed but the code I gave you is all I needed to make registration/login/download work. So you may want to double-check your changes to see if you missed something.

@devnoname120
Copy link
Contributor

@szescxz ac-4 is used for iPhones AFAIK

@szescxz
Copy link

szescxz commented Apr 18, 2024

@szescxz ac-4 is used for iPhones AFAIK

Well, server did issue a Widevine license for me to decrypt the file using Android tokens/identifiers. No way to verify the result since I don't have a supported decoder, though.
ec+3 works fine.

@mkb79
Copy link
Owner

mkb79 commented Apr 18, 2024

@devnoname120

@mkb79 And the release-keys can be found in the build.props of the device (here OnePlus 8):

OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys

And with the rest of the information from the build.props of that device the registration body would look like:

body = {
    "requested_token_type": [
        "bearer",
        "mac_dms",
        "website_cookies",
        "store_authentication_cookie",
    ],
    "cookies": {"website_cookies": [], "domain": f".amazon.{domain}"},
    "device_metadata": {
        "device_os_family": "android",
        "device_serial": "9f25e8f5e3d8ed7b9415",
        "device_type": "A10KISP2GWF0E4",
        "manufacturer": "OnePlus",
        "model": "IN2013",
        "os_version": "30",
        "product": "OnePlus8",
    },
    "registration_data": {
        "domain": "DeviceLegacy",
        "app_version": "139018",
        "device_serial": "9f25e8f5e3d8ed7b9415",
        "device_type": "A10KISP2GWF0E4",
        "device_name": (
            "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_STRATEGY_1ST%Audible for Android"
        ),
        "os_version": "OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys",
        "software_version": "110090009",
        "device_model": "IN2013",
        "app_name": "com.audible.application",
    },
    "auth_data": {
        "client_id": build_client_id(serial),
        "authorization_code": authorization_code,
        "code_verifier": code_verifier.decode(),
        "code_algorithm": "SHA-256",
        "client_domain": "DeviceLegacy",
    },
    "requested_extensions": ["device_info", "customer_info"],
}

Note: I generated a random device_serial of 20 characters.

@mkb79 Also, it may have changed but the code I gave you is all I needed to make registration/login/download work. So you may want to double-check your changes to see if you missed something.

I'm registered a new device and only changed the body with your suggestion, but I can’t download Widevine content. It still uses Adrm. Maybe changing the registration body is not enough. I'll try again by changing the login part too and will report back.

@mkb79
Copy link
Owner

mkb79 commented Apr 18, 2024

@devnoname120
I've changed the login part now. But it does not work. Maybe the registration data are incorrect so Audible does not know that this is an Atmos compatible device.

@devnoname120
Copy link
Contributor

@szescxz But how did you get the decryption key? This is the part where I'm confused. Did you just hook the right functions to grab it from the fangs of Widevine?

@devnoname120
Copy link
Contributor

@mkb79 Can you give me your code diff so that I can double-check?

@mkb79
Copy link
Owner

mkb79 commented Apr 18, 2024

@devnoname120
I've created this branch with my rework. This includes a new registration request body and a conversion of the private key.

@devnoname120
Copy link
Contributor

devnoname120 commented Apr 18, 2024

@mkb79 I don't see any changes to src/audible/login.py in your branch. Neither do I see any changes to the f"content/{asin}/licenserequest request.

@mkb79
Copy link
Owner

mkb79 commented Apr 19, 2024

@devnoname120
I've merged my changes to login.py to the same branch now.

device_register.py

from audible import Authenticator


r = Authenticator.from_login(
    "[REDACTED]",
    "[REDACTED]",
    "de"
)
r.to_file("credentials-android.json")

licenserequest.py

import json

import audible


auth_file_android = "credentials-android.json"
auth_file_iphone = "credentials-iphone.json"


auth = audible.Authenticator.from_file(auth_file_android)

with audible.Client(auth=auth, country_code='us') as client:
    asin = "B0C66LN3JW"
    body = {
        "supported_media_features": {
            "drm_types": [
              "Widevine",
              "Adrm",
              "Mpeg",
              "FairPlay"
            ],
            "codecs": [
              "mp4a.40.2",
              "mp4a.40.42",
              "ec+3",
              "ac-4"
            ],
            "chapter_titles_type": "Tree"
        },
        "spatial": True,
        "consumption_type": "Download",
        "quality": "High",
        "response_groups": "content_reference,chapter_info,pdf_url,ad_insertion"
    }
    r = client.post(f'content/{asin}/licenserequest', body=body)

    # print(json.dumps(r, indent=4))
    drm_type = r["content_license"]["drm_type"]
    cr = r["content_license"]["content_metadata"]["content_reference"]
    codec = cr["codec"]
    content_format = cr["content_format"]

    print(f"DRM TYPE: {drm_type}")
    print(f"CODEC {codec}")
    print(f"FORMAT: {content_format}")

When I run the licenserequest with my iPhone profile, i've got FairPlay content. With my Android profile it is ADRM

Edit:
I've registered a new device on US market to make sure, it's not marketplace related. It makes no difference.

@szescxz
Copy link

szescxz commented Apr 19, 2024

Can reproduce on my end.
But if changing consumption_type to Streaming (as well as changing other related parameters) then the server will return Widevine content.

Credential from a real device is still able to request Widevine content with consumption_type set to Download, though.

@mkb79
Copy link
Owner

mkb79 commented Apr 19, 2024

@szescxz
If I set the body in my licenserequest.py to

body = {
        "supported_media_features": {
            "drm_types": [
              "Widevine",
              "Mpeg",
              "FairPlay",
              #"Adrm",
            ],
            "codecs": [
              "mp4a.40.2",
              "mp4a.40.42",
              "ec+3",
              "ac-4"
            ],
            "chapter_titles_type": "Tree"
        },
        "spatial": True,
        "consumption_type": "Streaming",
        "quality": "High",
        "response_groups": "content_reference,chapter_info,pdf_url,ad_insertion"
    }

it results in

DRM TYPE: Widevine
CODEC ac-4
FORMAT: M4A_AC4

When I uncomment "Adrm" the result is

Bad Request (400): Only Dash, HlsCmaf, Hls, and Mpeg can be requested with Streaming consumption_type

Credential from a real device is still able to request Widevine content with consumption_type set to Download, though.

Maybe the correct body of the registration request makes the difference. Can you provide your body (without the serial?

@mkb79
Copy link
Owner

mkb79 commented Apr 19, 2024

If I use my iPhone credentials and the streaming request, the result is

DRM TYPE: FairPlay
CODEC ec+3
FORMAT: M4A_EC3

The codec and format differ between the android and iPhone credentials.

@mkb79 mkb79 added enhancement New feature or request help wanted Extra attention is needed labels Apr 23, 2024
@devnoname120
Copy link
Contributor

Any news?

@mkb79
Copy link
Owner

mkb79 commented Apr 28, 2024

Last status on my part:
I'm able to register an Android Audible device. But I can only stream Widevine/AC-4 content. Streaming EC3 content or downloading AC-4/EC3 content is not possible. But this is still okay. I can get the full content with a streaming request. But don’t in EC3.

@DrJapan
Copy link

DrJapan commented Jul 6, 2024

I was wondering if there was a way to piggy-back, off of this implementation, that allows for the downloading of EC-3 files, from Apple Music.

https://github.com/alacleaker/apple-music-alac-downloader

I've been able to use this script, and was able to acquire some DRM free, Dolby Atmos files.

@mkb79
Copy link
Owner

mkb79 commented Jul 6, 2024

I was wondering if there was a way to piggy-back, off of this implementation, that allows for the downloading of EC-3 files, from Apple Music.

https://github.com/alacleaker/apple-music-alac-downloader

I've been able to use this script, and was able to acquire some DRM free, Dolby Atmos files.

They are using a virtual machine and "patch" the system via an agent on the go to handle Apples FPS. If you find out, how Android or Apple build and encrypt the license challenge request, then you where able to download and decrypt FPS content. The FairPlay cert can be requested using this endpoint.

@DrJapan
Copy link

DrJapan commented Jul 7, 2024

So are you saying this could work? I'm wondering if there is a way to route the downloaded mp4 EC-3 files, through the script?

Here's another version of the same process, but this one uses .go files, to accomplish the task.

https://telegra.ph/Apple-Music-Alac高解析度无损音乐下载教程-04-02-2

@szescxz
Copy link

szescxz commented Jul 9, 2024

Sorry for the wait, here goes my full script of (roughly) the entire process:

import base64
import hashlib
import json
import os
import secrets
import subprocess
import uuid

from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from urllib.parse import parse_qs, urlencode, urlparse
import xml.etree.ElementTree as ET

# pip install requests
import requests

# pip install cryptography
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

# pip install pywidevine
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH

class Audible:
    APP_NAME = "com.audible.application"

    APK_VERSION_NAME = "3.79.0"
    APK_VERSION_CODE = "160008"
    APP_VERSION = "130050002"
    MAP_VERSION = "20240412N"

    REGION_CONFIGS = {
        "US": {
            "domain": "amazon.com",
            "region": "NA",
            "base_url": "https://www.amazon.com/ap/signin",
            "return_to": "https://www.audible.com/ap/maplanding",
            "assoc_handle": "amzn_audible_android_experiment_us",
            "register_url": "https://api.audible.com/auth/register"
        }
    }

    @staticmethod
    def generate_request_id():
        return str(uuid.uuid4())

    @staticmethod
    def sign_request(adp_token, device_private_key, method, url, params={}, data=""):
        request_date = datetime.now(timezone.utc)

        parsed_url = urlparse(url)
        query_string = urlencode(params)
        payload = f"{method}\n{parsed_url.path if parsed_url.path != '' else '/'}{'?' + query_string if query_string != '' else ''}\n{request_date.strftime('%Y-%m-%dT%H:%M:%SZ')}\n{data}\n{adp_token}"
        signature = base64.b64encode(
            device_private_key.sign(
                payload.encode(),
                padding.PKCS1v15(),
                hashes.SHA256()
            )
        ).decode()

        return {
            "x-adp-token": adp_token,
            "x-adp-alg": "SHA256WithRSA:1.0",
            "x-adp-signature": f"{signature}:{request_date.strftime('%Y-%m-%dT%H:%M:%SZ')}"
        }

    def __init__(self, region, device_properties):
        self.device_properties = device_properties

        self.region = region

        self.user_agent = "Dalvik/2.1.0 (Linux; U; Android {release}; {model} Build/{build_id})".format_map({
            "release": device_properties["ro.build.version.release"],
            "model": device_properties["ro.product.model"],
            "build_id": device_properties["ro.build.id"]
        })

        self.device_metadata = {
            "device_os_family": "android",
            "device_type": "A10KISP2GWF0E4",
            "device_serial": None,
            "manufacturer": device_properties["ro.product.manufacturer"],
            "model": device_properties["ro.product.model"],
            "os_version": device_properties["ro.product.build.version.sdk"],
            "product": device_properties["ro.product.name"]
        }

        self.registration_data = {
            "domain": "DeviceLegacy",
            "device_type": self.device_type_id,
            "device_name": "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_STRATEGY_1ST%Audible for Android",
            "app_name": self.APP_NAME,
            "app_version": self.APK_VERSION_CODE,
            "device_model": self.device_properties["ro.product.model"],
            "os_version": self.device_properties["ro.build.fingerprint"],
            "software_version": "0"
        }

        self.refresh_token = None
        self.access_token = None
        self.access_token_expiry = None

        self.session = requests.Session()

        # uncomment for debugging with mitmproxy
        #self.session.proxies.update({"https": "http://localhost:8080"})
        #self.session.verify = False

    @property
    def device_type_id(self):
        return self.device_metadata["device_type"]
    
    @property
    def device_serial(self):
        return self.device_metadata["device_serial"]

    def start_login(self):
        def build_device_serial() -> str:
            return uuid.uuid4().hex.lower()[:20]

        def create_s256_code_challenge(verifier: bytes) -> bytes:
            m = hashlib.sha256(verifier)
            return base64.urlsafe_b64encode(m.digest()).rstrip(b"=")

        def build_client_id(serial: str) -> str:
            client_id = serial.encode() + b"#" + self.device_type_id.encode()
            return client_id.hex()
        
        def create_code_verifier(length: int = 32) -> bytes:
            verifier = secrets.token_bytes(length)
            return base64.urlsafe_b64encode(verifier).rstrip(b"=")

        self.device_metadata["device_serial"] = build_device_serial()
        self.registration_data["device_serial"] = self.device_serial

        client_id = build_client_id(self.device_serial)
        code_verifier = create_code_verifier()
        code_challenge = create_s256_code_challenge(code_verifier)

        base_url = self.REGION_CONFIGS[self.region]["base_url"]
        return_to = self.REGION_CONFIGS[self.region]["return_to"]
        assoc_handle = self.REGION_CONFIGS[self.region]["assoc_handle"]
        page_id = "amzn_audible_android_aui_v2_dark_us"

        oauth_params = {
            "openid.oa2.response_type": "code",
            "openid.oa2.code_challenge_method": "S256",
            "openid.oa2.code_challenge": code_challenge,
            "openid.return_to": return_to,
            "openid.assoc_handle": assoc_handle,
            "openid.identity": "http://specs.openid.net/auth/2.0/" "identifier_select",
            "pageId": page_id,
            "accountStatusPolicy": "P1",
            "openid.claimed_id": "http://specs.openid.net/auth/2.0/" "identifier_select",
            "openid.mode": "checkid_setup",
            "openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2",
            "openid.oa2.client_id": f"device:{client_id}",
            "openid.ns.pape": "http://specs.openid.net/extensions/pape/1.0",
            "openid.oa2.scope": "device_auth_access",
            "openid.ns": "http://specs.openid.net/auth/2.0",
            "openid.pape.max_auth_age": "0",
        }

        self.auth_data = {
            "client_domain": self.registration_data["domain"],
            "client_id": client_id,
            "code_algorithm": "SHA-256",
            "code_verifier": code_verifier.decode(),
            "use_global_authentication": "true"
        }

        return f"{base_url}?{urlencode(oauth_params)}"
    
    def load_credentials(self, credentials):
        self.device_metadata["device_type"] = credentials["extensions"]["device_info"]["device_type"]
        self.device_metadata["device_serial"] = credentials["extensions"]["device_info"]["device_serial_number"]

        self.adp_token = credentials["tokens"]["mac_dms"]["adp_token"]
        self.device_private_key = serialization.load_der_private_key(base64.b64decode(credentials["tokens"]["mac_dms"]["device_private_key"]), password=None)

        self.refresh_access_token()

    def register_device(self, response_url):
        self.auth_data.update({
            "authorization_code": parse_qs(urlparse(response_url).query)["openid.oa2.authorization_code"][0]
        })
        
        resp = self.session.post(
            self.REGION_CONFIGS[self.region]["register_url"],
            json={
                "auth_data": self.auth_data,
                "cookies": {
                    "domain": f'www.{self.REGION_CONFIGS[self.region]["domain"]}',
                    "website_cookies": []
                },
                "device_metadata": self.device_metadata,
                "registration_data": self.registration_data,
                "requested_extensions": [
                    "device_info",
                    "customer_info"
                ],
                "requested_token_type": [
                    "bearer",
                    "mac_dms",
                    "store_authentication_cookie",
                    "website_cookies"
                ]
            },
            headers={
                "User-Agent": self.user_agent,
                "x-amzn-identity-auth-domain": self.REGION_CONFIGS[self.region]["register_url"].split("/")[2],
                "X-Amzn-RequestId": self.generate_request_id()
            }
        )
        resp.raise_for_status()
        credentials = resp.json()["response"]["success"]
        self.load_credentials(credentials)
        return credentials
    
    def audibleapi_request(self, method: str, uri, params={}, data={}):
        method = method.upper()

        headers = {
            "x-amzn-identity-auth-domain": f"www.audible.com",
            "X-Amzn-RequestId": self.generate_request_id()
        }

        if data:
            data = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
            headers["Content-Type"] = "application/json"
        else:
            data = ""

        url = f"https://api.audible.com{uri}"

        if self.refresh_token:
            headers.update({
                "User-Agent": f'AmazonWebView/MAPClientLib/{self.APP_VERSION}/Android/{self.device_properties["ro.build.version.release"]}/{self.device_metadata["model"]}',
                "Authorization": f"Bearer {self.access_token}"
            })
        else:
            headers["User-Agent"] = self.user_agent
            headers.update(self.sign_request(
                self.adp_token,
                self.device_private_key,
                method,
                url,
                data=data
            ))

        return self.session.request(
            method,
            url,
            params=params,
            data=data.encode("utf-8"),
            headers=headers
        )

    def refresh_access_token(self):
        data = {
            "app_name": self.APP_NAME,
            "app_version": self.APP_VERSION,
            "device_metadata": self.device_metadata,
            "map_version": {
                "client_metrics_integrated": True,
                "current_version": self.MAP_VERSION,
                "package_name": self.APP_NAME,
                "platform": "Android"
            }
        }
        if self.refresh_token:
            data.update({
                "requested_token_type": "access_token",
                "source_token": self.refresh_token,
                "source_token_type": "refresh_token"
            })
        else:
            data.update({
                "requested_token_type": "refresh_token",
                "source_token": "source_token",
                "source_token_type": "dms_token"
            })

        resp = self.audibleapi_request(
            "POST",
            "/auth/token",
            data=data
        )
        if not resp.ok:
            print(resp.json()["error_description"])
        resp.raise_for_status()
        now = parsedate_to_datetime(resp.headers["X-Amz-Date"])
        resp = resp.json()

        self.refresh_token = resp.get("refresh_token", self.refresh_token)
        self.access_token = resp["access_token"]
        self.access_token_expiry = now + timedelta(seconds=resp["expires_in"])

    @property
    def is_access_token_expired(self):
        return datetime.now(timezone.utc) >= self.access_token_expiry

    def licenserequest(self, asin, consumption_type="Download", spatial=False):
        assert consumption_type in ["Download", "Streaming"]
        codecs = [
            "mp4a.40.2"
        ]
        if spatial:
            codecs += [
                "ec+3",
                "ac-4"
            ]

        resp = self.audibleapi_request(
            "POST",
            f"/1.0/content/{asin}/licenserequest",
            data={
                "supported_media_features": {
                    "drm_types": [
                        "Widevine",
                        "Adrm",
                        "Mpeg"
                    ],
                    "codecs": codecs,
                    "chapter_titles_type": "Tree",
                    "previews": False,
                    "catalog_samples": False
                },
                "spatial": spatial,
                "consumption_type": consumption_type,
                "quality": "High",
                "response_groups": "content_reference,chapter_info,pdf_url,ad_insertion,narration_speed"
            }
        )
        resp.raise_for_status()
        return resp.json()

    def drmlicense_widevine(self, asin: str, challenge: bytes, consumption_type: str="Download") -> bytes:
        assert consumption_type in ["Download", "Streaming"]

        resp = self.audibleapi_request(
            "POST",
            f"/1.0/content/{asin}/drmlicense",
            data={
                "consumption_type": consumption_type,
                "drm_type": "Widevine",
                "licenseChallenge": base64.b64encode(challenge).decode()
            }
        )
        resp.raise_for_status()
        return base64.b64decode(resp.json()["license"])

def extract_widevine_pssh(client: Audible, manifest_url):
    user_agent = f'com.audible.playersdk.player/{client.APK_VERSION_NAME} (Linux;Android {client.device_properties["ro.build.version.release"]}) AndroidXMedia3/1.3.0'
    nsmap = {"mpd": "urn:mpeg:dash:schema:mpd:2011", "cenc": "urn:mpeg:cenc:2013"}

    resp = client.session.get(manifest_url, headers={"User-Agent": user_agent})
    resp.raise_for_status()
    manifest = ET.fromstring(resp.content)

    widevine_scheme_id_uri = Cdm.urn
    widevine_psshs = manifest.findall(f".//mpd:ContentProtection[@schemeIdUri='{widevine_scheme_id_uri}']/cenc:pssh", namespaces=nsmap)
    widevine_psshs = set([i.text.strip() for i in widevine_psshs])

    return widevine_psshs

if __name__ == "__main__":
    from pathlib import Path

    # adb shell getprop or whatever
    device_props = {
        "ro.build.fingerprint": "OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys",
        "ro.build.id": "RP1A.201005.001",
        "ro.build.version.release": "11",
        "ro.product.build.version.sdk": "30",
        "ro.product.manufacturer": "OnePlus",
        "ro.product.model": "IN2013",
        "ro.product.name": "OnePlus8"
    }
    region = "US"
    asin = "B0CY635C64"
    consumption_type = "Download"

    client = Audible(region, device_props)

    session_file_name = os.path.join(os.path.dirname(os.path.realpath(__file__)), f"{Path(__file__).stem}_{region.lower()}.session")

    # register a new device if necessary
    try:
        with open(session_file_name, "r") as f:
            session_data = json.load(f)

        client.load_credentials(session_data["audible"]["credentials"])
    except FileNotFoundError:
        login_url = client.start_login()
        credentials = client.register_device(
            input(
                f'Visit the following URL:\n{login_url}\nPaste the final URL after finishing sign in flow: '
            )
        )
        
        with open(session_file_name, "w") as f:
            json.dump({
                "audible": {
                    "credentials": credentials
                }
            }, f)

    resp = client.licenserequest(asin, consumption_type=consumption_type, spatial=True)

    content_license = resp["content_license"]
    codec = content_license["content_metadata"]["content_reference"]["codec"]
    manifest_url = content_license["license_response"]
    print(f'Manifest URL: {manifest_url}')

    assert content_license["drm_type"] == "Widevine"
    widevine_psshs = extract_widevine_pssh(client, manifest_url)

    # You will need an accepted CDM to run the code below
    cdm = Cdm.from_device(Device.load("audible.wvd"))
    assert cdm.system_id == 22435 # app does not seem to use L1 even if the device supports it
    session_id = cdm.open()

    challenge = cdm.get_license_challenge(
        session_id=session_id,
        pssh=PSSH(widevine_psshs.pop()),
        license_type="OFFLINE" if consumption_type == "Download" else "STREAMING",
        privacy_mode=False # matches with the actual behavior
    )

    print("Requesting decryption keys")
    license = client.drmlicense_widevine(asin, challenge, consumption_type=consumption_type)
    cdm.parse_license(session_id, license)
    keys = cdm.get_keys(session_id)
    keys = [key for key in keys if key.type == "CONTENT"]
    if keys:
        print("Keys:")
        print("\n".join([f"{key.kid.hex}:{key.key.hex()}" for key in keys]))

        # Usage of N_m3u8DL-RE is for demonstration only
        # consider implement the download + merge part in Python with multi-threaded download support
        # and use shaka-packager for decryption
        # FFmpeg does not support AC4 plus it cannot disable DASH probing (since the server requires the Range header but rejects "Range: bytes=0-")
        # so not recommended here
        print(f"Downloading and decrypting with N_m3u8DL-RE")
        subprocess.run(" ".join([
            "N_m3u8DL-RE", # Download from https://github.com/nilaoda/N_m3u8DL-RE/releases/latest and put it in PATH
            "--header",
            '"Range: bytes"', # HTTP 403 hackaround
            "--save-name",
            f"{asin}.{codec}",
            "--use-shaka-packager" # Download from https://github.com/shaka-project/shaka-packager/releases/latest and put it in PATH
        ] + [f"--key {key.kid.hex}:{key.key.hex()}" for key in keys] + [f"'{manifest_url}'"]), shell=True).check_returncode()

Due to the aforementioned reasons I'm not going to share the CDM in public (and by this design users are supposed to extract their own CDM; I prefer not to share any tutorials on this for now).
But everything until the DRM part should be now reproducible and hopefully this should help establishing a PR (I prefer to stay away from getting credited on this so not me).

@mkb79
Copy link
Owner

mkb79 commented Jul 9, 2024

@szescxz
Thank you for your hard work and that you share your findings with us.
I‘m working at a new feature , that will register and use an Android or iOS device of your choice. Your Script can work for Apple FairPlay too, if we can create a challenge for an Apple device, how do you do it in your code at this line challenge = cdm.get_license_challenge.

FYI:
I'm currently on holiday. So my reaction can take some time.

@szescxz
Copy link

szescxz commented Jul 10, 2024

Your Script can work for Apple FairPlay too, if we can create a challenge for an Apple device, how do you do it in your code at this line challenge = cdm.get_license_challenge.

DASH manifests given to the Android side does not seem to contain FairPlay's UUID. The only available ones are Widevine and PlayReady (see https://dashif.org/identifiers/content_protection/ for a list of UUIDs), so I believe FairPlay DRM is restricted to HLS manifests only which means everything is within Apple's ecosystem. The concept should be somewhat similar, though, if your goal is to refactor the codebase to support various DRM systems via external modules/libraries.

I picked Widevine because

  • I do not have any iDevices to poke with
  • pywidevine is open source and available via pip; to my knowledge there are no similar projects for other Hollywood-grade DRM systems that gets open-sourced

Therefore I'm unable to offer help on other DRM systems.

@DrJapan
Copy link

DrJapan commented Jul 11, 2024

I've used an application, such as Downie, to download the full mp4 file, but they are all encrypted. I'm not sure if this helps, or make anything easier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

6 participants