Installable from Pip and use in CMD

This commit is contained in:
Bipin 2023-08-28 20:17:34 +05:30
parent 4e61897e17
commit eb7d65bab5
18 changed files with 520 additions and 470 deletions

View file

@ -32,7 +32,7 @@ jobs:
- name: Build Executable - name: Build Executable
uses: Nuitka/Nuitka-Action@main uses: Nuitka/Nuitka-Action@main
with: with:
script-name: DeGourou.py script-name: DeGourou/DeGourou.py
onefile: true onefile: true
standalone: true standalone: true

29
DeGourou.py → DeGourou/DeGourou.py Executable file → Normal file
View file

@ -1,18 +1,18 @@
#!/usr/bin/env python #!/usr/bin/env python
from setup.loginAccount import loginAndGetKey from .setup.loginAccount import loginAndGetKey
from setup.fulfill import downloadFile from .setup.fulfill import downloadFile
from decrypt.decodePDF import decryptPDF from .decrypt.decodePDF import decryptPDF
from decrypt.decodeEPUB import decryptEPUB from .decrypt.decodeEPUB import decryptEPUB
import argparse import argparse
from os import mkdir, remove, rename from os import mkdir, remove, rename
from os.path import exists from os.path import exists
from setup.params import FILE_DEVICEKEY, FILE_DEVICEXML, FILE_ACTIVATIONXML from .setup.params import FILE_DEVICEKEY, FILE_DEVICEXML, FILE_ACTIVATIONXML
from decrypt.params import KEYPATH from .decrypt.params import KEYPATH
from setup.data import createDefaultFiles from .setup.data import createDefaultFiles
from setup.ia import SESSION_FILE, manage_login, get_book, return_book from .setup.ia import SESSION_FILE, manage_login, get_book, return_book
def loginADE(email, password): def loginADE(email, password):
@ -32,7 +32,7 @@ def loginIA(email,password):
manage_login(email,password) manage_login(email,password)
print() print()
def main(acsmFile, outputFilename): def start(acsmFile, outputFilename):
if not exists('account'): mkdir('account') if not exists('account'): mkdir('account')
# setting up the account and keys # setting up the account and keys
@ -81,13 +81,13 @@ def handle_IA(url,format):
if acsmFile is None: if acsmFile is None:
print("Could not get Book, try using ACSm file as input") print("Could not get Book, try using ACSm file as input")
return return
main(acsmFile,None) start(acsmFile,None)
remove(acsmFile) remove(acsmFile)
if(return_book(url) is None): if(return_book(url) is None):
print("Please return it yourself") print("Please return it yourself")
if __name__ == "__main__": def main():
parser = argparse.ArgumentParser(description="Download and Decrypt an encrypted PDF or EPUB file.") parser = argparse.ArgumentParser(description="Download and Decrypt an encrypted PDF or EPUB file.")
parser.add_argument("-f", type=str, nargs='?', default=None, help="path to the ACSM file") parser.add_argument("-f", type=str, nargs='?', default=None, help="path to the ACSM file")
parser.add_argument("-u", type=str, nargs='?', default=None, help="book url from InternetArchive") parser.add_argument("-u", type=str, nargs='?', default=None, help="book url from InternetArchive")
@ -134,8 +134,11 @@ if __name__ == "__main__":
elif args.f == None: elif args.f == None:
if exists("URLLink.acsm"): if exists("URLLink.acsm"):
args.f = "URLLink.acsm" args.f = "URLLink.acsm"
main(args.f, args.o) start(args.f, args.o)
else: parser.print_help() else: parser.print_help()
else: else:
main(args.f, args.o) start(args.f, args.o)
if __name__ == "__main__": main()

View file

@ -1,313 +1,313 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# ineptepub.py # ineptepub.py
# Copyright © 2009-2022 by i♥cabbages, Apprentice Harper et al. # Copyright © 2009-2022 by i♥cabbages, Apprentice Harper et al.
# Released under the terms of the GNU General Public Licence, version 3 # Released under the terms of the GNU General Public Licence, version 3
# <http://www.gnu.org/licenses/> # <http://www.gnu.org/licenses/>
""" """
Decrypt Adobe Digital Editions encrypted ePub books. Decrypt Adobe Digital Editions encrypted ePub books.
""" """
from decrypt.params import KEYPATH from .params import KEYPATH
__license__ = 'GPL v3' __license__ = 'GPL v3'
__version__ = "8.0" __version__ = "8.0"
import sys import sys
import os import os
import traceback import traceback
import base64 import base64
import zlib import zlib
from zipfile import ZipInfo, ZipFile, ZIP_STORED, ZIP_DEFLATED from zipfile import ZipInfo, ZipFile, ZIP_STORED, ZIP_DEFLATED
from decrypt.zeroedzipinfo import ZeroedZipInfo from .zeroedzipinfo import ZeroedZipInfo
from contextlib import closing from contextlib import closing
from lxml import etree from lxml import etree
from uuid import UUID from uuid import UUID
import hashlib import hashlib
try: try:
from Cryptodome.Cipher import AES, PKCS1_v1_5 from Cryptodome.Cipher import AES, PKCS1_v1_5
from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey import RSA
except ImportError: except ImportError:
from Crypto.Cipher import AES, PKCS1_v1_5 from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
def unpad(data, padding=16): def unpad(data, padding=16):
if sys.version_info[0] == 2: if sys.version_info[0] == 2:
pad_len = ord(data[-1]) pad_len = ord(data[-1])
else: else:
pad_len = data[-1] pad_len = data[-1]
return data[:-pad_len] return data[:-pad_len]
class ADEPTError(Exception): class ADEPTError(Exception):
pass pass
class ADEPTNewVersionError(Exception): class ADEPTNewVersionError(Exception):
pass pass
META_NAMES = ('mimetype', 'META-INF/rights.xml') META_NAMES = ('mimetype', 'META-INF/rights.xml')
NSMAP = {'adept': 'http://ns.adobe.com/adept', NSMAP = {'adept': 'http://ns.adobe.com/adept',
'enc': 'http://www.w3.org/2001/04/xmlenc#'} 'enc': 'http://www.w3.org/2001/04/xmlenc#'}
class Decryptor(object): class Decryptor(object):
def __init__(self, bookkey, encryption): def __init__(self, bookkey, encryption):
enc = lambda tag: '{%s}%s' % (NSMAP['enc'], tag) enc = lambda tag: '{%s}%s' % (NSMAP['enc'], tag)
self._aes = AES.new(bookkey, AES.MODE_CBC, b'\x00'*16) self._aes = AES.new(bookkey, AES.MODE_CBC, b'\x00'*16)
self._encryption = etree.fromstring(encryption) self._encryption = etree.fromstring(encryption)
self._encrypted = encrypted = set() self._encrypted = encrypted = set()
self._encryptedForceNoDecomp = encryptedForceNoDecomp = set() self._encryptedForceNoDecomp = encryptedForceNoDecomp = set()
self._otherData = otherData = set() self._otherData = otherData = set()
self._json_elements_to_remove = json_elements_to_remove = set() self._json_elements_to_remove = json_elements_to_remove = set()
self._has_remaining_xml = False self._has_remaining_xml = False
expr = './%s/%s/%s' % (enc('EncryptedData'), enc('CipherData'), expr = './%s/%s/%s' % (enc('EncryptedData'), enc('CipherData'),
enc('CipherReference')) enc('CipherReference'))
for elem in self._encryption.findall(expr): for elem in self._encryption.findall(expr):
path = elem.get('URI', None) path = elem.get('URI', None)
encryption_type_url = (elem.getparent().getparent().find("./%s" % (enc('EncryptionMethod'))).get('Algorithm', None)) encryption_type_url = (elem.getparent().getparent().find("./%s" % (enc('EncryptionMethod'))).get('Algorithm', None))
if path is not None: if path is not None:
if (encryption_type_url == "http://www.w3.org/2001/04/xmlenc#aes128-cbc"): if (encryption_type_url == "http://www.w3.org/2001/04/xmlenc#aes128-cbc"):
# Adobe # Adobe
path = path.encode('utf-8') path = path.encode('utf-8')
encrypted.add(path) encrypted.add(path)
json_elements_to_remove.add(elem.getparent().getparent()) json_elements_to_remove.add(elem.getparent().getparent())
elif (encryption_type_url == "http://ns.adobe.com/adept/xmlenc#aes128-cbc-uncompressed"): elif (encryption_type_url == "http://ns.adobe.com/adept/xmlenc#aes128-cbc-uncompressed"):
# Adobe uncompressed, for stuff like video files # Adobe uncompressed, for stuff like video files
path = path.encode('utf-8') path = path.encode('utf-8')
encryptedForceNoDecomp.add(path) encryptedForceNoDecomp.add(path)
json_elements_to_remove.add(elem.getparent().getparent()) json_elements_to_remove.add(elem.getparent().getparent())
else: else:
path = path.encode('utf-8') path = path.encode('utf-8')
otherData.add(path) otherData.add(path)
self._has_remaining_xml = True self._has_remaining_xml = True
for elem in json_elements_to_remove: for elem in json_elements_to_remove:
elem.getparent().remove(elem) elem.getparent().remove(elem)
def check_if_remaining(self): def check_if_remaining(self):
return self._has_remaining_xml return self._has_remaining_xml
def get_xml(self): def get_xml(self):
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + etree.tostring(self._encryption, encoding="utf-8", pretty_print=True, xml_declaration=False).decode("utf-8") return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + etree.tostring(self._encryption, encoding="utf-8", pretty_print=True, xml_declaration=False).decode("utf-8")
def decompress(self, bytes): def decompress(self, bytes):
dc = zlib.decompressobj(-15) dc = zlib.decompressobj(-15)
try: try:
decompressed_bytes = dc.decompress(bytes) decompressed_bytes = dc.decompress(bytes)
ex = dc.decompress(b'Z') + dc.flush() ex = dc.decompress(b'Z') + dc.flush()
if ex: if ex:
decompressed_bytes = decompressed_bytes + ex decompressed_bytes = decompressed_bytes + ex
except: except:
# possibly not compressed by zip - just return bytes # possibly not compressed by zip - just return bytes
return bytes return bytes
return decompressed_bytes return decompressed_bytes
def decrypt(self, path, data): def decrypt(self, path, data):
if path.encode('utf-8') in self._encrypted or path.encode('utf-8') in self._encryptedForceNoDecomp: if path.encode('utf-8') in self._encrypted or path.encode('utf-8') in self._encryptedForceNoDecomp:
data = self._aes.decrypt(data)[16:] data = self._aes.decrypt(data)[16:]
if type(data[-1]) != int: if type(data[-1]) != int:
place = ord(data[-1]) place = ord(data[-1])
else: else:
place = data[-1] place = data[-1]
data = data[:-place] data = data[:-place]
if not path.encode('utf-8') in self._encryptedForceNoDecomp: if not path.encode('utf-8') in self._encryptedForceNoDecomp:
data = self.decompress(data) data = self.decompress(data)
return data return data
# check file to make check whether it's probably an Adobe Adept encrypted ePub # check file to make check whether it's probably an Adobe Adept encrypted ePub
def adeptBook(inpath): def adeptBook(inpath):
with closing(ZipFile(open(inpath, 'rb'))) as inf: with closing(ZipFile(open(inpath, 'rb'))) as inf:
namelist = set(inf.namelist()) namelist = set(inf.namelist())
if 'META-INF/rights.xml' not in namelist or \ if 'META-INF/rights.xml' not in namelist or \
'META-INF/encryption.xml' not in namelist: 'META-INF/encryption.xml' not in namelist:
return False return False
try: try:
rights = etree.fromstring(inf.read('META-INF/rights.xml')) rights = etree.fromstring(inf.read('META-INF/rights.xml'))
adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag) adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag)
expr = './/%s' % (adept('encryptedKey'),) expr = './/%s' % (adept('encryptedKey'),)
bookkey = ''.join(rights.findtext(expr)) bookkey = ''.join(rights.findtext(expr))
if len(bookkey) in [192, 172, 64]: if len(bookkey) in [192, 172, 64]:
return True return True
except: except:
# if we couldn't check, assume it is # if we couldn't check, assume it is
return True return True
return False return False
def isPassHashBook(inpath): def isPassHashBook(inpath):
# If this is an Adobe book, check if it's a PassHash-encrypted book (B&N) # If this is an Adobe book, check if it's a PassHash-encrypted book (B&N)
with closing(ZipFile(open(inpath, 'rb'))) as inf: with closing(ZipFile(open(inpath, 'rb'))) as inf:
namelist = set(inf.namelist()) namelist = set(inf.namelist())
if 'META-INF/rights.xml' not in namelist or \ if 'META-INF/rights.xml' not in namelist or \
'META-INF/encryption.xml' not in namelist: 'META-INF/encryption.xml' not in namelist:
return False return False
try: try:
rights = etree.fromstring(inf.read('META-INF/rights.xml')) rights = etree.fromstring(inf.read('META-INF/rights.xml'))
adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag) adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag)
expr = './/%s' % (adept('encryptedKey'),) expr = './/%s' % (adept('encryptedKey'),)
bookkey = ''.join(rights.findtext(expr)) bookkey = ''.join(rights.findtext(expr))
if len(bookkey) == 64: if len(bookkey) == 64:
return True return True
except: except:
pass pass
return False return False
# Checks the license file and returns the UUID the book is licensed for. # Checks the license file and returns the UUID the book is licensed for.
# This is used so that the Calibre plugin can pick the correct decryption key # This is used so that the Calibre plugin can pick the correct decryption key
# first try without having to loop through all possible keys. # first try without having to loop through all possible keys.
def adeptGetUserUUID(inpath): def adeptGetUserUUID(inpath):
with closing(ZipFile(open(inpath, 'rb'))) as inf: with closing(ZipFile(open(inpath, 'rb'))) as inf:
try: try:
rights = etree.fromstring(inf.read('META-INF/rights.xml')) rights = etree.fromstring(inf.read('META-INF/rights.xml'))
adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag) adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag)
expr = './/%s' % (adept('user'),) expr = './/%s' % (adept('user'),)
user_uuid = ''.join(rights.findtext(expr)) user_uuid = ''.join(rights.findtext(expr))
if user_uuid[:9] != "urn:uuid:": if user_uuid[:9] != "urn:uuid:":
return None return None
return user_uuid[9:] return user_uuid[9:]
except: except:
return None return None
def removeHardening(rights, keytype, keydata): def removeHardening(rights, keytype, keydata):
adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag) adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag)
textGetter = lambda name: ''.join(rights.findtext('.//%s' % (adept(name),))) textGetter = lambda name: ''.join(rights.findtext('.//%s' % (adept(name),)))
# Gather what we need, and generate the IV # Gather what we need, and generate the IV
resourceuuid = UUID(textGetter("resource")) resourceuuid = UUID(textGetter("resource"))
deviceuuid = UUID(textGetter("device")) deviceuuid = UUID(textGetter("device"))
fullfillmentuuid = UUID(textGetter("fulfillment")[:36]) fullfillmentuuid = UUID(textGetter("fulfillment")[:36])
kekiv = UUID(int=resourceuuid.int ^ deviceuuid.int ^ fullfillmentuuid.int).bytes kekiv = UUID(int=resourceuuid.int ^ deviceuuid.int ^ fullfillmentuuid.int).bytes
# Derive kek from just "keytype" # Derive kek from just "keytype"
rem = int(keytype, 10) % 16 rem = int(keytype, 10) % 16
H = hashlib.sha256(keytype.encode("ascii")).digest() H = hashlib.sha256(keytype.encode("ascii")).digest()
kek = H[2*rem : 16 + rem] + H[rem : 2*rem] kek = H[2*rem : 16 + rem] + H[rem : 2*rem]
return unpad(AES.new(kek, AES.MODE_CBC, kekiv).decrypt(keydata), 16) # PKCS#7 return unpad(AES.new(kek, AES.MODE_CBC, kekiv).decrypt(keydata), 16) # PKCS#7
def decryptBook(userkey, inpath, outpath): def decryptBook(userkey, inpath, outpath):
with closing(ZipFile(open(inpath, 'rb'))) as inf: with closing(ZipFile(open(inpath, 'rb'))) as inf:
namelist = inf.namelist() namelist = inf.namelist()
if 'META-INF/rights.xml' not in namelist or \ if 'META-INF/rights.xml' not in namelist or \
'META-INF/encryption.xml' not in namelist: 'META-INF/encryption.xml' not in namelist:
print("{0:s} is DRM-free.".format(os.path.basename(inpath))) print("{0:s} is DRM-free.".format(os.path.basename(inpath)))
return 1 return 1
for name in META_NAMES: for name in META_NAMES:
namelist.remove(name) namelist.remove(name)
try: try:
rights = etree.fromstring(inf.read('META-INF/rights.xml')) rights = etree.fromstring(inf.read('META-INF/rights.xml'))
adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag) adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag)
expr = './/%s' % (adept('encryptedKey'),) expr = './/%s' % (adept('encryptedKey'),)
bookkeyelem = rights.find(expr) bookkeyelem = rights.find(expr)
bookkey = bookkeyelem.text bookkey = bookkeyelem.text
keytype = bookkeyelem.attrib.get('keyType', '0') keytype = bookkeyelem.attrib.get('keyType', '0')
if len(bookkey) >= 172 and int(keytype, 10) > 2: if len(bookkey) >= 172 and int(keytype, 10) > 2:
print("{0:s} is a secure Adobe Adept ePub with hardening.".format(os.path.basename(inpath))) print("{0:s} is a secure Adobe Adept ePub with hardening.".format(os.path.basename(inpath)))
elif len(bookkey) == 172: elif len(bookkey) == 172:
print("{0:s} is a secure Adobe Adept ePub.".format(os.path.basename(inpath))) print("{0:s} is a secure Adobe Adept ePub.".format(os.path.basename(inpath)))
elif len(bookkey) == 64: elif len(bookkey) == 64:
print("{0:s} is a secure Adobe PassHash (B&N) ePub.".format(os.path.basename(inpath))) print("{0:s} is a secure Adobe PassHash (B&N) ePub.".format(os.path.basename(inpath)))
else: else:
print("{0:s} is not an Adobe-protected ePub!".format(os.path.basename(inpath))) print("{0:s} is not an Adobe-protected ePub!".format(os.path.basename(inpath)))
return 1 return 1
if len(bookkey) != 64: if len(bookkey) != 64:
# Normal or "hardened" Adobe ADEPT # Normal or "hardened" Adobe ADEPT
rsakey = RSA.importKey(userkey) # parses the ASN1 structure rsakey = RSA.importKey(userkey) # parses the ASN1 structure
bookkey = base64.b64decode(bookkey) bookkey = base64.b64decode(bookkey)
if int(keytype, 10) > 2: if int(keytype, 10) > 2:
bookkey = removeHardening(rights, keytype, bookkey) bookkey = removeHardening(rights, keytype, bookkey)
try: try:
bookkey = PKCS1_v1_5.new(rsakey).decrypt(bookkey, None) # automatically unpads bookkey = PKCS1_v1_5.new(rsakey).decrypt(bookkey, None) # automatically unpads
except ValueError: except ValueError:
bookkey = None bookkey = None
if bookkey is None: if bookkey is None:
print("Could not decrypt {0:s}. Wrong key".format(os.path.basename(inpath))) print("Could not decrypt {0:s}. Wrong key".format(os.path.basename(inpath)))
return 2 return 2
else: else:
# Adobe PassHash / B&N # Adobe PassHash / B&N
key = base64.b64decode(userkey)[:16] key = base64.b64decode(userkey)[:16]
bookkey = base64.b64decode(bookkey) bookkey = base64.b64decode(bookkey)
bookkey = unpad(AES.new(key, AES.MODE_CBC, b'\x00'*16).decrypt(bookkey), 16) # PKCS#7 bookkey = unpad(AES.new(key, AES.MODE_CBC, b'\x00'*16).decrypt(bookkey), 16) # PKCS#7
if len(bookkey) > 16: if len(bookkey) > 16:
bookkey = bookkey[-16:] bookkey = bookkey[-16:]
encryption = inf.read('META-INF/encryption.xml') encryption = inf.read('META-INF/encryption.xml')
decryptor = Decryptor(bookkey, encryption) decryptor = Decryptor(bookkey, encryption)
kwds = dict(compression=ZIP_DEFLATED, allowZip64=False) kwds = dict(compression=ZIP_DEFLATED, allowZip64=False)
with closing(ZipFile(open(outpath, 'wb'), 'w', **kwds)) as outf: with closing(ZipFile(open(outpath, 'wb'), 'w', **kwds)) as outf:
for path in (["mimetype"] + namelist): for path in (["mimetype"] + namelist):
data = inf.read(path) data = inf.read(path)
zi = ZipInfo(path) zi = ZipInfo(path)
zi.compress_type=ZIP_DEFLATED zi.compress_type=ZIP_DEFLATED
if path == "mimetype": if path == "mimetype":
zi.compress_type = ZIP_STORED zi.compress_type = ZIP_STORED
elif path == "META-INF/encryption.xml": elif path == "META-INF/encryption.xml":
# Check if there's still something in there # Check if there's still something in there
if (decryptor.check_if_remaining()): if (decryptor.check_if_remaining()):
data = decryptor.get_xml() data = decryptor.get_xml()
print("Adding encryption.xml for the remaining embedded files.") print("Adding encryption.xml for the remaining embedded files.")
# We removed DRM, but there's still stuff like obfuscated fonts. # We removed DRM, but there's still stuff like obfuscated fonts.
else: else:
continue continue
try: try:
# get the file info, including time-stamp # get the file info, including time-stamp
oldzi = inf.getinfo(path) oldzi = inf.getinfo(path)
# copy across useful fields # copy across useful fields
zi.date_time = oldzi.date_time zi.date_time = oldzi.date_time
zi.comment = oldzi.comment zi.comment = oldzi.comment
zi.extra = oldzi.extra zi.extra = oldzi.extra
zi.internal_attr = oldzi.internal_attr zi.internal_attr = oldzi.internal_attr
# external attributes are dependent on the create system, so copy both. # external attributes are dependent on the create system, so copy both.
zi.external_attr = oldzi.external_attr zi.external_attr = oldzi.external_attr
zi.volume = oldzi.volume zi.volume = oldzi.volume
zi.create_system = oldzi.create_system zi.create_system = oldzi.create_system
zi.create_version = oldzi.create_version zi.create_version = oldzi.create_version
if any(ord(c) >= 128 for c in path) or any(ord(c) >= 128 for c in zi.comment): if any(ord(c) >= 128 for c in path) or any(ord(c) >= 128 for c in zi.comment):
# If the file name or the comment contains any non-ASCII char, set the UTF8-flag # If the file name or the comment contains any non-ASCII char, set the UTF8-flag
zi.flag_bits |= 0x800 zi.flag_bits |= 0x800
except: except:
pass pass
# Python 3 has a bug where the external_attr is reset to `0o600 << 16` # Python 3 has a bug where the external_attr is reset to `0o600 << 16`
# if it's NULL, so we need a workaround: # if it's NULL, so we need a workaround:
if zi.external_attr == 0: if zi.external_attr == 0:
zi = ZeroedZipInfo(zi) zi = ZeroedZipInfo(zi)
if path == "META-INF/encryption.xml": if path == "META-INF/encryption.xml":
outf.writestr(zi, data) outf.writestr(zi, data)
else: else:
outf.writestr(zi, decryptor.decrypt(path, data)) outf.writestr(zi, decryptor.decrypt(path, data))
except: except:
print("Could not decrypt {0:s} because of an exception:\n{1:s}".format(os.path.basename(inpath), traceback.format_exc())) print("Could not decrypt {0:s} because of an exception:\n{1:s}".format(os.path.basename(inpath), traceback.format_exc()))
return 2 return 2
return 0 return 0
def decryptEPUB(inpath): def decryptEPUB(inpath):
keypath = KEYPATH keypath = KEYPATH
outpath = os.path.basename(inpath).removesuffix(".epub") + "_decrypted.epub" outpath = os.path.basename(inpath).removesuffix(".epub") + "_decrypted.epub"
userkey = open(keypath,'rb').read() userkey = open(keypath,'rb').read()
result = decryptBook(userkey, inpath, outpath) result = decryptBook(userkey, inpath, outpath)
if result == 0: if result == 0:
print("Successfully decrypted") print("Successfully decrypted")
return outpath return outpath
else: else:
print("Decryption failed") print("Decryption failed")
return None return None

View file

@ -13,7 +13,7 @@
Decrypts Adobe ADEPT-encrypted PDF files. Decrypts Adobe ADEPT-encrypted PDF files.
""" """
from decrypt.params import KEYPATH from .params import KEYPATH
__license__ = 'GPL v3' __license__ = 'GPL v3'
__version__ = "10.0.4" __version__ = "10.0.4"

View file

@ -1,118 +1,118 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
''' '''
Use my own small RSA code so we don't have to include the huge Use my own small RSA code so we don't have to include the huge
python3-rsa just for these small bits. python3-rsa just for these small bits.
The original code used blinding and this one doesn't, The original code used blinding and this one doesn't,
but we don't really care about side-channel attacks ... but we don't really care about side-channel attacks ...
''' '''
import sys import sys
try: try:
from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey import RSA
except ImportError: except ImportError:
# Some distros still ship this as Crypto # Some distros still ship this as Crypto
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
class CustomRSA: class CustomRSA:
@staticmethod @staticmethod
def encrypt_for_adobe_signature(signing_key, message): def encrypt_for_adobe_signature(signing_key, message):
key = RSA.importKey(signing_key) key = RSA.importKey(signing_key)
keylen = CustomRSA.byte_size(key.n) keylen = CustomRSA.byte_size(key.n)
padded = CustomRSA.pad_message(message, keylen) padded = CustomRSA.pad_message(message, keylen)
payload = CustomRSA.transform_bytes2int(padded) payload = CustomRSA.transform_bytes2int(padded)
encrypted = CustomRSA.normal_encrypt(key, payload) encrypted = CustomRSA.normal_encrypt(key, payload)
block = CustomRSA.transform_int2bytes(encrypted, keylen) block = CustomRSA.transform_int2bytes(encrypted, keylen)
return bytearray(block) return bytearray(block)
@staticmethod @staticmethod
def byte_size(number): def byte_size(number):
# type: (int) -> int # type: (int) -> int
return (number.bit_length() + 7) // 8 return (number.bit_length() + 7) // 8
@staticmethod @staticmethod
def pad_message(message, target_len): def pad_message(message, target_len):
# type: (bytes, int) -> bytes # type: (bytes, int) -> bytes
# Padding always uses 0xFF # Padding always uses 0xFF
# Returns: 00 01 PADDING 00 MESSAGE # Returns: 00 01 PADDING 00 MESSAGE
max_message_length = target_len - 11 max_message_length = target_len - 11
message_length = len(message) message_length = len(message)
if message_length > max_message_length: if message_length > max_message_length:
raise OverflowError("Message too long, has %d bytes but only space for %d" % (message_length, max_message_length)) raise OverflowError("Message too long, has %d bytes but only space for %d" % (message_length, max_message_length))
padding_len = target_len - message_length - 3 padding_len = target_len - message_length - 3
ret = bytearray(b"".join([b"\x00\x01", padding_len * b"\xff", b"\x00"])) ret = bytearray(b"".join([b"\x00\x01", padding_len * b"\xff", b"\x00"]))
ret.extend(bytes(message)) ret.extend(bytes(message))
return ret return ret
@staticmethod @staticmethod
def normal_encrypt(key, message): def normal_encrypt(key, message):
if message < 0 or message > key.n: if message < 0 or message > key.n:
raise ValueError("Invalid message") raise ValueError("Invalid message")
encrypted = pow(message, key.d, key.n) encrypted = pow(message, key.d, key.n)
return encrypted return encrypted
@staticmethod @staticmethod
def py2_int_to_bytes(value, length, big_endian = True): def py2_int_to_bytes(value, length, big_endian = True):
result = [] result = []
for i in range(0, length): for i in range(0, length):
result.append(value >> (i * 8) & 0xff) result.append(value >> (i * 8) & 0xff)
if big_endian: if big_endian:
result.reverse() result.reverse()
return result return result
@staticmethod @staticmethod
def py2_bytes_to_int(bytes, big_endian = True): def py2_bytes_to_int(bytes, big_endian = True):
# type: (bytes, bool) -> int # type: (bytes, bool) -> int
my_bytes = bytes my_bytes = bytes
if not big_endian: if not big_endian:
my_bytes.reverse() my_bytes.reverse()
result = 0 result = 0
for b in my_bytes: for b in my_bytes:
result = result * 256 + int(b) result = result * 256 + int(b)
return result return result
@staticmethod @staticmethod
def transform_bytes2int(raw_bytes): def transform_bytes2int(raw_bytes):
# type: (bytes) -> int # type: (bytes) -> int
if sys.version_info[0] >= 3: if sys.version_info[0] >= 3:
return int.from_bytes(raw_bytes, "big", signed=False) return int.from_bytes(raw_bytes, "big", signed=False)
return CustomRSA.py2_bytes_to_int(raw_bytes, True) return CustomRSA.py2_bytes_to_int(raw_bytes, True)
@staticmethod @staticmethod
def transform_int2bytes(number, fill_size = 0): def transform_int2bytes(number, fill_size = 0):
# type: (int, int) -> bytes # type: (int, int) -> bytes
if number < 0: if number < 0:
raise ValueError("Negative number") raise ValueError("Negative number")
size = None size = None
if fill_size > 0: if fill_size > 0:
size = fill_size size = fill_size
else: else:
size = max(1, CustomRSA.byte_size(number)) size = max(1, CustomRSA.byte_size(number))
if sys.version_info[0] >= 3: if sys.version_info[0] >= 3:
return number.to_bytes(size, "big") return number.to_bytes(size, "big")
return CustomRSA.py2_int_to_bytes(number, size, True) return CustomRSA.py2_int_to_bytes(number, size, True)

View file

@ -1,5 +1,5 @@
from setup.params import FILE_DEVICEKEY, FILE_DEVICEXML, FILE_ACTIVATIONXML from .params import FILE_DEVICEKEY, FILE_DEVICEXML, FILE_ACTIVATIONXML
from decrypt.params import KEYPATH from ..decrypt.params import KEYPATH
keyContent = b'0\x82\x02\\\x02\x01\x00\x02\x81\x81\x00\xad*E\x8e0\n\x91\xd6\xbaj\xc1t3\xc2R2h\xa6\x18\x063i\xfd\x9bR/e\xa6\xec\x87\xab\x11\n\'\xb7\x93\x14\xb6\xbbm\xfa\xf0\xf4\xe8=\x18\xa6\xe9\x15$\xdao\xb3\x8d\xf5\xddT\n\xf5\t<\xe8\xb2\x93k\x02zi\xe6\x86\x10F\x13\xc9m\xcfZ\x83\xe6=\xd6G\xf2/]3\xff\x8ch#\xea|\xa9I\x9a\xf6\xbf\x19\xd9\x10\xe0\x18\xa1\rb\x801k~\xc03f\x84\x07{v\x88\x18\x9bH\x91+o \x90\x9b\xb7\xf5\x02\x03\x01\x00\x01\x02\x81\x80\x05\xfd\x95\xd3\x886\x9a\xba\x8ck\xc1\xb5\xc21\x86\xab\x1a\xa8^\x1af%\x9b\x8a\xc0\x96\xc6\x10}\xb6\xf6\xeb\x80\xc4R\xc2@\x9d\xf9F\xa1\xf7\xe6\x06jPs\xad\xc3w\xd3\xea\xb7\xca\xec\x03\x17\xcf\xff\x01u\x96\x15\n\x0e&\xb0\xc7\x90F\xc4\xdaZ"\xc1)>\xee\x19\xf6\x05\xa5\xba\x00H)\xa8>\x1fC\x02\xd3\xba\xa8){\x06^D\xb4\xfd"\x05\x05\xec\xef\xdb.tbZ8\xabU<,+\xb6\xfaI\x98\xcc7H\xedr\xa9\xfd\x02A\x00\xc27%\xc5\xa0\xff\xd5l\xaa\x7f=\x1dx\xab?\xd8~\xf7v\x1f!\x0cCh\xc9\xb4\x1a\x8b\xb2\xaeC\xa0\xf9\x91\xcc\x99<\x11\xfbQ\xae\x8fG\xb0\xd1b\x0c=\xebR\x19\xb4\x15\xd4\x1c\xbe\xf4\xc7E\xe8\xea\xe1\xb3\x0b\x02A\x00\xe4@\xcb(\xdd\x04F\xe4jT\xe5a\xaaj\xaf=F\xa1\xaf\x1c\xa6F\x93\xc7V1\xd9\xb1\x96\xdb\x1b\xf5\x86\r\xb11\x10\x12\x18\xc5\xee\xaeD\xa3\xc1/\xe3\xf2\x8f\xaf\xad\xda\xe6\t\x8d\x9d\x99z\x04\xeeK\xdb \xff\x02A\x00\xad_\x9d\x90v\xd0\xeb->f\xa7\xa0\x0f\x80\x90V+\xc1\xac\xe8\xcd\x0f\xad}u\xd2\x19\x80k\xd9\xb4\xf5\x96\xd4\xd8\xd8R\x0f\x9bR\xa7\x89\xb0m\xdf\xfc\xaf\x00\xf7y+\x08\xe0\x13\xa25\xb5=\xce\xe2\xc6\x0b\x05Q\x02@\x18\xee\xf7\x02\\\xbaU\xe0\'\xb9da9\xd3s\x97\x16\xfb\x1c|\xdd\xb1\x01\xfd\x99m\xd2\xa0\xf2\xa0\xb6\xba(M\xa0\x98\x82o\xe7\xa2\xdf\x82\xcb\xde\xb3\x80\xbe\xbe\xc5qdep\x11\x85\x15\xbd)6\x16\xad\xd4\x9f\x13\x02@\x0f\x15\xc1Y"b\x19\x81Q\x81\x8d\x006\xe4\xf0e\xa2\xa7\xb8\x98{\x1c\x12\xe0\nw\xbe\x86A-\xd0\x1c7\xf3\x169\xadd3\x85\xaf\x13\x99\x08\x97e)c\xaf\xb1V\xf1\x15\xf6K\r\x16\xb4\xf9\xd1\x10\xe2\x92\xf9' keyContent = b'0\x82\x02\\\x02\x01\x00\x02\x81\x81\x00\xad*E\x8e0\n\x91\xd6\xbaj\xc1t3\xc2R2h\xa6\x18\x063i\xfd\x9bR/e\xa6\xec\x87\xab\x11\n\'\xb7\x93\x14\xb6\xbbm\xfa\xf0\xf4\xe8=\x18\xa6\xe9\x15$\xdao\xb3\x8d\xf5\xddT\n\xf5\t<\xe8\xb2\x93k\x02zi\xe6\x86\x10F\x13\xc9m\xcfZ\x83\xe6=\xd6G\xf2/]3\xff\x8ch#\xea|\xa9I\x9a\xf6\xbf\x19\xd9\x10\xe0\x18\xa1\rb\x801k~\xc03f\x84\x07{v\x88\x18\x9bH\x91+o \x90\x9b\xb7\xf5\x02\x03\x01\x00\x01\x02\x81\x80\x05\xfd\x95\xd3\x886\x9a\xba\x8ck\xc1\xb5\xc21\x86\xab\x1a\xa8^\x1af%\x9b\x8a\xc0\x96\xc6\x10}\xb6\xf6\xeb\x80\xc4R\xc2@\x9d\xf9F\xa1\xf7\xe6\x06jPs\xad\xc3w\xd3\xea\xb7\xca\xec\x03\x17\xcf\xff\x01u\x96\x15\n\x0e&\xb0\xc7\x90F\xc4\xdaZ"\xc1)>\xee\x19\xf6\x05\xa5\xba\x00H)\xa8>\x1fC\x02\xd3\xba\xa8){\x06^D\xb4\xfd"\x05\x05\xec\xef\xdb.tbZ8\xabU<,+\xb6\xfaI\x98\xcc7H\xedr\xa9\xfd\x02A\x00\xc27%\xc5\xa0\xff\xd5l\xaa\x7f=\x1dx\xab?\xd8~\xf7v\x1f!\x0cCh\xc9\xb4\x1a\x8b\xb2\xaeC\xa0\xf9\x91\xcc\x99<\x11\xfbQ\xae\x8fG\xb0\xd1b\x0c=\xebR\x19\xb4\x15\xd4\x1c\xbe\xf4\xc7E\xe8\xea\xe1\xb3\x0b\x02A\x00\xe4@\xcb(\xdd\x04F\xe4jT\xe5a\xaaj\xaf=F\xa1\xaf\x1c\xa6F\x93\xc7V1\xd9\xb1\x96\xdb\x1b\xf5\x86\r\xb11\x10\x12\x18\xc5\xee\xaeD\xa3\xc1/\xe3\xf2\x8f\xaf\xad\xda\xe6\t\x8d\x9d\x99z\x04\xeeK\xdb \xff\x02A\x00\xad_\x9d\x90v\xd0\xeb->f\xa7\xa0\x0f\x80\x90V+\xc1\xac\xe8\xcd\x0f\xad}u\xd2\x19\x80k\xd9\xb4\xf5\x96\xd4\xd8\xd8R\x0f\x9bR\xa7\x89\xb0m\xdf\xfc\xaf\x00\xf7y+\x08\xe0\x13\xa25\xb5=\xce\xe2\xc6\x0b\x05Q\x02@\x18\xee\xf7\x02\\\xbaU\xe0\'\xb9da9\xd3s\x97\x16\xfb\x1c|\xdd\xb1\x01\xfd\x99m\xd2\xa0\xf2\xa0\xb6\xba(M\xa0\x98\x82o\xe7\xa2\xdf\x82\xcb\xde\xb3\x80\xbe\xbe\xc5qdep\x11\x85\x15\xbd)6\x16\xad\xd4\x9f\x13\x02@\x0f\x15\xc1Y"b\x19\x81Q\x81\x8d\x006\xe4\xf0e\xa2\xa7\xb8\x98{\x1c\x12\xe0\nw\xbe\x86A-\xd0\x1c7\xf3\x169\xadd3\x85\xaf\x13\x99\x08\x97e)c\xaf\xb1V\xf1\x15\xf6K\r\x16\xb4\xf9\xd1\x10\xe2\x92\xf9'

View file

@ -12,9 +12,9 @@ import os, time, shutil
import zipfile import zipfile
from lxml import etree from lxml import etree
from setup.libadobe import sendHTTPRequest_DL2FILE from .libadobe import sendHTTPRequest_DL2FILE
from setup.libadobeFulfill import buildRights, fulfill from .libadobeFulfill import buildRights, fulfill
from setup.libpdf import patch_drm_into_pdf from .libpdf import patch_drm_into_pdf
####################################################################### #######################################################################

View file

@ -30,7 +30,7 @@ except ImportError:
#@@CALIBRE_COMPAT_CODE@@ #@@CALIBRE_COMPAT_CODE@@
from setup.customRSA import CustomRSA from .customRSA import CustomRSA
from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
@ -38,7 +38,7 @@ from cryptography.hazmat.primitives import serialization
VAR_ACS_SERVER_HTTP = "http://adeactivate.adobe.com/adept" VAR_ACS_SERVER_HTTP = "http://adeactivate.adobe.com/adept"
VAR_ACS_SERVER_HTTPS = "https://adeactivate.adobe.com/adept" VAR_ACS_SERVER_HTTPS = "https://adeactivate.adobe.com/adept"
from setup.params import FILE_DEVICEKEY, FILE_DEVICEXML, FILE_ACTIVATIONXML from .params import FILE_DEVICEKEY, FILE_DEVICEXML, FILE_ACTIVATIONXML
# Lists of different ADE "versions" we know about # Lists of different ADE "versions" we know about
VAR_VER_SUPP_CONFIG_NAMES = [ "ADE 1.7.2", "ADE 2.0.1", "ADE 3.0.1", "ADE 4.0.3", "ADE 4.5.10", "ADE 4.5.11" ] VAR_VER_SUPP_CONFIG_NAMES = [ "ADE 1.7.2", "ADE 2.0.1", "ADE 3.0.1", "ADE 4.0.3", "ADE 4.5.10", "ADE 4.5.11" ]

View file

@ -15,12 +15,12 @@ except ImportError:
#@@CALIBRE_COMPAT_CODE@@ #@@CALIBRE_COMPAT_CODE@@
from setup.libadobe import addNonce, sign_node, sendRequestDocu, sendHTTPRequest from .libadobe import addNonce, sign_node, sendRequestDocu, sendHTTPRequest
from setup.libadobe import makeFingerprint, makeSerial, encrypt_with_device_key, decrypt_with_device_key from .libadobe import makeFingerprint, makeSerial, encrypt_with_device_key, decrypt_with_device_key
from setup.libadobe import get_devkey_path, get_device_path, get_activation_xml_path from .libadobe import get_devkey_path, get_device_path, get_activation_xml_path
from setup.libadobe import VAR_VER_SUPP_CONFIG_NAMES, VAR_VER_HOBBES_VERSIONS, VAR_VER_OS_IDENTIFIERS from .libadobe import VAR_VER_SUPP_CONFIG_NAMES, VAR_VER_HOBBES_VERSIONS, VAR_VER_OS_IDENTIFIERS
from setup.libadobe import VAR_VER_ALLOWED_BUILD_IDS_SWITCH_TO, VAR_VER_SUPP_VERSIONS, VAR_ACS_SERVER_HTTP from .libadobe import VAR_VER_ALLOWED_BUILD_IDS_SWITCH_TO, VAR_VER_SUPP_VERSIONS, VAR_ACS_SERVER_HTTP
from setup.libadobe import VAR_ACS_SERVER_HTTPS, VAR_VER_BUILD_IDS, VAR_VER_NEED_HTTPS_BUILD_ID_LIMIT, VAR_VER_ALLOWED_BUILD_IDS_AUTHORIZE from .libadobe import VAR_ACS_SERVER_HTTPS, VAR_VER_BUILD_IDS, VAR_VER_NEED_HTTPS_BUILD_ID_LIMIT, VAR_VER_ALLOWED_BUILD_IDS_AUTHORIZE
def createDeviceFile(randomSerial, useVersionIndex = 0): def createDeviceFile(randomSerial, useVersionIndex = 0):
@ -213,7 +213,7 @@ def createUser(useVersionIndex = 0, authCert = None):
def encryptLoginCredentials(username, password, authenticationCertificate): def encryptLoginCredentials(username, password, authenticationCertificate):
# type: (str, str, str) -> bytes # type: (str, str, str) -> bytes
from setup.libadobe import devkey_bytes as devkey_adobe from .libadobe import devkey_bytes as devkey_adobe
import struct import struct
if devkey_adobe is not None: if devkey_adobe is not None:

View file

@ -5,10 +5,10 @@ import time
#@@CALIBRE_COMPAT_CODE@@ #@@CALIBRE_COMPAT_CODE@@
from setup.libadobe import addNonce, sign_node, get_cert_from_pkcs12, sendRequestDocu, sendRequestDocuRC, sendHTTPRequest from .libadobe import addNonce, sign_node, get_cert_from_pkcs12, sendRequestDocu, sendRequestDocuRC, sendHTTPRequest
from setup.libadobe import get_devkey_path, get_device_path, get_activation_xml_path from .libadobe import get_devkey_path, get_device_path, get_activation_xml_path
from setup.libadobe import VAR_VER_SUPP_VERSIONS, VAR_VER_HOBBES_VERSIONS from .libadobe import VAR_VER_SUPP_VERSIONS, VAR_VER_HOBBES_VERSIONS
from setup.libadobe import VAR_VER_BUILD_IDS, VAR_VER_USE_DIFFERENT_NOTIFICATION_XML_ORDER from .libadobe import VAR_VER_BUILD_IDS, VAR_VER_USE_DIFFERENT_NOTIFICATION_XML_ORDER
def buildFulfillRequest(acsm): def buildFulfillRequest(acsm):
@ -143,7 +143,7 @@ def getDecryptedCert(pkcs12_b64_string = None):
pkcs12_data = base64.b64decode(pkcs12_b64_string) pkcs12_data = base64.b64decode(pkcs12_b64_string)
try: try:
from setup.libadobe import devkey_bytes as devkey_adobe from .libadobe import devkey_bytes as devkey_adobe
except: except:
pass pass

View file

@ -5,14 +5,14 @@
This is an experimental Python version of libgourou. This is an experimental Python version of libgourou.
''' '''
from setup.libadobe import createDeviceKeyFile, FILE_DEVICEKEY, FILE_DEVICEXML, FILE_ACTIVATIONXML from .libadobe import createDeviceKeyFile, FILE_DEVICEKEY, FILE_DEVICEXML, FILE_ACTIVATIONXML
from setup.libadobeAccount import createDeviceFile, createUser, signIn, activateDevice, exportAccountEncryptionKeyDER, getAccountUUID from .libadobeAccount import createDeviceFile, createUser, signIn, activateDevice, exportAccountEncryptionKeyDER, getAccountUUID
from os.path import exists from os.path import exists
VAR_MAIL = "" VAR_MAIL = ""
VAR_PASS = "" VAR_PASS = ""
VAR_VER = 1 # None # 1 for ADE2.0.1, 2 for ADE3.0.1 VAR_VER = 1 # None # 1 for ADE2.0.1, 2 for ADE3.0.1
from decrypt.params import KEYPATH from ..decrypt.params import KEYPATH
################################################################# #################################################################

View file

@ -30,7 +30,7 @@ This tool is intended for educational purposes only. Its primary aim is to assis
## Usage ## Usage
``` ```
usage: DeGourou.py [-h] [-f [F]] [-u [U]] [-t [T]] [-o [O]] [-la] [-li] [-e [E]] [-p [P]] [-lo] usage: DeGourou [-h] [-f [F]] [-u [U]] [-t [T]] [-o [O]] [-la] [-li] [-e [E]] [-p [P]] [-lo]
Download and Decrypt an encrypted PDF or EPUB file. Download and Decrypt an encrypted PDF or EPUB file.
@ -74,17 +74,28 @@ optional arguments:
C. MacOS user's accordingly with name ```DeGourou.bin``` C. MacOS user's accordingly with name ```DeGourou.bin```
### For Middlemans
1. Install through Pip using
```
pip install git+https://gitea.com/bipinkrish/DeGourou.git
```
2. Use `degourou` in Terminal/CMD
### For Developers ### For Developers
1. Clone the repositary or Download zip file and extract it 1. Clone the repositary or Download zip file and extract it
2. Install requirements using pip 2. Install requirements using pip
3. Run "DeGourou.py" file 3. Run "DeGourou.py" file in "DeGourou" directory
``` ```
git clone https://github.com/bipinkrish/DeGourou.git git clone https://gitea.com/bipinkrish/DeGourou.git
cd DeGourou cd DeGourou
pip install -r requirements.txt pip install -r requirements.txt
cd DeGourou
python DeGourou.py python DeGourou.py
``` ```

36
setup.py Normal file
View file

@ -0,0 +1,36 @@
from setuptools import setup
setup(
name='DeGourou',
version='1.3.8',
description='Automate the process of getting decrypted ebook from InternetArchive without the need for Adobe Digital Editions and Calibre.',
url='https://gitea.com/bipinkrish/DeGourou',
author='Bipin krishna',
license='GPL3',
packages=['DeGourou',"DeGourou/setup","DeGourou/decrypt"],
install_requires=['pycryptodomex>=3.17',
'cryptography>=41.0.1',
'lxml>=4.9.2',
'requests>=2.31.0',
'charset-normalizer>=3.1.0'
],
entry_points={
'console_scripts': [
'degourou = DeGourou.DeGourou:main'
]
},
classifiers=[
'Development Status :: 5 - Production/Stable',
'Intended Audience :: Science/Research',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Operating System :: MacOS',
'Operating System :: Microsoft :: Windows',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
],
)