From 44094265f88a8c40a0061292d9e417e00330170f Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 24 Nov 2022 17:32:01 +0800 Subject: [PATCH 01/58] =?UTF-8?q?typeutils.py=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=EF=BC=9A=201.=20=E6=94=B9=E5=90=8D=EF=BC=9Atoint=5Fnofloat()?= =?UTF-8?q?=20->=20toint()=E3=80=81is=5Ffilepath()=20->=20isfilepath()=202?= =?UTF-8?q?.=20tobytes()=E3=80=81tobytearray()=E3=80=81toint()=E3=80=81isf?= =?UTF-8?q?ilepath()=20=E7=9A=84=E5=8F=82=E6=95=B0=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=E6=98=AF=E7=BA=AF=E4=BD=8D=E7=BD=AE=E5=8F=82=E6=95=B0=203.=20?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=20tobytes()=E3=80=81tobytearray()?= =?UTF-8?q?=E3=80=81toint()=20=E7=9A=84=E6=96=87=E6=A1=A3=204.=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BA=86=20tobytes()=E3=80=81tobytearray()=E3=80=81to?= =?UTF-8?q?int()=20=E7=9A=84=E5=88=A4=E6=96=AD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/common.py | 18 +++---- src/libtakiyasha/kgmvpr/__init__.py | 4 +- src/libtakiyasha/kgmvpr/kgmvprdataciphers.py | 6 +-- src/libtakiyasha/kgmvpr/kgmvprmaskutils.py | 6 +-- src/libtakiyasha/kwm/__init__.py | 4 +- src/libtakiyasha/kwm/kwmdataciphers.py | 6 +-- src/libtakiyasha/ncm.py | 6 +-- src/libtakiyasha/qmc/__init__.py | 20 ++++---- src/libtakiyasha/qmc/qmcdataciphers.py | 10 ++-- src/libtakiyasha/stdciphers.py | 14 +++--- src/libtakiyasha/typeutils.py | 50 ++++++++++---------- 11 files changed, 73 insertions(+), 71 deletions(-) diff --git a/src/libtakiyasha/common.py b/src/libtakiyasha/common.py index bf6a620..1d56d50 100644 --- a/src/libtakiyasha/common.py +++ b/src/libtakiyasha/common.py @@ -13,7 +13,7 @@ import _pyio as io from .typedefs import IntegerLike, BytesLike, WritableBuffer -from .typeutils import tobytes, toint_nofloat +from .typeutils import tobytes, toint __all__ = [ 'CipherSkel', @@ -66,7 +66,7 @@ def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: offset: 明文在文件中的位置(偏移量),不应为负数 """ plaindata = tobytes(plaindata) - offset = toint_nofloat(offset) + offset = toint(offset) return bytestrxor(plaindata, self.keystream(offset, len(plaindata))) @@ -78,7 +78,7 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: offset: 密文在文件中的位置(偏移量),不应为负数 """ cipherdata = tobytes(cipherdata) - offset = toint_nofloat(offset) + offset = toint(offset) return bytestrxor(cipherdata, self.keystream(offset, len(cipherdata))) @@ -182,7 +182,7 @@ def iter_block_size(self) -> int: @iter_block_size.setter def iter_block_size(self, value: IntegerLike) -> None: - size = toint_nofloat(value) + size = toint(value) if size < 0: raise ValueError("attribute 'iter_block_size' cannot be a negative integer") self._iter_block_size = size @@ -371,7 +371,7 @@ def read(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) -> curpos = self.tell() if size is None: size = -1 - size = toint_nofloat(size) + size = toint(size) if size < 0: target_data = super().getvalue()[curpos:] else: @@ -427,10 +427,10 @@ def readblock(self, curpos = self.tell() if size is None: size = -1 - size = toint_nofloat(size) + size = toint(size) if block_size is None: block_size = io.DEFAULT_BUFFER_SIZE - block_size = toint_nofloat(block_size) + block_size = toint(block_size) if block_size < 0: block_size = io.DEFAULT_BUFFER_SIZE if size < 0: @@ -458,7 +458,7 @@ def readline(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) curpos = self.tell() if size is None: size = -1 - size = toint_nofloat(size) + size = toint(size) if size < 0: target_data = super().getvalue()[curpos:] else: @@ -494,7 +494,7 @@ def readlines(self, hint: IntegerLike | None = -1, /, nocryptlayer: bool = False results_lines = [] if hint is None: hint = -1 - hint = toint_nofloat(hint) + hint = toint(hint) if hint < 0: while 1: line = self.readline() diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index 151118e..27b276e 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -8,7 +8,7 @@ from ..exceptions import CrypterCreatingError from ..keyutils import make_salt from ..typedefs import BytesLike, FilePath -from ..typeutils import is_filepath, tobytes, verify_fileobj +from ..typeutils import isfilepath, tobytes, verify_fileobj __all__ = ['KGMorVPR'] @@ -165,7 +165,7 @@ def operation(fileobj: IO[bytes]) -> KGMorVPR: if vpr_key is not None: vpr_key = tobytes(vpr_key) - if is_filepath(kgm_vpr_filething): + if isfilepath(kgm_vpr_filething): with open(kgm_vpr_filething, mode='rb') as kgm_vpr_fileobj: instance = operation(kgm_vpr_fileobj) else: diff --git a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py index 8de3799..d9fcdc3 100644 --- a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py +++ b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py @@ -6,7 +6,7 @@ from .kgmvprmaskutils import make_maskstream, xor_half_lower_byte from ..common import StreamCipherSkel from ..typedefs import BytesLike, IntegerLike -from ..typeutils import CachedClassInstanceProperty, tobytes, toint_nofloat +from ..typeutils import CachedClassInstanceProperty, tobytes, toint __all__ = ['KGMorVPRTables', 'KGMorVPREncryptAlgorithm'] @@ -85,7 +85,7 @@ def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: vpr_key: bytes | None = self._vpr_key keysize = self.keysize - offset = toint_nofloat(offset) + offset = toint(offset) if offset < 0: ValueError("second argument 'offset' must be a non-negative integer") plaindata = tobytes(plaindata) @@ -108,7 +108,7 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: vpr_key: bytes | None = self._vpr_key keysize = self.keysize - offset = toint_nofloat(offset) + offset = toint(offset) if offset < 0: ValueError("second argument 'offset' must be a non-negative integer") cipherdata = tobytes(cipherdata) diff --git a/src/libtakiyasha/kgmvpr/kgmvprmaskutils.py b/src/libtakiyasha/kgmvpr/kgmvprmaskutils.py index ecd8c3e..7727b02 100644 --- a/src/libtakiyasha/kgmvpr/kgmvprmaskutils.py +++ b/src/libtakiyasha/kgmvpr/kgmvprmaskutils.py @@ -4,7 +4,7 @@ from typing import Generator from ..typedefs import BytesLike, IntegerLike -from ..typeutils import tobytes, toint_nofloat +from ..typeutils import tobytes, toint __all__ = ['make_maskstream', 'xor_half_lower_byte'] @@ -19,8 +19,8 @@ def make_maskstream(offset: IntegerLike, table2: BytesLike, tablev2: BytesLike ) -> Generator[int, None, None]: - offset = toint_nofloat(offset) - length = toint_nofloat(length) + offset = toint(offset) + length = toint(length) if offset < 0: raise ValueError("first argument 'offset' must be a non-negative integer") if length < 0: diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index 9c0bb0c..337a954 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -7,7 +7,7 @@ from ..common import CryptLayerWrappedIOSkel from ..keyutils import make_salt from ..typedefs import BytesLike, FilePath -from ..typeutils import is_filepath, tobytes, verify_fileobj +from ..typeutils import isfilepath, tobytes, verify_fileobj class KWM(CryptLayerWrappedIOSkel): @@ -101,7 +101,7 @@ def operation(fileobj: IO[bytes]) -> cls: core_key = tobytes(core_key) - if is_filepath(kwm_filething): + if isfilepath(kwm_filething): with open(kwm_filething, mode='rb') as kwm_fileobj: instance = operation(kwm_fileobj) else: diff --git a/src/libtakiyasha/kwm/kwmdataciphers.py b/src/libtakiyasha/kwm/kwmdataciphers.py index fd07096..bf21a6a 100644 --- a/src/libtakiyasha/kwm/kwmdataciphers.py +++ b/src/libtakiyasha/kwm/kwmdataciphers.py @@ -6,7 +6,7 @@ from ..common import StreamCipherSkel from ..miscutils import bytestrxor from ..typedefs import BytesLike, IntegerLike -from ..typeutils import tobytes, toint_nofloat +from ..typeutils import tobytes, toint __all__ = ['Mask32'] @@ -59,8 +59,8 @@ def cls_keystream(cls, length: IntegerLike, /, mask32: BytesLike ) -> Generator[int, None, None]: - offset = toint_nofloat(offset) - length = toint_nofloat(length) + offset = toint(offset) + length = toint(length) if offset < 0: raise ValueError("first argument 'offset' must be a non-negative integer") if length < 0: diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index a6e0135..b319726 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -14,7 +14,7 @@ from .miscutils import bytestrxor from .stdciphers import ARC4, StreamedAESWithModeECB from .typedefs import BytesLike, FilePath -from .typeutils import is_filepath, tobytes, verify_fileobj +from .typeutils import isfilepath, tobytes, verify_fileobj from .warns import CrypterCreatingWarning __all__ = ['CloudMusicIdentifier', 'NCM'] @@ -319,7 +319,7 @@ def operation(fileobj: IO[bytes]) -> NCM: return cls(cipher, audio_encrypted, ncm_tag=ncm_tag, cover_data=cover_data, core_key=core_key) - if is_filepath(ncm_filething): + if isfilepath(ncm_filething): with open(ncm_filething, mode='rb') as ncm_fileobj: instance = operation(ncm_fileobj) else: @@ -388,7 +388,7 @@ def operation(fileobj: IO[bytes]) -> None: else: core_key = tobytes(core_key) - if is_filepath(ncm_filething): + if isfilepath(ncm_filething): with open(ncm_filething, mode='wb') as ncm_fileobj: operation(ncm_fileobj) else: diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 2738332..98f88d9 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -13,7 +13,7 @@ from ..exceptions import CrypterCreatingError, CrypterSavingError from ..keyutils import make_random_ascii_string from ..typedefs import BytesLike, FilePath, IntegerLike -from ..typeutils import is_filepath, tobytes, toint_nofloat, verify_fileobj +from ..typeutils import isfilepath, tobytes, toint, verify_fileobj from ..warns import CrypterCreatingWarning, CrypterSavingWarning __all__ = [ @@ -63,7 +63,7 @@ def new(cls, master_key: BytesLike, simple_key: BytesLike, song_id: IntegerLike, master_key_encrypted_b64encoded = b64encode(master_key_encrypted) return cls(master_key_encrypted_b64encoded, - toint_nofloat(song_id), + toint(song_id), tobytes(unknown_value1) ) @@ -101,7 +101,7 @@ def to_bytes(self, with_tail: bool = False) -> bytes: @classmethod def new(cls, song_id: IntegerLike, unknown_value1: BytesLike, song_mid: str) -> QMCv2STag: - return cls(toint_nofloat(song_id), tobytes(unknown_value1), str(song_mid)) + return cls(toint(song_id), tobytes(unknown_value1), str(song_mid)) class QMCv1(CryptLayerWrappedIOSkel): @@ -179,7 +179,7 @@ def from_file(cls, f"must be 44, 128 or 256, not {len(master_key)}" ) - if is_filepath(qmcv1_filething): + if isfilepath(qmcv1_filething): with open(qmcv1_filething, mode='rb') as qmcv1_fileobj: instance = cls(cipher, qmcv1_fileobj.read()) else: @@ -211,7 +211,7 @@ def to_file(self, qmcv1_filething: FilePath | IO[bytes] = None, /) -> None: ) qmcv1_filething = self.name - if is_filepath(qmcv1_filething): + if isfilepath(qmcv1_filething): with open(qmcv1_filething, mode='wb') as qmcv1_fileobj: qmcv1_fileobj.write(self.getvalue(nocryptlayer=True)) else: @@ -290,7 +290,7 @@ def song_id(self) -> int: @song_id.setter def song_id(self, value: IntegerLike) -> None: - self._song_id = toint_nofloat(value) + self._song_id = toint(value) @song_id.deleter def song_id(self) -> None: @@ -381,7 +381,7 @@ def __init__(self, self._mix_key2 = None else: self._mix_key2 = tobytes(mix_key2) - self._song_id = toint_nofloat(song_id) + self._song_id = toint(song_id) self._song_mid = str(song_mid) self._unknown_value1 = tobytes(unknown_value1) @@ -581,7 +581,7 @@ def operation(fileobj: IO[bytes]) -> QMCv2: if master_key is not None: master_key = tobytes(master_key) - if is_filepath(qmcv2_filething): + if isfilepath(qmcv2_filething): with open(qmcv2_filething, mode='rb') as qmcv2_fileobj: instance = operation(qmcv2_fileobj) else: @@ -711,7 +711,7 @@ def operation(fileobj: IO[bytes]) -> None: f"not {type(tag_type).__name__}" ) - master_key_enc_ver = toint_nofloat(master_key_enc_ver) + master_key_enc_ver = toint(master_key_enc_ver) if simple_key is not None: simple_key = tobytes(simple_key) if mix_key1 is not None: @@ -719,7 +719,7 @@ def operation(fileobj: IO[bytes]) -> None: if mix_key2 is not None: mix_key2 = tobytes(mix_key2) - if is_filepath(qmcv2_filething): + if isfilepath(qmcv2_filething): with open(qmcv2_filething, mode='wb') as qmcv2_fileobj: operation(qmcv2_fileobj) else: diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index 9ebf323..c038739 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -7,7 +7,7 @@ from .qmcconsts import KEY256_MAPPING from ..common import StreamCipherSkel from ..typedefs import BytesLike, IntegerLike -from ..typeutils import CachedClassInstanceProperty, tobytes, toint_nofloat +from ..typeutils import CachedClassInstanceProperty, tobytes, toint __all__ = [ 'HardenedRC4', @@ -98,8 +98,8 @@ def cls_keystream(cls, startblk_len = len(startblk_data) commonblk_data = firstblk_data[:-1] # 普通块:第 65536 字节往后每一个 32767 大小的块 commonblk_len = len(commonblk_data) - offset = toint_nofloat(offset) - length = toint_nofloat(length) + offset = toint(offset) + length = toint(length) if offset < 0: raise ValueError("first argument 'offset' must be a non-negative integer") if length < 0: @@ -217,9 +217,9 @@ def _yield_common_segment_keystream(self, yield box[(box[j] + box[k]) % key_len] def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Generator[int, None, None]: - pending = toint_nofloat(length) + pending = toint(length) done = 0 - offset = toint_nofloat(offset) + offset = toint(offset) if offset < 0: raise ValueError("first argument 'offset' must be a non-negative integer") if pending < 0: diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index 4a3f38a..41e3071 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -17,7 +17,7 @@ from .exceptions import CipherDecryptingError from .typedefs import IntegerLike, BytesLike from .miscutils import bytestrxor -from .typeutils import CachedClassInstanceProperty, tobytes, toint_nofloat +from .typeutils import CachedClassInstanceProperty, tobytes, toint __all__ = [ 'StreamedAESWithModeECB', @@ -71,8 +71,8 @@ def __init__(self, magic_number: IntegerLike = 0x9e3779b9 ) -> None: self._key = tobytes(key) - self._rounds = toint_nofloat(rounds) - self._delta = toint_nofloat(magic_number) + self._rounds = toint(rounds) + self._delta = toint(magic_number) if len(self._key) != self.blocksize: raise ValueError(f"invalid key length: should be {self.blocksize}, not {len(self._key)}") @@ -166,8 +166,8 @@ def __init__(self, magic_number: 加/解密使用的魔数 """ key = tobytes(key) - rounds = toint_nofloat(rounds) - magic_number = toint_nofloat(magic_number) + rounds = toint(rounds) + magic_number = toint(magic_number) if len(key) != self.master_key_size: raise ValueError(f"invalid key length {len(key)}: " f"should be {self.master_key_size}, not {len(key)}" @@ -391,8 +391,8 @@ def __init__(self, key: BytesLike, /) -> None: self._meta_keystream = bytes(meta_keystream) def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Generator[int, None, None]: - offset = toint_nofloat(offset) - length = toint_nofloat(length) + offset = toint(offset) + length = toint(length) if offset < 0: raise ValueError("first argument 'offset' must be a non-negative integer") if length < 0: diff --git a/src/libtakiyasha/typeutils.py b/src/libtakiyasha/typeutils.py index 2ebe48e..037152e 100644 --- a/src/libtakiyasha/typeutils.py +++ b/src/libtakiyasha/typeutils.py @@ -11,8 +11,8 @@ 'CachedClassInstanceProperty', 'tobytes', 'tobytearray', - 'toint_nofloat', - 'is_filepath', + 'toint', + 'isfilepath', 'verify_fileobj' ] @@ -128,55 +128,57 @@ def __delete__(self, obj: T) -> None: super().__delete__(obj) -def tobytes(byteslike: BytesLike) -> bytes: - """尝试将 ``byteslike`` 转换为 ``bytes``。 +def tobytes(byteslike: BytesLike, /) -> bytes: + """一个 ``bytes()`` 的包装器。 - 对 ``int`` 类型的对象不适用。如果输入这样的值,会触发 ``TypeError``。 + 本函数尝试将 ``byteslike`` 转换为 ``bytes``。 + + 和 ``bytes()`` 不一样,本函数不支持通过指定长度的方式创建 ``bytes`` + 对象,即不接受整数类型 ``int`` 对象作为参数。如果输入此类对象,将会引发 + ``TypeError``。同时,本函数也不承担对字符串的编码。 """ - if isinstance(byteslike, int): - # 防止出现 bytes(1000) 这样的情况 + if isinstance(byteslike, int) and not hasattr(byteslike, '__bytes__'): + # 防止出现 bytes(114514) 这样的情况 raise TypeError(f"a bytes-like object is required, not '{type(byteslike).__name__}'") - elif isinstance(byteslike, bytes): - return byteslike else: return bytes(byteslike) -def tobytearray(byteslike: BytesLike) -> bytearray: - """尝试将 ``byteslike`` 转换为 ``bytearray``。 +def tobytearray(byteslike: BytesLike, /) -> bytearray: + """一个 ``bytearray()`` 的包装器。 + + 本函数尝试将 ``byteslike`` 转换为 ``bytearray``。 - 对 ``int`` 类型的对象不适用。如果输入这样的值,会触发 ``TypeError``。 + 和 ``bytearray()`` 不一样,本函数不支持通过指定长度的方式创建 ``bytearray`` + 对象,即不接受整数类型 ``int`` 对象作为参数。如果输入此类对象,将会引发 + ``TypeError``。同时,本函数也不承担对字符串的编码。 """ - if isinstance(byteslike, int): - # 防止出现 bytearray(1000) 这样的情况 + if isinstance(byteslike, int) and not hasattr(byteslike, '__bytes__'): + # 防止出现 bytearray(114514) 这样的情况 raise TypeError(f"a bytes-like object is required, not '{type(byteslike).__name__}'") - elif isinstance(byteslike, bytearray): - return byteslike else: return bytearray(byteslike) -def toint_nofloat(integerlike: IntegerLike) -> int: - """尝试将 ``integerlike`` 转换为 ``int``。 +def toint(integerlike: IntegerLike, /) -> int: + """一个 ``int()`` 的包装器。 - 对 ``float`` 类型或拥有 ``__float__`` 属性的对象不适用。 - 如果输入这样的值,会触发 ``TypeError``。 + 尽管 ``int()`` 可以转换浮点数,但是本函数不接受浮点数。如果输入此类对象,将会引发 + ``TypeError``。 """ if isinstance(integerlike, float): raise TypeError(f"'{type(integerlike).__name__}' object cannot be interpreted as an integer") - elif isinstance(integerlike, int): - return integerlike else: return int(integerlike) -def is_filepath(obj) -> bool: +def isfilepath(obj, /) -> bool: """判断对象 ``obj`` 是否可以被视为文件路径。 只有 ``str``、``bytes`` 类型,或者拥有 ``__fspath__`` 属性的对象,才会被视为文件路径。 """ - return isinstance(obj, (str, bytes)) or hasattr(obj, '__fspath__') + return isinstance(obj, (str, bytes, bytearray)) or hasattr(obj, '__fspath__') def verify_fileobj(fileobj: IO[str | bytes], From fff45b12cb3c53bafb1389ba813001928c6a17b8 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 24 Nov 2022 19:52:21 +0800 Subject: [PATCH 02/58] =?UTF-8?q?common.py=EF=BC=9A=E4=B8=BA=20StreamCiphe?= =?UTF-8?q?rSkel=20=E6=B7=BB=E5=8A=A0=E4=BA=86=E6=96=B9=E6=B3=95=20preoper?= =?UTF-8?q?ations=5Fplaindata()=20=E5=92=8C=20preoperations=5Fcipherdata()?= =?UTF-8?q?=EF=BC=8C=E4=BA=A4=E6=8D=A2=E4=BA=86=20keystream()=20=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E7=9A=84=E4=B8=A4=E4=B8=AA=E5=8F=82=E6=95=B0=E7=9A=84?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/common.py | 42 ++++++++++++++++---- src/libtakiyasha/kgmvpr/kgmvprdataciphers.py | 2 +- src/libtakiyasha/kwm/kwmdataciphers.py | 20 ++++------ src/libtakiyasha/qmc/qmcdataciphers.py | 34 +++++++--------- src/libtakiyasha/stdciphers.py | 12 +++--- src/libtakiyasha/typedefs.py | 12 +++++- 6 files changed, 75 insertions(+), 47 deletions(-) diff --git a/src/libtakiyasha/common.py b/src/libtakiyasha/common.py index 1d56d50..bc52920 100644 --- a/src/libtakiyasha/common.py +++ b/src/libtakiyasha/common.py @@ -45,19 +45,43 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: class StreamCipherSkel(metaclass=ABCMeta): - """适用于简单流式加密算法的框架类。子类必须实现 ``keystream()`` 方法。""" + """适用于简单流式加密算法的框架类。子类必须实现 ``keystream()`` 方法。 + + 如果受限于技术原因无法在 ``keystream()`` 中实现逻辑,那么 + ``keystream()`` 需要引发 ``NotImplementedError``。 + """ @abstractmethod - def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Generator[int, None, None]: + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: """返回一个生成器对象,对其进行迭代,即可得到从起始点 - ``offset`` 开始,持续一定长度 ``length`` 的密钥流。 + ``offset`` 开始,持续一定长度 ``nbytes`` 的密钥流。 Args: offset: 密钥流的起始点,不应为负数 - length: 密钥流的长度,不应为负数 + nbytes: 密钥流的长度,不应为负数 """ raise NotImplementedError + @classmethod + def preoperations_plaindata(cls, plaindata: BytesLike, /) -> Generator[int, None, None]: + """返回一个生成器对象,对其进行迭代,即可得到对明文 ``plaindata`` + 的每个字节进行特定操作之后的结果。 + + Args: + plaindata: 要操作的明文 + """ + yield from tobytes(plaindata) + + @classmethod + def preoperations_cipherdata(cls, cipherdata: BytesLike, /) -> Generator[int, None, None]: + """返回一个生成器对象,对其进行迭代,即可得到对密文 ``cipher`` + 的每个字节进行特定操作之后的结果。 + + Args: + cipherdata: 要操作的密文 + """ + yield from tobytes(cipherdata) + def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: """加密明文 ``plaindata`` 并返回加密结果。 @@ -68,7 +92,9 @@ def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: plaindata = tobytes(plaindata) offset = toint(offset) - return bytestrxor(plaindata, self.keystream(offset, len(plaindata))) + return bytestrxor(self.preoperations_plaindata(plaindata), + self.keystream(len(plaindata), offset) + ) def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: """解密密文 ``cipherdata`` 并返回解密结果。 @@ -80,7 +106,9 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: cipherdata = tobytes(cipherdata) offset = toint(offset) - return bytestrxor(cipherdata, self.keystream(offset, len(cipherdata))) + return bytestrxor(self.preoperations_cipherdata(cipherdata), + self.keystream(len(cipherdata), offset) + ) class CryptLayerWrappedIOSkel(io.BytesIO): @@ -341,7 +369,7 @@ def _xor_data_keystream(self, if data == b'': return - keystream = self._cipher.keystream(offset, len(data)) + keystream = self._cipher.keystream(len(data), offset) for databyteord, streambyteord in zip(data, keystream): resultbyteord = databyteord ^ streambyteord yield resultbyteord diff --git a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py index d9fcdc3..ea9dad4 100644 --- a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py +++ b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py @@ -77,7 +77,7 @@ def __init__(self, f"should be {self.keysize}, not {len(self._vpr_key)}" ) - def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Generator[int, None, None]: + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: raise NotImplementedError def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: diff --git a/src/libtakiyasha/kwm/kwmdataciphers.py b/src/libtakiyasha/kwm/kwmdataciphers.py index bf21a6a..d156b6a 100644 --- a/src/libtakiyasha/kwm/kwmdataciphers.py +++ b/src/libtakiyasha/kwm/kwmdataciphers.py @@ -54,23 +54,19 @@ def __init__(self, core_key: BytesLike, master_key=BytesLike, /) -> None: self._mask32 = mask_final @classmethod - def cls_keystream(cls, - offset: IntegerLike, - length: IntegerLike, /, - mask32: BytesLike - ) -> Generator[int, None, None]: + def cls_keystream(cls, nbytes: IntegerLike, offset: IntegerLike, /, mask32: BytesLike) -> Generator[int, None, None]: offset = toint(offset) - length = toint(length) + nbytes = toint(nbytes) if offset < 0: - raise ValueError("first argument 'offset' must be a non-negative integer") - if length < 0: - raise ValueError("second argument 'length' must be a non-negative integer") + raise ValueError("second argument 'offset' must be a non-negative integer") + if nbytes < 0: + raise ValueError("first argument 'nbytes' must be a non-negative integer") maskblk_data: bytes = tobytes(mask32) maskblk_len = len(maskblk_data) if maskblk_len != 32: raise ValueError(f"invalid mask length: should be 32, not {maskblk_len}") - target_in_maskblk_len = length + target_in_maskblk_len = nbytes target_offset_in_maskblk = offset % maskblk_len if target_offset_in_maskblk == 0: target_before_maskblk_area_len = 0 @@ -86,5 +82,5 @@ def cls_keystream(cls, yield from maskblk_data yield from maskblk_data[:target_after_maskblk_area_len] - def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Generator[int, None, None]: - yield from self.cls_keystream(offset, length, self._mask32) + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + yield from self.cls_keystream(nbytes, offset, self._mask32) diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index c038739..3840da9 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -84,11 +84,7 @@ def __init__(self, mask128: BytesLike, /): raise ValueError(f"invalid mask length: should be 128, got {len(self._mask128)}") @classmethod - def cls_keystream(cls, - offset: IntegerLike, - length: IntegerLike, /, - mask128: BytesLike - ) -> Generator[int, None, None]: + def cls_keystream(cls, nbytes: IntegerLike, offset: IntegerLike, /, mask128: BytesLike) -> Generator[int, None, None]: mask128: bytes = tobytes(mask128) if len(mask128) != 128: raise ValueError(f"invalid mask length (should be 128, got {len(mask128)})") @@ -99,16 +95,16 @@ def cls_keystream(cls, commonblk_data = firstblk_data[:-1] # 普通块:第 65536 字节往后每一个 32767 大小的块 commonblk_len = len(commonblk_data) offset = toint(offset) - length = toint(length) + nbytes = toint(nbytes) if offset < 0: - raise ValueError("first argument 'offset' must be a non-negative integer") - if length < 0: - raise ValueError("second argument 'length' must be a non-negative integer") + raise ValueError("second argument 'offset' must be a non-negative integer") + if nbytes < 0: + raise ValueError("first argument 'nbytes' must be a non-negative integer") if 0 <= offset < startblk_len: max_target_in_startblk_len = startblk_len - offset - target_in_commonblk_len = length - max_target_in_startblk_len - target_in_startblk_len = min(length, max_target_in_startblk_len) + target_in_commonblk_len = nbytes - max_target_in_startblk_len + target_in_startblk_len = min(nbytes, max_target_in_startblk_len) yield from startblk_data[offset:offset + target_in_startblk_len] if target_in_commonblk_len <= 0: return @@ -116,7 +112,7 @@ def cls_keystream(cls, offset = 0 else: offset -= startblk_len - target_in_commonblk_len = length + target_in_commonblk_len = nbytes target_offset_in_commonblk = offset % commonblk_len if target_offset_in_commonblk == 0: @@ -133,8 +129,8 @@ def cls_keystream(cls, yield from commonblk_data yield from commonblk_data[:target_after_commonblk_area_len] - def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Generator[int, None, None]: - yield from self.cls_keystream(offset, length, mask128=self._mask128) + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + yield from self.cls_keystream(nbytes, offset, mask128=self._mask128) class HardenedRC4(StreamCipherSkel): @@ -216,14 +212,14 @@ def _yield_common_segment_keystream(self, if i >= 0: yield box[(box[j] + box[k]) % key_len] - def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Generator[int, None, None]: - pending = toint(length) + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + pending = toint(nbytes) done = 0 offset = toint(offset) if offset < 0: - raise ValueError("first argument 'offset' must be a non-negative integer") + raise ValueError("second argument 'offset' must be a non-negative integer") if pending < 0: - raise ValueError("second argument 'length' must be a non-negative integer") + raise ValueError("first argument 'nbytes' must be a non-negative integer") def mark(p: int) -> None: nonlocal pending, done, offset @@ -251,4 +247,4 @@ def mark(p: int) -> None: mark(self.common_segment_size) if pending > 0: - yield from self._yield_common_segment_keystream(length - done, offset) + yield from self._yield_common_segment_keystream(nbytes - done, offset) diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index 41e3071..89d06b6 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -390,13 +390,13 @@ def __init__(self, key: BytesLike, /) -> None: self._meta_keystream = bytes(meta_keystream) - def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Generator[int, None, None]: + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: offset = toint(offset) - length = toint(length) + nbytes = toint(nbytes) if offset < 0: - raise ValueError("first argument 'offset' must be a non-negative integer") - if length < 0: - raise ValueError("second argument 'length' must be a non-negative integer") + raise ValueError("second argument 'offset' must be a non-negative integer") + if nbytes < 0: + raise ValueError("first argument 'nbytes' must be a non-negative integer") - for i in range(offset, offset + length): + for i in range(offset, offset + nbytes): yield self._meta_keystream[i % 256] diff --git a/src/libtakiyasha/typedefs.py b/src/libtakiyasha/typedefs.py index 96c7982..9d11e48 100644 --- a/src/libtakiyasha/typedefs.py +++ b/src/libtakiyasha/typedefs.py @@ -4,7 +4,7 @@ import array import mmap from os import PathLike -from typing import ByteString, Iterable, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable +from typing import ByteString, Iterable, Iterator, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable __all__ = [ 'T', @@ -50,7 +50,15 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: @runtime_checkable class StreamCipherProto(Protocol): - def keystream(self, offset: IntegerLike, length: IntegerLike, /) -> Iterable[int]: + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Iterator[int]: + raise NotImplementedError + + @classmethod + def preoperations_plaindata(cls, plaindata: BytesLike, /) -> Iterator[int]: + raise NotImplementedError + + @classmethod + def preoperations_cipherdata(cls, cipherdata: BytesLike, /) -> Iterator[int]: raise NotImplementedError def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: From 5c62445923c2e514dccc9fe7b6154f80417c89c6 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 24 Nov 2022 20:20:14 +0800 Subject: [PATCH 03/58] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E4=BA=86=20StreamCiphe?= =?UTF-8?q?rProto=EF=BC=8C=E4=BB=85=E4=BF=9D=E7=95=99=E6=96=B9=E6=B3=95=20?= =?UTF-8?q?encrypt()=20=E5=92=8C=20decrypt()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/typedefs.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/libtakiyasha/typedefs.py b/src/libtakiyasha/typedefs.py index 9d11e48..53e545e 100644 --- a/src/libtakiyasha/typedefs.py +++ b/src/libtakiyasha/typedefs.py @@ -4,7 +4,7 @@ import array import mmap from os import PathLike -from typing import ByteString, Iterable, Iterator, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable +from typing import ByteString, Iterable, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable __all__ = [ 'T', @@ -50,17 +50,6 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: @runtime_checkable class StreamCipherProto(Protocol): - def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Iterator[int]: - raise NotImplementedError - - @classmethod - def preoperations_plaindata(cls, plaindata: BytesLike, /) -> Iterator[int]: - raise NotImplementedError - - @classmethod - def preoperations_cipherdata(cls, cipherdata: BytesLike, /) -> Iterator[int]: - raise NotImplementedError - def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: raise NotImplementedError From e5e241bdf5f345ddb098dfdee529db1d77a3c62f Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 24 Nov 2022 23:49:17 +0800 Subject: [PATCH 04/58] =?UTF-8?q?typedefs.py=EF=BC=9A=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?KeyStreamBasedStreamCipherProto=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=86=E6=96=B9=E6=B3=95=20keystream()=20=E5=92=8C=20preproc?= =?UTF-8?q?ess()=20common.py=EF=BC=9AStreamCipherSkel=20->=20KeyStreamBase?= =?UTF-8?q?dStreamCipherSkel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/common.py | 34 +++++++++----------- src/libtakiyasha/kgmvpr/kgmvprdataciphers.py | 4 +-- src/libtakiyasha/kwm/kwmdataciphers.py | 4 +-- src/libtakiyasha/qmc/qmcdataciphers.py | 6 ++-- src/libtakiyasha/stdciphers.py | 4 +-- src/libtakiyasha/typedefs.py | 19 ++++++++++- 6 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/libtakiyasha/common.py b/src/libtakiyasha/common.py index bc52920..5492d13 100644 --- a/src/libtakiyasha/common.py +++ b/src/libtakiyasha/common.py @@ -17,7 +17,7 @@ __all__ = [ 'CipherSkel', - 'StreamCipherSkel', + 'KeyStreamBasedStreamCipherSkel', 'CryptLayerWrappedIOSkel' ] @@ -44,7 +44,7 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: raise NotImplementedError -class StreamCipherSkel(metaclass=ABCMeta): +class KeyStreamBasedStreamCipherSkel(metaclass=ABCMeta): """适用于简单流式加密算法的框架类。子类必须实现 ``keystream()`` 方法。 如果受限于技术原因无法在 ``keystream()`` 中实现逻辑,那么 @@ -63,24 +63,20 @@ def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[in raise NotImplementedError @classmethod - def preoperations_plaindata(cls, plaindata: BytesLike, /) -> Generator[int, None, None]: - """返回一个生成器对象,对其进行迭代,即可得到对明文 ``plaindata`` - 的每个字节进行特定操作之后的结果。 + def preprocess(cls, + operation: Literal['encrypt', 'decrypt'], + data: BytesLike, / + ) -> Generator[int, None, None]: + """对目标数据 ``data`` 根据操作 ``operation`` 进行预处理,并返回一个生成器。 + 迭代此生成器,即可得到处理后的结果数据。 Args: - plaindata: 要操作的明文 + operation: 需要针对的操作,只能是 ``'encrypt'`` 或 ``'decrypt'`` + data: 需要预处理的数据 """ - yield from tobytes(plaindata) - - @classmethod - def preoperations_cipherdata(cls, cipherdata: BytesLike, /) -> Generator[int, None, None]: - """返回一个生成器对象,对其进行迭代,即可得到对密文 ``cipher`` - 的每个字节进行特定操作之后的结果。 - - Args: - cipherdata: 要操作的密文 - """ - yield from tobytes(cipherdata) + if operation not in ('encrypt', 'decrypt'): + raise ValueError(f"first argument 'operation' must be 'encrypt' or 'decrypt', not {repr(operation)}") + yield from tobytes(data) def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: """加密明文 ``plaindata`` 并返回加密结果。 @@ -92,7 +88,7 @@ def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: plaindata = tobytes(plaindata) offset = toint(offset) - return bytestrxor(self.preoperations_plaindata(plaindata), + return bytestrxor(self.preprocess('encrypt', plaindata), self.keystream(len(plaindata), offset) ) @@ -106,7 +102,7 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: cipherdata = tobytes(cipherdata) offset = toint(offset) - return bytestrxor(self.preoperations_cipherdata(cipherdata), + return bytestrxor(self.preprocess('decrypt', cipherdata), self.keystream(len(cipherdata), offset) ) diff --git a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py index ea9dad4..b0d0ea5 100644 --- a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py +++ b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py @@ -4,7 +4,7 @@ from typing import Generator, TypedDict from .kgmvprmaskutils import make_maskstream, xor_half_lower_byte -from ..common import StreamCipherSkel +from ..common import KeyStreamBasedStreamCipherSkel from ..typedefs import BytesLike, IntegerLike from ..typeutils import CachedClassInstanceProperty, tobytes, toint @@ -17,7 +17,7 @@ class KGMorVPRTables(TypedDict): tablev2: bytes -class KGMorVPREncryptAlgorithm(StreamCipherSkel): +class KGMorVPREncryptAlgorithm(KeyStreamBasedStreamCipherSkel): @CachedClassInstanceProperty def keysize(self) -> int: return 17 diff --git a/src/libtakiyasha/kwm/kwmdataciphers.py b/src/libtakiyasha/kwm/kwmdataciphers.py index d156b6a..a7c1021 100644 --- a/src/libtakiyasha/kwm/kwmdataciphers.py +++ b/src/libtakiyasha/kwm/kwmdataciphers.py @@ -3,7 +3,7 @@ from typing import Generator -from ..common import StreamCipherSkel +from ..common import KeyStreamBasedStreamCipherSkel from ..miscutils import bytestrxor from ..typedefs import BytesLike, IntegerLike from ..typeutils import tobytes, toint @@ -11,7 +11,7 @@ __all__ = ['Mask32'] -class Mask32(StreamCipherSkel): +class Mask32(KeyStreamBasedStreamCipherSkel): @property def core_key(self) -> bytes: return self._core_key diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index 3840da9..cbcbb62 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -5,7 +5,7 @@ from typing import Generator from .qmcconsts import KEY256_MAPPING -from ..common import StreamCipherSkel +from ..common import KeyStreamBasedStreamCipherSkel from ..typedefs import BytesLike, IntegerLike from ..typeutils import CachedClassInstanceProperty, tobytes, toint @@ -15,7 +15,7 @@ ] -class Mask128(StreamCipherSkel): +class Mask128(KeyStreamBasedStreamCipherSkel): @property def original_master_key(self) -> bytes | None: if hasattr(self, '_original_master_key'): @@ -133,7 +133,7 @@ def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[in yield from self.cls_keystream(nbytes, offset, mask128=self._mask128) -class HardenedRC4(StreamCipherSkel): +class HardenedRC4(KeyStreamBasedStreamCipherSkel): @property def hash_base(self) -> int: base = 1 diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index 89d06b6..8895ab6 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -13,7 +13,7 @@ from pyaes import AESModeOfOperationECB from pyaes.util import append_PKCS7_padding, strip_PKCS7_padding -from .common import StreamCipherSkel, CipherSkel +from .common import KeyStreamBasedStreamCipherSkel, CipherSkel from .exceptions import CipherDecryptingError from .typedefs import IntegerLike, BytesLike from .miscutils import bytestrxor @@ -353,7 +353,7 @@ def crypt_block() -> None: return bytes(out_buf) -class ARC4(StreamCipherSkel): +class ARC4(KeyStreamBasedStreamCipherSkel): @property def master_key(self) -> bytes: return self._key diff --git a/src/libtakiyasha/typedefs.py b/src/libtakiyasha/typedefs.py index 53e545e..b356207 100644 --- a/src/libtakiyasha/typedefs.py +++ b/src/libtakiyasha/typedefs.py @@ -4,7 +4,7 @@ import array import mmap from os import PathLike -from typing import ByteString, Iterable, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable +from typing import ByteString, Iterable, Iterator, Literal, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable __all__ = [ 'T', @@ -20,6 +20,7 @@ 'FilePath', 'CipherProto', 'StreamCipherProto', + 'KeyStreamBasedStreamCipherProto', 'StreamCipherBasedCryptedIOProto' ] @@ -57,6 +58,22 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: raise NotImplementedError +@runtime_checkable +class KeyStreamBasedStreamCipherProto(Protocol): + def keystream(self, nbytes: IntegerLike, offset: IntegerLike = 0, /) -> Iterator[int]: + raise NotImplementedError + + @classmethod + def preprocess(cls, operation: Literal['encrypt', 'decrypt'], data: BytesLike, /) -> Iterator[int]: + raise NotImplementedError + + def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: + raise NotImplementedError + + def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: + raise NotImplementedError + + @runtime_checkable class StreamCipherBasedCryptedIOProto(Protocol): def read(self, size: IntegerLike = -1, /) -> bytes: From 3433797b843382bdb63c9f28ee4032df3ffb8b0b Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 25 Nov 2022 02:42:28 +0800 Subject: [PATCH 05/58] =?UTF-8?q?typedefs.KeyStreamBasedStreamCipherProto?= =?UTF-8?q?=E3=80=81common.KeyStreamBasedStreamCipherSkel=EF=BC=9Apreproce?= =?UTF-8?q?ss()=20=E6=94=B9=E5=90=8D=E4=B8=BA=20prexor()=EF=BC=9B=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BA=86=20postxor()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/common.py | 44 ++++++++++++++++++++++++++---------- src/libtakiyasha/typedefs.py | 6 ++++- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/libtakiyasha/common.py b/src/libtakiyasha/common.py index 5492d13..85adf1c 100644 --- a/src/libtakiyasha/common.py +++ b/src/libtakiyasha/common.py @@ -3,7 +3,7 @@ from abc import ABCMeta, abstractmethod from functools import lru_cache -from typing import Generator, Iterable, Literal +from typing import Callable, Generator, Iterable, Iterator, Literal, NamedTuple, Type from .miscutils import bytestrxor @@ -12,7 +12,7 @@ except ImportError: import _pyio as io -from .typedefs import IntegerLike, BytesLike, WritableBuffer +from .typedefs import IntegerLike, BytesLike, KeyStreamBasedStreamCipherProto, StreamCipherProto, WritableBuffer from .typeutils import tobytes, toint __all__ = [ @@ -63,10 +63,10 @@ def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[in raise NotImplementedError @classmethod - def preprocess(cls, - operation: Literal['encrypt', 'decrypt'], - data: BytesLike, / - ) -> Generator[int, None, None]: + def prexor(cls, + operation: Literal['encrypt', 'decrypt'], + data: BytesLike, / + ) -> Generator[int, None, None]: """对目标数据 ``data`` 根据操作 ``operation`` 进行预处理,并返回一个生成器。 迭代此生成器,即可得到处理后的结果数据。 @@ -78,6 +78,22 @@ def preprocess(cls, raise ValueError(f"first argument 'operation' must be 'encrypt' or 'decrypt', not {repr(operation)}") yield from tobytes(data) + @classmethod + def postxor(cls, + operation: Literal['encrypt', 'decrypt'], + data: BytesLike, / + ) -> Generator[int, None, None]: + """对目标数据 ``data`` 根据操作 ``operation`` 进行后处理,并返回一个生成器。 + 迭代此生成器,即可得到处理后的结果数据。 + + Args: + operation: 需要针对的操作,只能是 ``'encrypt'`` 或 ``'decrypt'`` + data: 需要后处理的数据 + """ + if operation not in ('encrypt', 'decrypt'): + raise ValueError(f"first argument 'operation' must be 'encrypt' or 'decrypt', not {repr(operation)}") + yield from tobytes(data) + def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: """加密明文 ``plaindata`` 并返回加密结果。 @@ -88,9 +104,11 @@ def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: plaindata = tobytes(plaindata) offset = toint(offset) - return bytestrxor(self.preprocess('encrypt', plaindata), - self.keystream(len(plaindata), offset) - ) + return bytes(self.postxor( + 'encrypt', + bytestrxor(self.prexor('encrypt', plaindata), self.keystream(len(plaindata), offset)) + ) + ) def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: """解密密文 ``cipherdata`` 并返回解密结果。 @@ -102,9 +120,11 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: cipherdata = tobytes(cipherdata) offset = toint(offset) - return bytestrxor(self.preprocess('decrypt', cipherdata), - self.keystream(len(cipherdata), offset) - ) + return bytes(self.postxor( + 'decrypt', + bytestrxor(self.prexor('decrypt', cipherdata), self.keystream(len(cipherdata), offset)) + ) + ) class CryptLayerWrappedIOSkel(io.BytesIO): diff --git a/src/libtakiyasha/typedefs.py b/src/libtakiyasha/typedefs.py index b356207..5c60cba 100644 --- a/src/libtakiyasha/typedefs.py +++ b/src/libtakiyasha/typedefs.py @@ -64,7 +64,11 @@ def keystream(self, nbytes: IntegerLike, offset: IntegerLike = 0, /) -> Iterator raise NotImplementedError @classmethod - def preprocess(cls, operation: Literal['encrypt', 'decrypt'], data: BytesLike, /) -> Iterator[int]: + def prexor(cls, operation: Literal['encrypt', 'decrypt'], data: BytesLike, /) -> Iterator[int]: + raise NotImplementedError + + @classmethod + def postxor(cls, operation: Literal['encrypt', 'decrypt'], data: BytesLike, /) -> Iterator[int]: raise NotImplementedError def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: From 9582d1f72b2389665e23c72cdb82e03a660f4493 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 15 Dec 2022 19:34:07 +0800 Subject: [PATCH 06/58] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BA=86=20`EncryptedB?= =?UTF-8?q?ytesIOSkel`=20=E5=92=8C=20`KeyStreamBasedStreamCipherSkel`?= =?UTF-8?q?=EF=BC=8C=E4=BB=8E=20`KeyStreamBasedStreamCipherProto`=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=20`*xor`=20=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/common.py | 441 +++++++++++++++++++++++++++++++---- src/libtakiyasha/typedefs.py | 10 +- 2 files changed, 397 insertions(+), 54 deletions(-) diff --git a/src/libtakiyasha/common.py b/src/libtakiyasha/common.py index 85adf1c..fb18a63 100644 --- a/src/libtakiyasha/common.py +++ b/src/libtakiyasha/common.py @@ -3,9 +3,9 @@ from abc import ABCMeta, abstractmethod from functools import lru_cache -from typing import Callable, Generator, Iterable, Iterator, Literal, NamedTuple, Type - -from .miscutils import bytestrxor +from pathlib import Path +from random import randint +from typing import Callable, Generator, Iterable, Iterator, Literal, Type try: import io @@ -62,38 +62,6 @@ def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[in """ raise NotImplementedError - @classmethod - def prexor(cls, - operation: Literal['encrypt', 'decrypt'], - data: BytesLike, / - ) -> Generator[int, None, None]: - """对目标数据 ``data`` 根据操作 ``operation`` 进行预处理,并返回一个生成器。 - 迭代此生成器,即可得到处理后的结果数据。 - - Args: - operation: 需要针对的操作,只能是 ``'encrypt'`` 或 ``'decrypt'`` - data: 需要预处理的数据 - """ - if operation not in ('encrypt', 'decrypt'): - raise ValueError(f"first argument 'operation' must be 'encrypt' or 'decrypt', not {repr(operation)}") - yield from tobytes(data) - - @classmethod - def postxor(cls, - operation: Literal['encrypt', 'decrypt'], - data: BytesLike, / - ) -> Generator[int, None, None]: - """对目标数据 ``data`` 根据操作 ``operation`` 进行后处理,并返回一个生成器。 - 迭代此生成器,即可得到处理后的结果数据。 - - Args: - operation: 需要针对的操作,只能是 ``'encrypt'`` 或 ``'decrypt'`` - data: 需要后处理的数据 - """ - if operation not in ('encrypt', 'decrypt'): - raise ValueError(f"first argument 'operation' must be 'encrypt' or 'decrypt', not {repr(operation)}") - yield from tobytes(data) - def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: """加密明文 ``plaindata`` 并返回加密结果。 @@ -104,11 +72,7 @@ def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: plaindata = tobytes(plaindata) offset = toint(offset) - return bytes(self.postxor( - 'encrypt', - bytestrxor(self.prexor('encrypt', plaindata), self.keystream(len(plaindata), offset)) - ) - ) + return bytes(pd_byte ^ ks_byte for pd_byte, ks_byte in zip(plaindata, self.keystream(len(plaindata), offset))) def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: """解密密文 ``cipherdata`` 并返回解密结果。 @@ -120,11 +84,7 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: cipherdata = tobytes(cipherdata) offset = toint(offset) - return bytes(self.postxor( - 'decrypt', - bytestrxor(self.prexor('decrypt', cipherdata), self.keystream(len(cipherdata), offset)) - ) - ) + return bytes(cd_byte ^ ks_byte for cd_byte, ks_byte in zip(cipherdata, self.keystream(len(cipherdata), offset))) class CryptLayerWrappedIOSkel(io.BytesIO): @@ -569,3 +529,394 @@ def writelines(self, lines: Iterable[BytesLike], /, nocryptlayer: bool = False) else: for line in lines: super().write(line) + + +class EncryptedBytesIOSkel(io.BytesIO): + @property + def source(self) -> Path | None: + """当前对象来源文件的路径。 + + 在此类的对象中,此属性总是 ``None``。 + + 如果是通过子类的构造器方法或函数创建的对象,此属性可能会为来源文件的路径,使用 ``Path`` 对象表示。 + """ + if hasattr(self, '_name'): + return Path(self._name) + + @property + def DEFAULT_BUFFER_SIZE(self) -> int: + return self._DEFAULT_BUFFER_SIZE + + @DEFAULT_BUFFER_SIZE.setter + def DEFAULT_BUFFER_SIZE(self, value: IntegerLike) -> None: + bufsize = toint(value) + if bufsize <= 0: + raise ValueError(f"attribute 'DEFAULT_BUFFER_SIZE' must greater than 0, got {bufsize}") + self._DEFAULT_BUFFER_SIZE = bufsize + + @property + def ITER_METHOD(self) -> Literal['block', 'line']: + return self._ITER_METHOD + + @ITER_METHOD.setter + def ITER_METHOD(self, value: Literal['block', 'line']) -> None: + if value not in ('block', 'line'): + raise ValueError(f"attribute 'ITER_METHOD' must be 'block' or 'line', not {repr(value)}") + self._ITER_METHOD = value + + @property + def ITER_WITHOUT_CRYPTLAYER(self) -> bool: + return self._ITER_WITHOUT_CRYPTLAYER + + @ITER_WITHOUT_CRYPTLAYER.setter + def ITER_WITHOUT_CRYPTLAYER(self, value: bool) -> None: + self._ITER_WITHOUT_CRYPTLAYER = bool(value) + + # @classmethod + # def _verify_streamcipher(cls, obj) -> _StreamCipherProxy: + # keystream_available = False + # can_encrypt = False + # can_decrypt = False + # + # if isinstance(obj, KeyStreamBasedStreamCipherProto): + # keystream = obj.keystream + # + # # 验证 keystream() + # try: + # method_keystream_result = keystream(1, 1) + # except Exception as exc: + # if not callable(keystream): + # raise TypeError(f"{type(obj).__name__}.keystream is not callable") + # raise exc + # else: + # if not isinstance(method_keystream_result, Iterator): + # raise TypeError( + # f"{type(obj).__name__}.keystream() returned non-iterable object " + # f"(type {type(method_keystream_result).__name__})" + # ) + # keystream_available = True + # elif not isinstance(obj, StreamCipherProto): + # raise TypeError( + # f"{repr(obj)} is not a stream cipher object: " + # f"missing method 'encrypt()' or 'decrypt()'" + # ) + # encrypt: Callable[[BytesLike, IntegerLike], bytes] = obj.encrypt + # decrypt: Callable[[BytesLike, IntegerLike], bytes] = obj.decrypt + # + # # 验证 encrypt() + # try: + # encrypt_result = encrypt(bytes([randint(0, 255)]), 1) + # except Exception as exc: + # if not callable(encrypt): + # raise TypeError(f"{type(obj).__name__}.encrypt is not callable") + # raise exc + # else: + # if not isinstance(encrypt_result, bytes): + # raise TypeError( + # f"{type(obj).__name__}.encrypt() returned non-bytes " + # f"(type {type(encrypt_result).__name__})" + # ) + # # 验证 decrypt() + # try: + # decrypt_result = decrypt(bytes([randint(0, 255)]), 1) + # except Exception as exc: + # if not callable(decrypt): + # raise TypeError(f"{type(obj).__name__}.decrypt is not callable") + # raise exc + # else: + # if not isinstance(decrypt_result, bytes): + # raise TypeError( + # f"{type(obj).__name__}.decrypt() returned non-bytes " + # f"(type {type(decrypt_result).__name__})" + # ) + # + # return + + @classmethod + def verify_stream_cipher(cls, + cipher: KeyStreamBasedStreamCipherProto | StreamCipherProto + ) -> tuple[bool, bool, bool]: + keystream_available = True + encrypt_available = True + decrypt_available = True + # 验证 keystream() + if isinstance(cipher, KeyStreamBasedStreamCipherProto): + try: + ks = cipher.keystream(randint(0, 255), randint(0, 8191)) + except NotImplementedError: + keystream_available = False + except Exception as exc: + raise TypeError(f"{repr(cipher)} is not a valid cipher object") from exc + else: + if not all(map(lambda _: isinstance(_, int), ks)): + raise TypeError( + f"result of {repr(cipher)}.keystream() returns non-int during iterating" + ) + elif isinstance(cipher, StreamCipherProto): + keystream_available = False + else: + raise TypeError(f"{repr(cipher)} is not a cipher object") + + try: + encrypt_result = cipher.encrypt(bytes([randint(0, 255)]), randint(0, 8191)) + except NotImplementedError: + encrypt_available = False + except Exception as exc: + raise TypeError(f"{repr(cipher)} is not a valid cipher object") from exc + else: + if not isinstance(encrypt_result, (bytes, bytearray)): + raise TypeError( + f"{repr(cipher)}.encrypt() returns non-bytes (type {type(encrypt_result).__name__})" + ) + + try: + decrypt_result = cipher.decrypt(bytes([randint(0, 255)]), randint(0, 8191)) + except NotImplementedError: + decrypt_available = False + except Exception as exc: + raise TypeError(f"{repr(cipher)} is not a valid cipher object") from exc + else: + if not isinstance(decrypt_result, (bytes, bytearray)): + raise TypeError( + f"{repr(cipher)}.decrypt() returns non-bytes (type {type(decrypt_result).__name__})" + ) + + return encrypt_available, decrypt_available, keystream_available + + @property + def acceptable_ciphers(self) -> list[Type[StreamCipherProto] | Type[KeyStreamBasedStreamCipherProto]]: + return [] + + def __init__(self, + cipher: StreamCipherProto | KeyStreamBasedStreamCipherProto, /, + initial_bytes: BytesLike = b'' + ) -> None: + super().__init__(tobytes(initial_bytes)) + self._encrypt_available, self._decrypt_available, self._keystream_available = self.verify_stream_cipher(cipher) + self._cipher = cipher + if self.acceptable_ciphers: + if not isinstance(self._cipher, tuple(self.acceptable_ciphers)): + if len(self.acceptable_ciphers) == 1: + raise TypeError( + f"first argument 'cipher' must be {self.acceptable_ciphers[0].__name__}, " + f"not {type(self._cipher).__name__}" + ) + else: + supported_ciphers_str = ', '.join( + _.__name__ for _ in self.acceptable_ciphers[:-1] + ) + f'or {self.acceptable_ciphers[-1].__name__}' + raise TypeError( + f"first argument 'cipher' must in {supported_ciphers_str}, " + f"not {type(self._cipher).__name__}" + ) + + self._DEFAULT_BUFFER_SIZE = io.DEFAULT_BUFFER_SIZE + self._ITER_METHOD: Literal['block', 'line'] = 'block' + self._ITER_WITHOUT_CRYPTLAYER = False + + def _iterencrypt(self, plaindata: bytes, offset: int, /) -> Generator[int, None, None]: + encrypt = self._cipher.encrypt + iterprexor_plaindata: Callable[[bytes], Iterator[int]] | None = getattr(self, '_iterprexor_plaindata', None) + + if not self._keystream_available: + yield from encrypt(plaindata, offset) + elif iterprexor_plaindata: + keystream = self._cipher.keystream + for prexor_pb_byte, ks_byte in zip(iterprexor_plaindata(plaindata), keystream(len(plaindata), offset)): + yield prexor_pb_byte ^ ks_byte + else: + keystream = self._cipher.keystream + for pd_byte, ks_byte in zip(plaindata, keystream(len(plaindata), offset)): + yield pd_byte ^ ks_byte + + def _iterdecrypt(self, cipherdata: bytes, offset: int, /) -> Generator[int, None, None]: + decrypt = self._cipher.decrypt + iterprexor_cipherdata: Callable[[bytes], Iterator[int]] | None = getattr(self, '_iterprexor_cipherdata', None) + + if not cipherdata: + return + + if not self._keystream_available: + yield from iter(decrypt(cipherdata, offset)) + elif iterprexor_cipherdata: + keystream = self._cipher.keystream + for prexor_cd_byte, ks_byte in zip(iterprexor_cipherdata(cipherdata), keystream(len(cipherdata), offset)): + yield prexor_cd_byte ^ ks_byte + else: + keystream = self._cipher.keystream + for cd_byte, ks_byte in zip(cipherdata, keystream(len(cipherdata), offset)): + yield cd_byte ^ ks_byte + + def _iterdecrypt_untilnl(self, cipherdata: bytes, offset: int, /) -> Generator[int, None, None]: + decrypt = self._cipher.decrypt + iterprexor_cipherdata: Callable[[bytes], Iterator[int]] | None = getattr(self, '_iterprexor_cipherdata', None) + + if not cipherdata: + return + + if not self._keystream_available: + for pd_byte in iter(decrypt(cipherdata, offset)): + yield pd_byte + if pd_byte == 10: + return + elif iterprexor_cipherdata: + keystream = self._cipher.keystream + for prexor_cd_byte, ks_byte in zip(iterprexor_cipherdata(cipherdata), keystream(len(cipherdata), offset)): + yld = prexor_cd_byte ^ ks_byte + yield yld + if yld == 10: + return + else: + keystream = self._cipher.keystream + for cd_byte, ks_byte in zip(cipherdata, keystream(len(cipherdata), offset)): + yld = cd_byte ^ ks_byte + yield yld + if yld == 10: + return + + def getbuffer(self, nocryptlayer: bool = False) -> memoryview: + if nocryptlayer: + return super().getbuffer() + else: + raise NotImplementedError('memoryview with crypt layer is not supported') + + def getvalue(self, nocryptvalue: bool = False) -> bytes: + if nocryptvalue: + return super().getvalue() + else: + return bytes(self._iterdecrypt(super().getvalue(), 0)) + + def read(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) -> bytes: + if nocryptlayer: + return super().read(size) + else: + curpos = self.tell() + if size is None: + size = -1 + else: + size = toint(size) + if size < 0: + target_data = super().getvalue()[curpos:] + else: + target_data = super().getvalue()[curpos:curpos + size] + + result = bytes(self._iterdecrypt(target_data, curpos)) + self.seek(curpos + len(result), 0) + + return result + + def read1(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) -> bytes: + return self.read(size, nocryptlayer) + + def readline(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) -> bytes: + if nocryptlayer: + return super().readline(size) + else: + curpos = self.tell() + if size is None: + size = -1 + else: + size = toint(size) + if size < 0: + blksize = self._DEFAULT_BUFFER_SIZE + results = [] + while 1: + target_data = super().getvalue()[curpos:curpos + blksize] + if not target_data: + return b'' + result_data = bytes(self._iterdecrypt_untilnl(target_data, curpos)) + self.seek(curpos + len(result_data), 0) + curpos += len(result_data) + results.append(result_data) + if len(result_data) != len(target_data): + return b''.join(results) + else: + target_data = super().getvalue()[curpos:curpos + size] + + result = bytes( + self._iterdecrypt_untilnl(target_data, curpos) + ) + self.seek(curpos + len(result), 0) + + return result + + def readlines(self, + hint: IntegerLike | None = None, /, + nocryptlayer: bool = False + ) -> list[bytes]: + if nocryptlayer: + return super().readlines(hint) + else: + curpos = self.tell() + max_read_size = len(super().getvalue()[curpos:]) + if hint is None: + hint = -1 + else: + hint = toint(hint) + if hint < 0: + read_size = max_read_size + else: + read_size = min(hint, max_read_size) + + nbytes = 0 + lines = [] + while 1: + line = self.readline() + lines.append(line) + nbytes += len(line) + if nbytes >= read_size: + break + + return lines + + def readinto(self, buffer: WritableBuffer, /, nocryptlayer: bool = False) -> int: + if nocryptlayer: + return super().readinto(buffer) + else: + if not isinstance(buffer, memoryview): + buffer = memoryview(buffer) + buffer = buffer.cast('B') + + data = self.read(len(buffer)) + data_len = len(data) + + buffer[:data_len] = data + + return data_len + + def readinto1(self, buffer: WritableBuffer, /, nocryptlayer: bool = False) -> int: + return self.readinto(buffer, nocryptlayer) + + def write(self, data: BytesLike, /, nocryptlayer: bool = False) -> int: + if nocryptlayer: + return super().write(data) + else: + curpos = self.tell() + return super().write( + bytes( + self._iterencrypt(tobytes(data), curpos) + ) + ) + + def writelines(self, lines: Iterable[BytesLike], /, nocryptlayer: bool = False) -> None: + for data in lines: + self.write(data, nocryptlayer) + + def __iter__(self): + return self + + def __next__(self) -> bytes: + if self._ITER_METHOD == 'line': + ret = self.readline(nocryptlayer=self._ITER_WITHOUT_CRYPTLAYER) + if not ret: + raise StopIteration + return ret + elif self._ITER_METHOD == 'block': + ret = self.read(self._DEFAULT_BUFFER_SIZE, nocryptlayer=self._ITER_WITHOUT_CRYPTLAYER) + if not ret: + raise StopIteration + return ret + else: + raise ValueError( + f"attribute 'ITER_METHOD' must be 'block' or 'line', not {repr(self._ITER_METHOD)}" + ) diff --git a/src/libtakiyasha/typedefs.py b/src/libtakiyasha/typedefs.py index 5c60cba..975f484 100644 --- a/src/libtakiyasha/typedefs.py +++ b/src/libtakiyasha/typedefs.py @@ -4,7 +4,7 @@ import array import mmap from os import PathLike -from typing import ByteString, Iterable, Iterator, Literal, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable +from typing import ByteString, Iterable, Iterator, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable __all__ = [ 'T', @@ -63,14 +63,6 @@ class KeyStreamBasedStreamCipherProto(Protocol): def keystream(self, nbytes: IntegerLike, offset: IntegerLike = 0, /) -> Iterator[int]: raise NotImplementedError - @classmethod - def prexor(cls, operation: Literal['encrypt', 'decrypt'], data: BytesLike, /) -> Iterator[int]: - raise NotImplementedError - - @classmethod - def postxor(cls, operation: Literal['encrypt', 'decrypt'], data: BytesLike, /) -> Iterator[int]: - raise NotImplementedError - def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: raise NotImplementedError From 2f5165448fdff0891b91344a4ef8cc39e62a4420 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 15 Dec 2022 19:35:13 +0800 Subject: [PATCH 07/58] =?UTF-8?q?`bytestrxor()`=20=E5=BC=95=E5=8F=91?= =?UTF-8?q?=E7=9A=84=E5=BC=82=E5=B8=B8=E4=BC=9A=E5=8C=85=E5=90=AB=E6=9B=B4?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E7=9A=84=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/miscutils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libtakiyasha/miscutils.py b/src/libtakiyasha/miscutils.py index 0a3921f..b9a4078 100644 --- a/src/libtakiyasha/miscutils.py +++ b/src/libtakiyasha/miscutils.py @@ -75,6 +75,9 @@ def bytestrxor(term1: BytesLike, term2: BytesLike, /) -> bytes: bytestring2 = tobytes(term2) if len(bytestring1) != len(bytestring2): - raise ValueError('only byte strings of equal length can be xored') + raise ValueError( + 'only byte strings of equal length can be xored: ' + f'term 1 ({len(bytestring1)}) != term 2 ({len(bytestring2)})' + ) return bytes(b1 ^ b2 for b1, b2 in zip(bytestring1, bytestring2)) From 5a59613c2f026c82d0620b98aa314bd6cb292e03 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 15 Dec 2022 19:46:39 +0800 Subject: [PATCH 08/58] common.py -> prototypes.py --- src/libtakiyasha/kgmvpr/__init__.py | 2 +- src/libtakiyasha/kgmvpr/kgmvprdataciphers.py | 2 +- src/libtakiyasha/kwm/__init__.py | 2 +- src/libtakiyasha/kwm/kwmdataciphers.py | 2 +- src/libtakiyasha/ncm.py | 2 +- src/libtakiyasha/{common.py => prototypes.py} | 0 src/libtakiyasha/qmc/__init__.py | 2 +- src/libtakiyasha/qmc/qmcdataciphers.py | 2 +- src/libtakiyasha/qmc/qmckeyciphers.py | 2 +- src/libtakiyasha/stdciphers.py | 2 +- 10 files changed, 9 insertions(+), 9 deletions(-) rename src/libtakiyasha/{common.py => prototypes.py} (100%) diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index 27b276e..ace5a49 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -4,9 +4,9 @@ from typing import IO, Literal from .kgmvprdataciphers import KGMorVPREncryptAlgorithm -from ..common import CryptLayerWrappedIOSkel from ..exceptions import CrypterCreatingError from ..keyutils import make_salt +from ..prototypes import CryptLayerWrappedIOSkel from ..typedefs import BytesLike, FilePath from ..typeutils import isfilepath, tobytes, verify_fileobj diff --git a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py index b0d0ea5..a2f5584 100644 --- a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py +++ b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py @@ -4,7 +4,7 @@ from typing import Generator, TypedDict from .kgmvprmaskutils import make_maskstream, xor_half_lower_byte -from ..common import KeyStreamBasedStreamCipherSkel +from ..prototypes import KeyStreamBasedStreamCipherSkel from ..typedefs import BytesLike, IntegerLike from ..typeutils import CachedClassInstanceProperty, tobytes, toint diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index 337a954..9f5a576 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -4,8 +4,8 @@ from typing import IO from .kwmdataciphers import Mask32 -from ..common import CryptLayerWrappedIOSkel from ..keyutils import make_salt +from ..prototypes import CryptLayerWrappedIOSkel from ..typedefs import BytesLike, FilePath from ..typeutils import isfilepath, tobytes, verify_fileobj diff --git a/src/libtakiyasha/kwm/kwmdataciphers.py b/src/libtakiyasha/kwm/kwmdataciphers.py index a7c1021..f95cf56 100644 --- a/src/libtakiyasha/kwm/kwmdataciphers.py +++ b/src/libtakiyasha/kwm/kwmdataciphers.py @@ -3,8 +3,8 @@ from typing import Generator -from ..common import KeyStreamBasedStreamCipherSkel from ..miscutils import bytestrxor +from ..prototypes import KeyStreamBasedStreamCipherSkel from ..typedefs import BytesLike, IntegerLike from ..typeutils import tobytes, toint diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index b319726..b8cad91 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -8,10 +8,10 @@ from secrets import token_bytes from typing import Any, IO, Iterable, Mapping, TypedDict -from .common import CryptLayerWrappedIOSkel from .exceptions import CrypterCreatingError, CrypterSavingError from .keyutils import make_random_ascii_string, make_random_number_string from .miscutils import bytestrxor +from .prototypes import CryptLayerWrappedIOSkel from .stdciphers import ARC4, StreamedAESWithModeECB from .typedefs import BytesLike, FilePath from .typeutils import isfilepath, tobytes, verify_fileobj diff --git a/src/libtakiyasha/common.py b/src/libtakiyasha/prototypes.py similarity index 100% rename from src/libtakiyasha/common.py rename to src/libtakiyasha/prototypes.py diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 98f88d9..08ff10b 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -9,9 +9,9 @@ from .qmcdataciphers import HardenedRC4, Mask128 from .qmckeyciphers import QMCv2KeyEncryptV1, QMCv2KeyEncryptV2 -from ..common import CryptLayerWrappedIOSkel from ..exceptions import CrypterCreatingError, CrypterSavingError from ..keyutils import make_random_ascii_string +from ..prototypes import CryptLayerWrappedIOSkel from ..typedefs import BytesLike, FilePath, IntegerLike from ..typeutils import isfilepath, tobytes, toint, verify_fileobj from ..warns import CrypterCreatingWarning, CrypterSavingWarning diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index cbcbb62..9629776 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -5,7 +5,7 @@ from typing import Generator from .qmcconsts import KEY256_MAPPING -from ..common import KeyStreamBasedStreamCipherSkel +from ..prototypes import KeyStreamBasedStreamCipherSkel from ..typedefs import BytesLike, IntegerLike from ..typeutils import CachedClassInstanceProperty, tobytes, toint diff --git a/src/libtakiyasha/qmc/qmckeyciphers.py b/src/libtakiyasha/qmc/qmckeyciphers.py index 455cf9d..7c46d97 100644 --- a/src/libtakiyasha/qmc/qmckeyciphers.py +++ b/src/libtakiyasha/qmc/qmckeyciphers.py @@ -4,8 +4,8 @@ from base64 import b64decode, b64encode from math import tan -from ..common import CipherSkel from ..exceptions import CipherDecryptingError, CipherEncryptingError +from ..prototypes import CipherSkel from ..stdciphers import TarsCppTCTEAWithModeCBC from ..typedefs import BytesLike from ..typeutils import tobytes diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index 8895ab6..2113600 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -13,7 +13,7 @@ from pyaes import AESModeOfOperationECB from pyaes.util import append_PKCS7_padding, strip_PKCS7_padding -from .common import KeyStreamBasedStreamCipherSkel, CipherSkel +from .prototypes import KeyStreamBasedStreamCipherSkel, CipherSkel from .exceptions import CipherDecryptingError from .typedefs import IntegerLike, BytesLike from .miscutils import bytestrxor From f41c63c6896f6790f8fa446d42fa6a9ebf37b1fe Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 15 Dec 2022 19:53:59 +0800 Subject: [PATCH 09/58] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BA=86=20`=5F=5Fall?= =?UTF-8?q?=5F=5F`=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=9A=84=E5=8F=82=E6=95=B0=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/prototypes.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libtakiyasha/prototypes.py b/src/libtakiyasha/prototypes.py index fb18a63..7385fb3 100644 --- a/src/libtakiyasha/prototypes.py +++ b/src/libtakiyasha/prototypes.py @@ -18,7 +18,8 @@ __all__ = [ 'CipherSkel', 'KeyStreamBasedStreamCipherSkel', - 'CryptLayerWrappedIOSkel' + 'CryptLayerWrappedIOSkel', + 'EncryptedBytesIOSkel' ] @@ -780,8 +781,8 @@ def getbuffer(self, nocryptlayer: bool = False) -> memoryview: else: raise NotImplementedError('memoryview with crypt layer is not supported') - def getvalue(self, nocryptvalue: bool = False) -> bytes: - if nocryptvalue: + def getvalue(self, nocryptlayer: bool = False) -> bytes: + if nocryptlayer: return super().getvalue() else: return bytes(self._iterdecrypt(super().getvalue(), 0)) From f487c4e4fafdb3b76d446af89e431a53a589a6b5 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 15 Dec 2022 23:15:43 +0800 Subject: [PATCH 10/58] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=8F=90=E4=BA=A4=E9=81=97=E7=95=99=E7=9A=84?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/prototypes.py | 60 ---------------------------------- 1 file changed, 60 deletions(-) diff --git a/src/libtakiyasha/prototypes.py b/src/libtakiyasha/prototypes.py index 7385fb3..30522ab 100644 --- a/src/libtakiyasha/prototypes.py +++ b/src/libtakiyasha/prototypes.py @@ -573,66 +573,6 @@ def ITER_WITHOUT_CRYPTLAYER(self) -> bool: def ITER_WITHOUT_CRYPTLAYER(self, value: bool) -> None: self._ITER_WITHOUT_CRYPTLAYER = bool(value) - # @classmethod - # def _verify_streamcipher(cls, obj) -> _StreamCipherProxy: - # keystream_available = False - # can_encrypt = False - # can_decrypt = False - # - # if isinstance(obj, KeyStreamBasedStreamCipherProto): - # keystream = obj.keystream - # - # # 验证 keystream() - # try: - # method_keystream_result = keystream(1, 1) - # except Exception as exc: - # if not callable(keystream): - # raise TypeError(f"{type(obj).__name__}.keystream is not callable") - # raise exc - # else: - # if not isinstance(method_keystream_result, Iterator): - # raise TypeError( - # f"{type(obj).__name__}.keystream() returned non-iterable object " - # f"(type {type(method_keystream_result).__name__})" - # ) - # keystream_available = True - # elif not isinstance(obj, StreamCipherProto): - # raise TypeError( - # f"{repr(obj)} is not a stream cipher object: " - # f"missing method 'encrypt()' or 'decrypt()'" - # ) - # encrypt: Callable[[BytesLike, IntegerLike], bytes] = obj.encrypt - # decrypt: Callable[[BytesLike, IntegerLike], bytes] = obj.decrypt - # - # # 验证 encrypt() - # try: - # encrypt_result = encrypt(bytes([randint(0, 255)]), 1) - # except Exception as exc: - # if not callable(encrypt): - # raise TypeError(f"{type(obj).__name__}.encrypt is not callable") - # raise exc - # else: - # if not isinstance(encrypt_result, bytes): - # raise TypeError( - # f"{type(obj).__name__}.encrypt() returned non-bytes " - # f"(type {type(encrypt_result).__name__})" - # ) - # # 验证 decrypt() - # try: - # decrypt_result = decrypt(bytes([randint(0, 255)]), 1) - # except Exception as exc: - # if not callable(decrypt): - # raise TypeError(f"{type(obj).__name__}.decrypt is not callable") - # raise exc - # else: - # if not isinstance(decrypt_result, bytes): - # raise TypeError( - # f"{type(obj).__name__}.decrypt() returned non-bytes " - # f"(type {type(decrypt_result).__name__})" - # ) - # - # return - @classmethod def verify_stream_cipher(cls, cipher: KeyStreamBasedStreamCipherProto | StreamCipherProto From 5688682bad892aa8b99ab8499adfc331746bb6ae Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 16 Dec 2022 23:42:32 +0800 Subject: [PATCH 11/58] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=20`EncryptedB?= =?UTF-8?q?ytesIOSkel`=20=E7=9A=84=20repr=20=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/prototypes.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/libtakiyasha/prototypes.py b/src/libtakiyasha/prototypes.py index 30522ab..da83ffd 100644 --- a/src/libtakiyasha/prototypes.py +++ b/src/libtakiyasha/prototypes.py @@ -533,6 +533,14 @@ def writelines(self, lines: Iterable[BytesLike], /, nocryptlayer: bool = False) class EncryptedBytesIOSkel(io.BytesIO): + def __repr__(self) -> str: + reprstr_seq = [f'<{self.__module__}.{self.__class__.__name__} at {hex(id(self))}'] + if self.source is not None: + reprstr_seq.append(f", source '{str(self.source)}'") + reprstr_seq.append('>') + + return ''.join(reprstr_seq) + @property def source(self) -> Path | None: """当前对象来源文件的路径。 From f53bf7703a44988f661170972bb3035feaa8ae04 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 16 Dec 2022 23:56:04 +0800 Subject: [PATCH 12/58] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E9=87=8D=E5=86=99=20NC?= =?UTF-8?q?M=20=E7=9A=84=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BD=BF=E7=94=A8=20En?= =?UTF-8?q?cryptedBytesIOSkel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + src/libtakiyasha/binaries/ncm/empty.flac | Bin 0 -> 86 bytes src/libtakiyasha/miscutils.py | 4 + src/libtakiyasha/ncm.py | 270 ++++++++++++++++++++++- 4 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 src/libtakiyasha/binaries/ncm/empty.flac diff --git a/requirements.txt b/requirements.txt index 62f988c..8d0a1cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyaes setuptools +mutagen diff --git a/src/libtakiyasha/binaries/ncm/empty.flac b/src/libtakiyasha/binaries/ncm/empty.flac new file mode 100644 index 0000000000000000000000000000000000000000..14ae8f6f8376ca4de85f7737c056755a3b5071ad GIT binary patch literal 86 zcmYfENpxmlU{DfZ5McQK|38q)b;RidkYczZ+jFy@VH3;C2F@98emK>&FfeE+0L6<^ j(^894^O92)ax#ow>4E0R(j1-IvjEoF{7^D{fhUyrF literal 0 HcmV?d00001 diff --git a/src/libtakiyasha/miscutils.py b/src/libtakiyasha/miscutils.py index b9a4078..05abc03 100644 --- a/src/libtakiyasha/miscutils.py +++ b/src/libtakiyasha/miscutils.py @@ -1,16 +1,20 @@ # -*- coding: utf-8 -*- from __future__ import annotations +from pathlib import Path from typing import Iterable, Mapping from .typedefs import BytesLike, KT, T, VT from .typeutils import tobytes __all__ = [ + 'BINARIES_ROOTDIR', 'bytestrxor', 'getattribute' ] +BINARIES_ROOTDIR = Path(__file__).parent / 'binaries' + def getattribute(obj: object, name: str, diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index b8cad91..4e8f59d 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -5,13 +5,16 @@ import warnings from base64 import b64decode, b64encode from dataclasses import asdict, dataclass, field as dcfield +from pathlib import Path from secrets import token_bytes -from typing import Any, IO, Iterable, Mapping, TypedDict +from typing import Any, Callable, IO, Iterable, Literal, Mapping, NamedTuple, Type, TypedDict + +from mutagen import flac, id3 from .exceptions import CrypterCreatingError, CrypterSavingError from .keyutils import make_random_ascii_string, make_random_number_string -from .miscutils import bytestrxor -from .prototypes import CryptLayerWrappedIOSkel +from .miscutils import BINARIES_ROOTDIR, bytestrxor +from .prototypes import CryptLayerWrappedIOSkel, EncryptedBytesIOSkel from .stdciphers import ARC4, StreamedAESWithModeECB from .typedefs import BytesLike, FilePath from .typeutils import isfilepath, tobytes, verify_fileobj @@ -19,6 +22,7 @@ __all__ = ['CloudMusicIdentifier', 'NCM'] +MODULE_BINARIES_ROOTDIR = BINARIES_ROOTDIR / Path(__file__).stem _TAG_KEY = b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28' MutagenStyleDict = TypedDict( @@ -396,3 +400,263 @@ def operation(fileobj: IO[bytes]) -> None: verify_writable=True ) operation(ncm_fileobj) + + +@dataclass +class NewCloudMusicIdentifier: + format: str = '' + musicId: str = '' + musicName: str = '' + artist: list[list[str | int]] = dcfield(default_factory=list) + album: str = '' + albumId: int = 0 + albumPicDocId: int = 0 + albumPic: str = '' + mvId: int = 0 + flag: int = 0 + bitrate: int = 0 + duration: int = 0 + gain: float = 0.0 + mp3DocId: str = '' + alias: list[str] = dcfield(default_factory=list) + transNames: list[str] = dcfield(default_factory=list) + + def to_mutagen_tag(self, tag_type: Literal['FLAC', 'ID3'] = None) -> flac.FLAC | id3.ID3: + if tag_type is None: + if self.format.lower() == 'flac': + tag_type = 'FLAC' + elif self.format.lower() == 'mp3': + tag_type = 'ID3' + else: + raise ValueError( + "don't know which type of tag is needed: " + "self.format and 'tag_type' are empty" + ) + if tag_type == 'FLAC': + with open(MODULE_BINARIES_ROOTDIR / 'empty.flac', mode='rb') as _f: + # 受 mutagen 功能限制,编辑 FLAC 标签之前必须打开一个空 FLAC 文件 + tag: flac.FLAC | id3.ID3 = flac.FLAC(_f) + keymaps = { + 'musicName': ('title', lambda _: [_]), + 'artist' : ('artist', lambda _: list(str(list(__)[0]) for __ in list(_))), + 'album' : ('album', lambda _: [_]) + } + elif tag_type == 'ID3': + tag: flac.FLAC | id3.ID3 = id3.ID3() + keymaps = { + 'musicName': ('TIT2', lambda _: id3.TIT2(text=[_], encoding=3)), + 'artist' : ('TPE1', lambda _: id3.TPE1(text=list(str(list(__)[0]) for __ in list(_)), encoding=3)), + 'album' : ('TALB', lambda _: id3.TALB(text=[_], encoding=3)) + } + else: + raise ValueError( + f"'tag_type' must be 'FLAC', 'ID3', or None, not {repr(tag_type)}" + ) + + tagkey_constructor: tuple[str, Callable[[str], list[str]] | Callable[[str], id3.Frame]] + for attrname, tagkey_constructor in keymaps.items(): + tagkey, constructor = tagkey_constructor + attr = getattr(self, attrname) + if attr: + tag[tagkey] = constructor(attr) + + return tag + + @classmethod + def from_ncm_163key(cls, ncm_163key: str | BytesLike, tag_key: BytesLike = None): + if isinstance(ncm_163key, str): + ncm_163key = bytes(ncm_163key, encoding='utf-8') + else: + ncm_163key = tobytes(ncm_163key) + if tag_key is None: + tag_key = _TAG_KEY + else: + tag_key = tobytes(tag_key) + + ncm_tag_serialized_encrypted_encoded = ncm_163key[22:] # 去除开头的 b"163 key(Don't modify):" + ncm_tag_serialized_encrypted = b64decode(ncm_tag_serialized_encrypted_encoded, validate=True) + ncm_tag_serialized = StreamedAESWithModeECB(tag_key).decrypt( + ncm_tag_serialized_encrypted + )[6:] # 去除字节串开头的 b'music:' + + return cls(**json.loads(ncm_tag_serialized)) + + +class NCMFileInfo(NamedTuple): + master_key_encrypted: bytes + ncm_163key: bytes + cipher_ctor: Callable[[...], ARC4] + cipher_data_offset: int + cipher_data_len: int + cover_data_offset: int + cover_data_len: int + + +def probe(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], NCMFileInfo | None]: + def operation(fd: IO[bytes]) -> NCMFileInfo | None: + fd.seek(0, 0) + + if not fd.read(10).startswith(b'CTENFDAM'): + return + + master_key_encrypted_xored_len = int.from_bytes(fd.read(4), 'little') + master_key_encrypted_xored = fd.read(master_key_encrypted_xored_len) + master_key_encrypted = bytestrxor(b'd' * master_key_encrypted_xored_len, + master_key_encrypted_xored + ) + + ncm_163key_xored_len = int.from_bytes(fd.read(4), 'little') + ncm_163key_xored = fd.read(ncm_163key_xored_len) + ncm_163key = bytestrxor(b'c' * ncm_163key_xored_len, ncm_163key_xored) + + fd.seek(5, 1) + + cover_space_len = int.from_bytes(fd.read(4), 'little') + cover_data_len = int.from_bytes(fd.read(4), 'little') + if cover_space_len - cover_data_len < 0: + raise CrypterCreatingError(f'file structure error: ' + f'cover space length ({cover_space_len}) ' + f'< cover data length ({cover_data_len})' + ) + cover_data_offset = fd.tell() + cipher_data_offset = fd.seek(cover_space_len, 1) + cipher_data_len = fd.seek(0, 2) - cipher_data_offset + + return NCMFileInfo( + master_key_encrypted=master_key_encrypted, + ncm_163key=ncm_163key, + cipher_ctor=ARC4, + cipher_data_offset=cipher_data_offset, + cipher_data_len=cipher_data_len, + cover_data_offset=cover_data_offset, + cover_data_len=cover_data_len + ) + + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + return Path(filething), operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_origpos = fileobj.tell() + prs = operation(fileobj) + fileobj.seek(fileobj_origpos, 0) + + return fileobj, prs + + +class NewNCM(EncryptedBytesIOSkel): + @classmethod + def from_file(cls, + file_or_info: tuple[Path | IO[bytes], NCMFileInfo | None] | FilePath | IO[bytes], /, + core_key: BytesLike, + tag_key: BytesLike = None + ): + return cls.open(file_or_info, core_key=core_key, tag_key=tag_key) + + @classmethod + def open(cls, + filething_or_info: tuple[Path | IO[bytes], NCMFileInfo | None] | FilePath | IO[bytes], /, + core_key: BytesLike, + tag_key: BytesLike = None + ): + core_key = tobytes(core_key) + if tag_key is None: + tag_key = _TAG_KEY + else: + tag_key = tobytes(tag_key) + + def operation(fd: IO[bytes]) -> cls: + master_key = StreamedAESWithModeECB(core_key).decrypt( + fileinfo.master_key_encrypted + )[17:] # 去除开头的 b'neteasecloudmusic' + cipher = fileinfo.cipher_ctor(master_key) + + try: + ncm_tag = NewCloudMusicIdentifier.from_ncm_163key( + ncm_163key=fileinfo.ncm_163key, + tag_key=tag_key + ) + except Exception as exc: + warnings.warn(f'skip parsing 163key, because an exception was raised while parsing: ' + f'{type(exc).__name__}: {exc}', + CrypterCreatingWarning + ) + warnings.warn(f"you may need to check if the file {repr(filething)} " + f"is corrupted.", + CrypterCreatingWarning + ) + ncm_tag = None + + fd.seek(fileinfo.cover_data_offset, 0) + cover_data = fd.read(fileinfo.cover_data_len) + + fd.seek(fileinfo.cipher_data_offset, 0) + initial_bytes = fd.read(fileinfo.cipher_data_len) + + inst = cls(cipher, initial_bytes) + inst._cover_data = cover_data + inst._ncm_tag = ncm_tag + + return inst + + if isinstance(filething_or_info, tuple): + filething_or_info: tuple[Path | IO[bytes], NCMFileInfo | None] + if len(filething_or_info) != 2: + raise TypeError( + "first argument 'filething_or_info' must be a file path, a file object, " + "or a tuple of probe() returns" + ) + filething, fileinfo = filething_or_info + if fileinfo is None: + raise CrypterCreatingError( + f"{repr(filething)} is not a NCM file" + ) + else: + filething, fileinfo = probe(filething_or_info) + + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + instance = operation(fileobj) + instance._name = Path(filething) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_sourcefile = getattr(fileobj, 'name', None) + instance = operation(fileobj) + + if fileobj_sourcefile is not None: + instance._name = Path(fileobj_sourcefile) + + return instance + + @property + def acceptable_ciphers(self) -> list[Type[ARC4]]: + return [ARC4] + + def __init__(self, cipher: ARC4, /, initial_bytes=b''): + super().__init__(cipher, initial_bytes=initial_bytes) + + self._cover_data: bytes | None = None + self._ncm_tag: NewCloudMusicIdentifier = NewCloudMusicIdentifier() + self._sourcefile: Path | None = None + + @property + def cover_data(self) -> bytes | None: + return self._cover_data + + @cover_data.setter + def cover_data(self, value: BytesLike) -> None: + self._cover_data = tobytes(value) + + @cover_data.deleter + def cover_data(self) -> None: + self._cover_data = None + + @property + def ncm_tag(self) -> NewCloudMusicIdentifier: + return self._ncm_tag From cafc0d71ccd3ca8b0038c3d01bf02fe36f0ecab5 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sun, 18 Dec 2022 11:31:23 +0800 Subject: [PATCH 13/58] =?UTF-8?q?=E4=B8=BA=E6=89=80=E6=9C=89=E7=9A=84=20Ci?= =?UTF-8?q?pher=20=E5=8D=8F=E8=AE=AE=E7=B1=BB=E6=B7=BB=E5=8A=A0=E4=BA=86?= =?UTF-8?q?=20getkey()=20=E6=96=B9=E6=B3=95=EF=BC=9B=E5=86=8D=E6=AC=A1?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=20EncryptedBytesIOSkel=20=E7=9A=84?= =?UTF-8?q?=20repr=20=E6=98=BE=E7=A4=BA=EF=BC=8C=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E5=8F=AA=E8=AF=BB=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=20cipher=20=E5=92=8C=20master=5Fkey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/prototypes.py | 19 ++++++++++++++++++- src/libtakiyasha/typedefs.py | 13 +++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/libtakiyasha/prototypes.py b/src/libtakiyasha/prototypes.py index da83ffd..65d6272 100644 --- a/src/libtakiyasha/prototypes.py +++ b/src/libtakiyasha/prototypes.py @@ -26,6 +26,9 @@ class CipherSkel(metaclass=ABCMeta): """适用于一般加密算法的框架类。子类必须实现 ``encrypt()`` 和 ``decrypt()`` 方法。""" + def getkey(self, keyname: str = 'master') -> bytes | None: + raise NotImplementedError + @abstractmethod def encrypt(self, plaindata: BytesLike, /) -> bytes: """加密明文 ``plaindata`` 并返回加密结果。 @@ -52,6 +55,9 @@ class KeyStreamBasedStreamCipherSkel(metaclass=ABCMeta): ``keystream()`` 需要引发 ``NotImplementedError``。 """ + def getkey(self, keyname: str = 'master') -> bytes | None: + raise NotImplementedError + @abstractmethod def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: """返回一个生成器对象,对其进行迭代,即可得到从起始点 @@ -534,7 +540,10 @@ def writelines(self, lines: Iterable[BytesLike], /, nocryptlayer: bool = False) class EncryptedBytesIOSkel(io.BytesIO): def __repr__(self) -> str: - reprstr_seq = [f'<{self.__module__}.{self.__class__.__name__} at {hex(id(self))}'] + reprstr_seq = [ + f'<{self.__module__}.{self.__class__.__name__} at {hex(id(self))}, ' + f'cipher {repr(self.cipher)}' + ] if self.source is not None: reprstr_seq.append(f", source '{str(self.source)}'") reprstr_seq.append('>') @@ -552,6 +561,14 @@ def source(self) -> Path | None: if hasattr(self, '_name'): return Path(self._name) + @property + def cipher(self) -> StreamCipherProto | KeyStreamBasedStreamCipherProto: + return self._cipher + + @property + def master_key(self) -> bytes | None: + return self._cipher.getkey('master') + @property def DEFAULT_BUFFER_SIZE(self) -> int: return self._DEFAULT_BUFFER_SIZE diff --git a/src/libtakiyasha/typedefs.py b/src/libtakiyasha/typedefs.py index 975f484..513df7f 100644 --- a/src/libtakiyasha/typedefs.py +++ b/src/libtakiyasha/typedefs.py @@ -42,6 +42,9 @@ @runtime_checkable class CipherProto(Protocol): + def getkey(self, keyname: str = 'master') -> bytes | None: + raise NotImplementedError + def encrypt(self, plaindata: BytesLike, /) -> bytes: raise NotImplementedError @@ -51,6 +54,9 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: @runtime_checkable class StreamCipherProto(Protocol): + def getkey(self, keyname: str = 'master') -> bytes | None: + raise NotImplementedError + def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: raise NotImplementedError @@ -60,6 +66,9 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: @runtime_checkable class KeyStreamBasedStreamCipherProto(Protocol): + def getkey(self, keyname: str = 'master') -> bytes | None: + raise NotImplementedError + def keystream(self, nbytes: IntegerLike, offset: IntegerLike = 0, /) -> Iterator[int]: raise NotImplementedError @@ -72,6 +81,10 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: @runtime_checkable class StreamCipherBasedCryptedIOProto(Protocol): + @property + def cipher(self) -> StreamCipherProto | KeyStreamBasedStreamCipherProto: + raise NotImplementedError + def read(self, size: IntegerLike = -1, /) -> bytes: raise NotImplementedError From 31cd3f3d1d9e3d548e12f7501cb03877ba7a8fd8 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sun, 18 Dec 2022 11:32:42 +0800 Subject: [PATCH 14/58] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=20ARC4=20?= =?UTF-8?q?=E7=9A=84=20master=5Fkey=20=E5=B1=9E=E6=80=A7=EF=BC=8C=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20`getkey()`=20=E6=96=B9=E6=B3=95=E4=BB=A3=E6=9B=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/stdciphers.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index 2113600..dc9afe0 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -354,10 +354,6 @@ def crypt_block() -> None: class ARC4(KeyStreamBasedStreamCipherSkel): - @property - def master_key(self) -> bytes: - return self._key - def __init__(self, key: BytesLike, /) -> None: """标准的 RC4 加密算法实现。 @@ -390,6 +386,18 @@ def __init__(self, key: BytesLike, /) -> None: self._meta_keystream = bytes(meta_keystream) + def getkey(self, keyname: str = 'master') -> bytes: + if keyname == 'master': + return self._key + elif isinstance(keyname, str): + raise ValueError( + f"'keyname' must be 'master', not {repr(keyname)}" + ) + else: + raise TypeError( + f"'keyname' must be str, not {type(keyname).__name__}" + ) + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: offset = toint(offset) nbytes = toint(nbytes) From faa8c6c5b85f9611daa6a9e86cbfd0f32be5ac0b Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sun, 18 Dec 2022 11:34:10 +0800 Subject: [PATCH 15/58] =?UTF-8?q?=E4=BD=BF=E7=94=A8=20EncryptedBytesIOSkel?= =?UTF-8?q?=20=E9=87=8D=E5=86=99=E4=BA=86=20NCM=EF=BC=8C=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BA=86=E6=8E=A2=E6=B5=8B=E6=AD=A5=E9=AA=A4=E7=9A=84=E5=88=86?= =?UTF-8?q?=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 665 ++++++++++++++++------------------------ 1 file changed, 272 insertions(+), 393 deletions(-) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index 4e8f59d..b13d456 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -7,14 +7,14 @@ from dataclasses import asdict, dataclass, field as dcfield from pathlib import Path from secrets import token_bytes -from typing import Any, Callable, IO, Iterable, Literal, Mapping, NamedTuple, Type, TypedDict +from typing import Callable, IO, Literal, NamedTuple, Type from mutagen import flac, id3 -from .exceptions import CrypterCreatingError, CrypterSavingError +from .exceptions import CrypterCreatingError from .keyutils import make_random_ascii_string, make_random_number_string from .miscutils import BINARIES_ROOTDIR, bytestrxor -from .prototypes import CryptLayerWrappedIOSkel, EncryptedBytesIOSkel +from .prototypes import EncryptedBytesIOSkel from .stdciphers import ARC4, StreamedAESWithModeECB from .typedefs import BytesLike, FilePath from .typeutils import isfilepath, tobytes, verify_fileobj @@ -23,387 +23,17 @@ __all__ = ['CloudMusicIdentifier', 'NCM'] MODULE_BINARIES_ROOTDIR = BINARIES_ROOTDIR / Path(__file__).stem -_TAG_KEY = b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28' - -MutagenStyleDict = TypedDict( - 'MutagenStyleDict', { - 'TIT2' : list, - 'TPE1' : list, - 'TALB' : list, - 'TXXX::comment': list, - 'title' : list, - 'artist' : list, - 'album' : list, - 'comment' : list - }, - total=False -) - - -@dataclass -class CloudMusicIdentifier: - """解析、储存和重建网易云音乐 163key 。""" - format: str = '' - musicId: str = '' - musicName: str = '' - artist: list[list[str | int]] = dcfield(default_factory=list) - album: str = '' - albumId: int = 0 - albumPicDocId: int = 0 - albumPic: str = '' - mvId: int = 0 - flag: int = 0 - bitrate: int = 0 - duration: int = 0 - gain: float = 0.0 - mp3DocId: str = '' - alias: list[str] = dcfield(default_factory=list) - transNames: list[str] = dcfield(default_factory=list) - - @classmethod - def from_ncm_163key(cls, - ncm_163key_maybe_xored: BytesLike, /, - is_xored: bool = False - ) -> CloudMusicIdentifier: - """解析 163key,返回一个储存解析结果的 ``CloudMusicIdentifier`` 对象。 - - 第一个位置参数 ``ncm_163key_maybe_xored`` 是需要解析的 163key 字节串。 - - 如果 ``is_xored=True``,那么本方法会在解析之前,将 ``ncm_163key_maybe_xored`` - 的每一个字节都与 ``0x63`` 进行 XOR。一般情况下不需要提供此参数。 - """ - ncm_163key_maybe_xored = tobytes(ncm_163key_maybe_xored) - - if is_xored: - ncm_163key = bytestrxor(b'c' * len(ncm_163key_maybe_xored), - ncm_163key_maybe_xored - ) - else: - ncm_163key = ncm_163key_maybe_xored - - ncm_tag_bytestr_encrypted_encoded = ncm_163key[22:] # 去除开头的 b"163 key(Don't modify):" - ncm_tag_bytestr_encrypted = b64decode(ncm_tag_bytestr_encrypted_encoded, validate=True) - ncm_tag_bytestr = StreamedAESWithModeECB(_TAG_KEY).decrypt(ncm_tag_bytestr_encrypted)[6:] # 去除字节串开头的 b'music:' - - return cls(**json.loads(ncm_tag_bytestr)) - - def to_ncm_163key(self, with_xor: bool = False) -> bytes: - """根据当前对象储存的解析结果,重建并返回一个 163key。 - - 如果 ``with_xor=True``,那么本方法在返回结果之间, - 将结果字节串的每一个字节都与 ``0x63`` 进行 XOR。一般情况下不需要提供此参数。 - """ - ncm_tag_bytestr = json.dumps(asdict(self), ensure_ascii=False).encode('utf-8') - ncm_tag_bytestr_encrypted = StreamedAESWithModeECB(_TAG_KEY).encrypt(b'music:' + ncm_tag_bytestr) - target = b"163 key(Don't modify):" + b64encode(ncm_tag_bytestr_encrypted) - - if with_xor: - return bytestrxor(b'c' * len(target), target) - else: - return target - - def to_mutagen_style_dict(self) -> MutagenStyleDict: - """根据当前对象储存的解析结果,构建并返回一个 Mutagen VorbisComment/ID3 风格的字典。 - - 此方法需要当前对象的 ``format`` 属性来决定构建何种风格的字典, - 并且只支持 ``'flac'`` (VorbisComment) 和 ``'mp3'`` (ID3)。 - - 本方法不支持嵌入封面图像,你需要通过其他手段做到。 - - 配合 Mutagen 使用(以 FLAC 为例): - - >>> from mutagen import flac # type: ignore - >>> ncm_tag = CloudMusicIdentifier(format='flac') - >>> mutagen_flac = mutagen.flac.FLAC('target.flac') # type: ignore - >>> mutagen_flac.clear() # 可选,此步骤将会清空 mutagen_flac 已有的数据 - >>> mutagen_flac.update(ncm_tag.to_mutagen_style_dict()) - >>> mutagen_flac.save() - >>> - - 配合 Mutagen 使用(以 MP3 为例,稍微麻烦一些): - - >>> from mutagen import id3, mp3 # type: ignore - >>> ncm_tag = CloudMusicIdentifier(format='mp3') - >>> mutagen_mp3 = mutagen.mp3.MP3('target.mp3') # type: ignore - >>> mutagen_mp3.clear() # 可选,此步骤将会清空 mutagen_mp3 已有的数据 - >>> for key, value in ncm_tag.to_mutagen_style_dict().items(): - ... id3frame_cls = getattr(id3, key[:4]) - ... id3frame = mutagen_mp3.get(key) - ... if id3frame is None: - ... mutagen_mp3[key] = id3frame_cls(text=value, desc='comment') - ... elif id3frame.text: - ... id3frame.text = value - ... mutagen_mp3[key] = id3frame - ... - >>> mutagen_mp3.save() - >>> - """ - comment = self.to_ncm_163key(with_xor=False).decode('utf-8') - if not isinstance(self.format, str): - raise TypeError(f"'self.format' must be str, not {type(self.format)}") - elif self.format.lower() == 'flac': - ret = { - 'title' : [self.musicName], - 'artist': [artistinfo[0] for artistinfo in self.artist], - 'album' : [self.album], - } - if comment is not None: - ret['comment'] = [comment] - elif self.format.lower() == 'mp3': - ret = { - 'TIT2': [self.musicName], - 'TPE1': [artistinfo[0] for artistinfo in self.artist], - 'TALB': [self.album] - } - if comment is not None: - ret['TXXX::comment'] = [comment] - else: - raise ValueError(f"unsupported tag format '{self.format}'") - - return ret - - -class NCM(CryptLayerWrappedIOSkel): - """基于 BytesIO 的 NCM 透明加密二进制流。 - - 所有读写相关方法都会经过透明加密层处理: - 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 - - 调用读写相关方法时,附加参数 ``nocryptlayer=True`` - 可绕过透明加密层,访问缓冲区内的原始加密数据。 - - 如果你要新建一个 NCM 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``NCM.new()`` 和 ``NCM.from_file()`` 新建或打开已有 NCM 文件, - 使用已有 NCM 对象的 ``self.to_file()`` 方法将其保存到文件。 - """ - - @property - def cipher(self) -> ARC4: - return self._cipher - - @property - def master_key(self) -> bytes: - return self.cipher.master_key - - @property - def core_key(self) -> bytes: - return self._core_key - - @core_key.setter - def core_key(self, value: BytesLike) -> None: - self._core_key = tobytes(value) - - @core_key.deleter - def core_key(self) -> None: - self._core_key = None - - @property - def ncm_tag(self) -> CloudMusicIdentifier: - return self._ncm_tag - - @property - def cover_data(self) -> bytes: - return self._cover_data - - @cover_data.setter - def cover_data(self, value: BytesLike) -> None: - self._cover_data = tobytes(value) - - @cover_data.deleter - def cover_data(self) -> None: - self._cover_data = b'' - - def __init__(self, - cipher: ARC4, /, - initial_bytes: BytesLike = b'', - core_key: BytesLike = None, *, - ncm_tag: CloudMusicIdentifier | Mapping[str, Any] | Iterable[tuple[str, Any]] = None, - cover_data: BytesLike = b'' - ) -> None: - """基于 BytesIO 的 NCM 透明加密二进制流。 - - 所有读写相关方法都会经过透明加密层处理: - 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 - - 调用读写相关方法时,附加参数 ``nocryptlayer=True`` - 可绕过透明加密层,访问缓冲区内的原始加密数据。 - - 如果你要新建一个 NCM 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``NCM.new()`` 和 ``NCM.from_file()`` 新建或打开已有 NCM 文件, - 使用已有 NCM 对象的 ``self.to_file()`` 方法将其保存到文件。 - """ - if core_key is None: - self._core_key = None - else: - self._core_key = tobytes(core_key) - if ncm_tag is None: - ncm_tag = CloudMusicIdentifier() - elif not isinstance(ncm_tag, CloudMusicIdentifier): - ncm_tag = CloudMusicIdentifier(**ncm_tag) - self._ncm_tag: CloudMusicIdentifier = ncm_tag - self._cover_data: bytes = tobytes(cover_data) - super().__init__(cipher, initial_bytes) - if not isinstance(self._cipher, ARC4): - raise TypeError(f"'{type(self).__name__}' " - f"only support cipher '{ARC4.__module__}.{ARC4.__name__}', " - f"got '{type(self._cipher).__name__}'" - ) - - @classmethod - def new(cls, - core_key: BytesLike = None, *, - ncm_tag: CloudMusicIdentifier | Mapping[str, Any] | Iterable[tuple[str, Any]] = None, - cover_data: BytesLike = b'' - ) -> NCM: - """创建一个空的 NCM 对象。""" - master_key = (make_random_number_string(29) + make_random_ascii_string(84)).encode('utf-8') - - return cls(ARC4(master_key), - core_key=core_key, - ncm_tag=ncm_tag, - cover_data=cover_data - ) - - @classmethod - def from_file(cls, - ncm_filething: FilePath | IO[bytes], /, - core_key: BytesLike, - ) -> NCM: - """打开一个已有的 NCM 文件 ``ncm_filething``。 - - 第一个位置参数 ``ncm_filething`` 可以是 ``str``、``bytes`` 或任何拥有 ``__fspath__`` - 属性的路径对象。``ncm_filething`` 也可以是文件对象,该对象必须可读和可跳转 - (``ncm_filething.seekable() == True``)。 - - 本方法需要在文件中寻找并解密主密钥,随后使用主密钥解密音频数据。 - - 核心密钥 ``core_key`` 是第二个参数,用于解密找到的主密钥。 - """ - - def operation(fileobj: IO[bytes]) -> NCM: - if not fileobj.read(10).startswith(b'CTENFDAM'): - raise ValueError(f"{fileobj} is not a NCM file") - - master_key_encrypted_xored_len = int.from_bytes(fileobj.read(4), 'little') - master_key_encrypted_xored = fileobj.read(master_key_encrypted_xored_len) - master_key_encrypted = bytestrxor(b'd' * master_key_encrypted_xored_len, - master_key_encrypted_xored - ) - master_key = StreamedAESWithModeECB(core_key).decrypt(master_key_encrypted)[17:] # 去除开头的 b'neteasecloudmusic' - cipher = ARC4(master_key) - - ncm_163key_xored_len = int.from_bytes(fileobj.read(4), 'little') - ncm_163key_xored = fileobj.read(ncm_163key_xored_len) - try: - ncm_tag = CloudMusicIdentifier.from_ncm_163key(ncm_163key_xored, is_xored=True) - except Exception as exc: - warnings.warn(f'skip parsing 163key, because an exception was raised while parsing: ' - f'{type(exc).__name__}: {exc}', - CrypterCreatingWarning - ) - warnings.warn(f"you may need to check if the file {repr(ncm_filething)} " - f"is corrupted.", - CrypterCreatingWarning - ) - ncm_tag = None - - fileobj.seek(5, 1) - - cover_space_len = int.from_bytes(fileobj.read(4), 'little') - cover_data_len = int.from_bytes(fileobj.read(4), 'little') - if cover_space_len - cover_data_len < 0: - raise CrypterCreatingError(f'file structure error: ' - f'cover space length ({cover_space_len}) ' - f'< cover data length ({cover_data_len})' - ) - cover_data = fileobj.read(cover_data_len) - fileobj.seek(cover_space_len - cover_data_len, 1) - - audio_encrypted = fileobj.read() - - return cls(cipher, audio_encrypted, ncm_tag=ncm_tag, cover_data=cover_data, core_key=core_key) - if isfilepath(ncm_filething): - with open(ncm_filething, mode='rb') as ncm_fileobj: - instance = operation(ncm_fileobj) - else: - ncm_fileobj = verify_fileobj(ncm_filething, 'binary', - verify_readable=True, - verify_seekable=True - ) - instance = operation(ncm_fileobj) - - instance._name = getattr(ncm_fileobj, 'name', None) - - return instance - - def to_file(self, - ncm_filething: FilePath | IO[bytes] = None, /, - core_key: BytesLike = None - ) -> None: - """将当前 NCM 对象保存到文件 ``filething``。 - 此过程会向 ``ncm_filething`` 写入 NCM 文件结构。 - - 第一个位置参数 ``ncm_filething`` 可以是 ``str``、``bytes`` 或任何拥有 ``__fspath__`` - 属性的路径对象。``ncm_filething`` 也可以是文件对象,该对象必须可写。 - - 第二个位置参数 ``core_key`` 是可选的。 - 如果提供此参数,本方法会将其作为核心密钥来加密主密钥;否则,使用 - ``self.core_key`` 代替。如果两者都为 ``None`` 或未提供,触发 ``ValueError``。 - - 如果提供了 ``ncm_filething``,本方法将会把数据写入 ``ncm_filething`` - 指向的文件。否则,本方法以写入模式打开一个指向 ``self.name`` - 的文件对象,将数据写入此文件对象。如果两者都为空或未提供,则会触发 - ``ValueError``。 - """ - - def operation(fileobj: IO[bytes]) -> None: - fileobj.write(b'CTENFDAM') - fileobj.write(token_bytes(2)) - - master_key_encrypted = StreamedAESWithModeECB(core_key).encrypt(b'neteasecloudmusic' + self.cipher.master_key) - master_key_encrypted_xored = bytestrxor(b'd' * len(master_key_encrypted), master_key_encrypted) - master_key_encrypted_xored_len = len(master_key_encrypted_xored).to_bytes(4, 'little') - fileobj.write(master_key_encrypted_xored_len) - fileobj.write(master_key_encrypted_xored) - - ncm_163key_xored = self.ncm_tag.to_ncm_163key(with_xor=True) - ncm_163key_xored_len = len(ncm_163key_xored).to_bytes(4, 'little') - fileobj.write(ncm_163key_xored_len) - fileobj.write(ncm_163key_xored) - - fileobj.write(token_bytes(5)) - - cover_space_len = len(self.cover_data).to_bytes(4, 'little') - cover_data_len = cover_space_len - fileobj.write(cover_space_len) - fileobj.write(cover_data_len) - fileobj.write(self.cover_data) - - fileobj.write(self.getvalue(nocryptlayer=True)) - - if core_key is None: - if self.core_key is None: - raise CrypterSavingError('core key missing: ' - "argument 'core_key' and attribute 'self.core_key' " - "are None or unspecified" - ) - core_key = self.core_key - else: - core_key = tobytes(core_key) - if isfilepath(ncm_filething): - with open(ncm_filething, mode='wb') as ncm_fileobj: - operation(ncm_fileobj) - else: - ncm_fileobj = verify_fileobj(ncm_filething, 'binary', - verify_writable=True - ) - operation(ncm_fileobj) +@dataclass(init=True) +class CloudMusicIdentifier: + """解析、储存和重建网易云音乐 163key 。""" + def __post_init__(self) -> None: + self._orig_ncm_tag: dict | None = None + self._orig_ncm_163key: bytes | None = None + self._orig_tag_key: bytes | None = None -@dataclass -class NewCloudMusicIdentifier: format: str = '' musicId: str = '' musicName: str = '' @@ -422,16 +52,55 @@ class NewCloudMusicIdentifier: transNames: list[str] = dcfield(default_factory=list) def to_mutagen_tag(self, tag_type: Literal['FLAC', 'ID3'] = None) -> flac.FLAC | id3.ID3: + """将 CloudMusicIdentifier 对象导出为 Mutagen 库使用的标签格式实例: + ``mutagen.flac.FLAC`` 和 ``mutagen.id3.ID3``。 + + ``tag_type`` 用于选择需要导出为何种格式的标签实例,仅支持 ``FLAC`` + 和 ``ID3``。如果留空,则根据 ``self.format`` 决定。如果两者都为空,则会触发 ``ValueError``。 + + Args: + tag_type: 需要导出为何种格式的标签实例,仅支持 'FLAC' 和 'ID3' + + Examples: + >>> from mutagen import flac, mp3 + [...] + >>> ncm_tag1: CloudMusicIdentifier + >>> ncm_tag1.format + 'flac' + >>> mutagen_tag1 = ncm_tag1.to_mutagen_tag() + >>> type(mutagen_tag1) + mutagen.flac.FLAC + >>> flactag = flac.FLAC('test.flac') + >>> flactag.update(mutagen_tag1) + >>> flactag.save() + >>> + [...] + >>> ncm_tag2: CloudMusicIdentifier + >>> ncm_tag2.format + 'mp3' + >>> mutagen_tag2 = ncm_tag2.to_mutagen_tag() + >>> type(mutagen_tag2) + mutagen.id3.ID3 + >>> mp3tag = mp3.MP3('test.mp3') + >>> mp3tag.update(mutagen_tag2) + >>> mp3tag.save() + >>> + """ if tag_type is None: if self.format.lower() == 'flac': tag_type = 'FLAC' elif self.format.lower() == 'mp3': tag_type = 'ID3' - else: + elif not self.format: raise ValueError( "don't know which type of tag is needed: " "self.format and 'tag_type' are empty" ) + else: + raise ValueError( + "don't know which type of tag is needed: " + "'tag_type' is empty, and the value of self.format is not supported" + ) if tag_type == 'FLAC': with open(MODULE_BINARIES_ROOTDIR / 'empty.flac', mode='rb') as _f: # 受 mutagen 功能限制,编辑 FLAC 标签之前必须打开一个空 FLAC 文件 @@ -463,13 +132,22 @@ def to_mutagen_tag(self, tag_type: Literal['FLAC', 'ID3'] = None) -> flac.FLAC | return tag @classmethod - def from_ncm_163key(cls, ncm_163key: str | BytesLike, tag_key: BytesLike = None): + def from_ncm_163key(cls, ncm_163key: str | BytesLike, /, tag_key: BytesLike = None): + """将一个 163key 字符串/字节对象转换为 CloudMusicIdentifier 对象。 + + 本方法会缓存给定的 163key,以及该 163key 解密后的结果,用于确保 ``self.to_ncm_163key()`` + 返回值的一致性。 + + Args: + ncm_163key: 以“163key”开头的字符串/字节对象 + tag_key: 歌曲信息密钥,用于解密 163key + """ if isinstance(ncm_163key, str): ncm_163key = bytes(ncm_163key, encoding='utf-8') else: ncm_163key = tobytes(ncm_163key) if tag_key is None: - tag_key = _TAG_KEY + tag_key = b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28' else: tag_key = tobytes(tag_key) @@ -478,11 +156,62 @@ def from_ncm_163key(cls, ncm_163key: str | BytesLike, tag_key: BytesLike = None) ncm_tag_serialized = StreamedAESWithModeECB(tag_key).decrypt( ncm_tag_serialized_encrypted )[6:] # 去除字节串开头的 b'music:' + ncm_tag = json.loads(ncm_tag_serialized) - return cls(**json.loads(ncm_tag_serialized)) + instance = cls(**ncm_tag) + instance._orig_ncm_tag = ncm_tag + instance._orig_ncm_163key = ncm_163key + instance._orig_tag_key = tag_key + return instance + + def to_ncm_163key(self, tag_key: BytesLike = None, return_cached: bytes = True) -> bytes: + """将 CloudMusicIdentifier 对象导出为 163key。 + + 第一个参数 ``tag_key`` 用于解密 163key。如果留空,则使用默认值: + ``b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28'`` + + 第二个参数 ``return_cached`` 如果为 ``True``, + 那么在当前对象转换而来的字典(下称当前字典)与缓存的 163key 解密得到的字典(下称缓存字典) + 满足以下条件时,本方法会直接返回 ``self.from_ncm_163key()`` 缓存的 163key: + + - 当前字典包含了缓存字典中的所有字段,且在两个字典中,这些键对应的值也是一致的 + - 当前字典中缓存字典没有的字段,其值为默认值(空值) + + 如果以上条件中的任意一条未被满足,转而返回一个根据当前对象重新生成的 163key。 + Args: + tag_key: 歌曲信息密钥,用于解密 163key + return_cached: 在满足特定条件时,返回缓存的 163key,而不是重新生成一个 + """ + if tag_key is None: + tag_key = b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28' + else: + tag_key = tobytes(tag_key) + + if return_cached and self._orig_ncm_tag: + target_ncm_tag = {_ck: _cv for _ck, _cv in asdict(self).items() if _cv or _ck in self._orig_ncm_tag} + else: + target_ncm_tag = asdict(self) + + def operation() -> bytes: + ncm_tag_serialized = json.dumps(target_ncm_tag, ensure_ascii=False).encode('utf-8') + ncm_tag_serialized_encrypted = StreamedAESWithModeECB(tag_key).encrypt(b'music:' + ncm_tag_serialized) + ncm_163key = b"163 key(Don't modify):" + b64encode(ncm_tag_serialized_encrypted) + + return ncm_163key + + if return_cached and tag_key == self._orig_tag_key and self._orig_ncm_tag and self._orig_ncm_163key: + if len(target_ncm_tag) == len(self._orig_ncm_tag): + for k, v in self._orig_ncm_tag.items(): + if target_ncm_tag[k] != v: + break + else: + return tobytes(self._orig_ncm_163key) + + return operation() class NCMFileInfo(NamedTuple): + """用于储存 NCM 文件的信息。""" master_key_encrypted: bytes ncm_163key: bytes cipher_ctor: Callable[[...], ARC4] @@ -493,6 +222,20 @@ class NCMFileInfo(NamedTuple): def probe(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], NCMFileInfo | None]: + """探测源文件 ``filething`` 是否为一个 NCM 文件。 + + 返回一个 2 个元素长度的元组:第一个元素为 ``filething``;如果 + ``filething`` 是 NCM 文件,那么第二个元素为一个 ``NCMFileInfo`` 对象;否则为 ``None``。 + + 本方法的返回值可以用于 ``NCM.open()`` 的第一个位置参数。 + + Args: + filething: 源文件的路径或文件对象 + Returns: + 一个 2 个元素长度的元组:第一个元素为 filething;如果 + filething 是 NCM 文件,那么第二个元素为一个 NCMFileInfo 对象;否则为 None。 + """ + def operation(fd: IO[bytes]) -> NCMFileInfo | None: fd.seek(0, 0) @@ -547,13 +290,30 @@ def operation(fd: IO[bytes]) -> NCMFileInfo | None: return fileobj, prs -class NewNCM(EncryptedBytesIOSkel): +class NCM(EncryptedBytesIOSkel): + """基于 BytesIO 的 NCM 透明加密二进制流。 + + 所有读写相关方法都会经过透明加密层处理: + 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + + 调用读写相关方法时,附加参数 ``nocryptlayer=True`` + 可绕过透明加密层,访问缓冲区内的原始加密数据。 + + 如果你要新建一个 NCM 对象,不要直接调用 ``__init__()``,而是使用构造器方法 + ``NCM.new()`` 和 ``NCM.open()`` 新建或打开已有 NCM 文件, + 使用已有 NCM 对象的 ``save()`` 方法将其保存到文件。 + """ + @classmethod def from_file(cls, file_or_info: tuple[Path | IO[bytes], NCMFileInfo | None] | FilePath | IO[bytes], /, core_key: BytesLike, tag_key: BytesLike = None ): + """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``NCM.open()`` 代替。""" + warnings.warn( + DeprecationWarning('NCM.from_file() is deprecated and no longer used. Use NCM.open() instead.') + ) return cls.open(file_or_info, core_key=core_key, tag_key=tag_key) @classmethod @@ -562,10 +322,27 @@ def open(cls, core_key: BytesLike, tag_key: BytesLike = None ): + """打开一个 NCM 文件,并返回一个 ``NCM`` 对象。 + + 第一个位置参数 ``filething_or_info`` 需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + + ``filething_or_info`` 也可以接受 ``probe()`` 函数的返回值: + 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 + + 第二个参数 ``core_key`` 是必需的,用于解密文件内嵌的主密钥。 + + 第三个参数 ``tag_key`` 可选,用于解密文件内嵌的歌曲信息。如果留空,则使用默认值: + ``b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28'`` + + Args: + filething_or_info: 源文件的路径或文件对象,或者 probe() 的返回值 + core_key: 核心密钥,用于解密文件内嵌的主密钥 + tag_key: 歌曲信息密钥,用于解密文件内嵌的歌曲信息 + """ core_key = tobytes(core_key) - if tag_key is None: - tag_key = _TAG_KEY - else: + if tag_key is not None: tag_key = tobytes(tag_key) def operation(fd: IO[bytes]) -> cls: @@ -575,8 +352,8 @@ def operation(fd: IO[bytes]) -> cls: cipher = fileinfo.cipher_ctor(master_key) try: - ncm_tag = NewCloudMusicIdentifier.from_ncm_163key( - ncm_163key=fileinfo.ncm_163key, + ncm_tag = CloudMusicIdentifier.from_ncm_163key( + fileinfo.ncm_163key, tag_key=tag_key ) except Exception as exc: @@ -588,7 +365,7 @@ def operation(fd: IO[bytes]) -> cls: f"is corrupted.", CrypterCreatingWarning ) - ncm_tag = None + ncm_tag = CloudMusicIdentifier() fd.seek(fileinfo.cover_data_offset, 0) cover_data = fd.read(fileinfo.cover_data_len) @@ -634,29 +411,131 @@ def operation(fd: IO[bytes]) -> cls: return instance + def to_file(self, + core_key: BytesLike, + filething: FilePath | IO[bytes] = None, + tag_key: BytesLike | None = None + ) -> None: + """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``NCM.save()`` 代替。""" + warnings.warn( + DeprecationWarning('NCM.to_file() is deprecated and no longer used. Use NCM.save() instead.') + ) + return self.save(core_key, filething=filething, tag_key=tag_key) + + def save(self, + core_key: BytesLike, + filething: FilePath | IO[bytes] = None, + tag_key: BytesLike | None = None + ) -> None: + """将当前对象保存为一个新 NCM 文件。 + + 第一个参数 ``core_key`` 是必需的,用于加密主密钥,以便嵌入到文件。 + + 第二个参数 ``filething`` 需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + + 第三个参数 ``tag_key`` 可选,用于加密歌曲信息,以便嵌入到文件。如果留空,则使用默认值: + ``b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28'`` + + Args: + core_key: 核心密钥,用于加密主密钥,以便嵌入到文件 + filething: 目标文件的路径或文件对象 + tag_key: 歌曲信息密钥,用于加密歌曲信息,以便嵌入到文件 + """ + core_key = tobytes(core_key) + if tag_key is not None: + tag_key = tobytes(tag_key) + + def operation(fd: IO[bytes]) -> None: + fd.seek(0, 0) + + fd.write(b'CTENFDAM') + fd.seek(2, 1) + + master_key = self.master_key + master_key_encrypted = StreamedAESWithModeECB(core_key).encrypt(b'neteasecloudmusic' + master_key) + master_key_encrypted_xored = bytestrxor(b'd' * len(master_key_encrypted), master_key_encrypted) + master_key_encrypted_xored_len = len(master_key_encrypted_xored) + fd.write(master_key_encrypted_xored_len.to_bytes(4, 'little')) + fd.write(master_key_encrypted_xored) + + ncm_163key = self.ncm_tag.to_ncm_163key(tag_key) + ncm_163key_xored = bytestrxor(b'c' * len(ncm_163key), ncm_163key) + ncm_163key_xored_len = len(ncm_163key_xored) + fd.write(ncm_163key_xored_len.to_bytes(4, 'little')) + fd.write(ncm_163key_xored) + + fd.write(token_bytes(5)) + + cover_data = self.cover_data if self.cover_data else b'' + cover_data_len = len(cover_data) + fd.write(cover_data_len.to_bytes(4, 'little')) # cover_space length + fd.write(cover_data_len.to_bytes(4, 'little')) # cover_data length + fd.write(cover_data) + + fd.write(self.getvalue(nocryptlayer=True)) + + if isfilepath(filething): + with open(filething, mode='wb') as fileobj: + return operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_seekable=True, + verify_writable=True + ) + return operation(fileobj) + + @classmethod + def new(cls): + """返回一个空 NCM 对象。""" + master_key = (make_random_number_string(29) + make_random_ascii_string(84)).encode('utf-8') + + return cls(ARC4(master_key)) + @property def acceptable_ciphers(self) -> list[Type[ARC4]]: return [ARC4] def __init__(self, cipher: ARC4, /, initial_bytes=b''): + """基于 BytesIO 的 NCM 透明加密二进制流。 + + 所有读写相关方法都会经过透明加密层处理: + 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + + 调用读写相关方法时,附加参数 ``nocryptlayer=True`` + 可绕过透明加密层,访问缓冲区内的原始加密数据。 + + 如果你要新建一个 NCM 对象,不要直接调用 ``__init__()``,而是使用构造器方法 + ``NCM.new()`` 和 ``NCM.open()`` 新建或打开已有 NCM 文件, + 使用已有 NCM 对象的 ``save()`` 方法将其保存到文件。 + + Args: + cipher: 要使用的 cipher,必须是一个 libtakiyasha.stdciphers.ARC4 对象 + initial_bytes: 内置缓冲区的初始数据 + """ super().__init__(cipher, initial_bytes=initial_bytes) self._cover_data: bytes | None = None - self._ncm_tag: NewCloudMusicIdentifier = NewCloudMusicIdentifier() + self._ncm_tag: CloudMusicIdentifier = CloudMusicIdentifier() self._sourcefile: Path | None = None @property def cover_data(self) -> bytes | None: + """封面图像数据。""" return self._cover_data @cover_data.setter def cover_data(self, value: BytesLike) -> None: + """封面图像数据。""" self._cover_data = tobytes(value) @cover_data.deleter def cover_data(self) -> None: + """封面图像数据。""" self._cover_data = None @property - def ncm_tag(self) -> NewCloudMusicIdentifier: + def ncm_tag(self) -> CloudMusicIdentifier: + """163key 的解析结果。""" return self._ncm_tag From 1083a4fafbe29e3e0013eaea41cbdef7d16f021f Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sun, 18 Dec 2022 11:54:32 +0800 Subject: [PATCH 16/58] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BA=86=20EncryptedBy?= =?UTF-8?q?tesIOSkel=20=E7=9A=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/prototypes.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/libtakiyasha/prototypes.py b/src/libtakiyasha/prototypes.py index 65d6272..b51acf8 100644 --- a/src/libtakiyasha/prototypes.py +++ b/src/libtakiyasha/prototypes.py @@ -563,14 +563,20 @@ def source(self) -> Path | None: @property def cipher(self) -> StreamCipherProto | KeyStreamBasedStreamCipherProto: + """加解密内置缓冲区数据使用的 Cipher 实例。""" return self._cipher @property def master_key(self) -> bytes | None: + """主密钥。""" return self._cipher.getkey('master') @property def DEFAULT_BUFFER_SIZE(self) -> int: + """迭代本对象时,每次迭代返回的数据大小。 + + 此值必须是一个非零正整数。使用其他值会引发 ``ValueError``。 + """ return self._DEFAULT_BUFFER_SIZE @DEFAULT_BUFFER_SIZE.setter @@ -582,6 +588,10 @@ def DEFAULT_BUFFER_SIZE(self, value: IntegerLike) -> None: @property def ITER_METHOD(self) -> Literal['block', 'line']: + """迭代本对象时使用的迭代模式:按行迭代(``line``)或按固定大小迭代(``block``)。 + + 仅接受以上两个值,使用其他值会引发 ``ValueError``。 + """ return self._ITER_METHOD @ITER_METHOD.setter @@ -592,6 +602,7 @@ def ITER_METHOD(self, value: Literal['block', 'line']) -> None: @property def ITER_WITHOUT_CRYPTLAYER(self) -> bool: + """迭代本对象时,是否直接返回加密数据,而不进行解密。""" return self._ITER_WITHOUT_CRYPTLAYER @ITER_WITHOUT_CRYPTLAYER.setter @@ -651,6 +662,9 @@ def verify_stream_cipher(cls, @property def acceptable_ciphers(self) -> list[Type[StreamCipherProto] | Type[KeyStreamBasedStreamCipherProto]]: + """``__init__()`` 的第一个参数 ``cipher`` 可接受的对象类型。 + 在此列表之外的类型会导致 ``__init__()`` 抛出 ``TypeError``。 + """ return [] def __init__(self, From 1c83b0f2057b9f6f0f0e5a853360aacb9522b4ca Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 19 Dec 2022 18:18:18 +0800 Subject: [PATCH 17/58] =?UTF-8?q?=E5=AF=B9=20prototypes.py=20=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=BA=86=E5=A4=A7=E9=87=8F=E4=BF=AE=E6=94=B9=EF=BC=9A?= =?UTF-8?q?=20-=20`CipherSkel`=20=E5=92=8C=20`KeyStreamBasedStreamCipherSk?= =?UTF-8?q?el`=20=E7=9A=84=20`getkey`=20=E6=96=B9=E6=B3=95=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E6=98=AF=E6=8A=BD=E8=B1=A1=E6=96=B9=E6=B3=95=20-=20?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=20`CipherSkel`=20=E5=92=8C=20`KeySt?= =?UTF-8?q?reamBasedStreamCipherSkel`=20=E7=9A=84=E6=96=87=E6=A1=A3=20-=20?= =?UTF-8?q?=E5=AF=B9=E4=BA=8E=20`KeyStreamBasedStreamCipherSkel`=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E5=8F=AF=E9=80=89=E7=9A=84=E5=8A=A0?= =?UTF-8?q?=E5=AF=86/=E8=A7=A3=E5=AF=86=E7=9A=84=E9=A2=84/=E5=90=8E?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=96=B9=E6=B3=95=EF=BC=9B`EncryptedBytesIOS?= =?UTF-8?q?kel`=20=E7=8E=B0=E5=9C=A8=E4=B9=9F=E4=BC=9A=E6=9F=A5=E6=89=BE?= =?UTF-8?q?=E8=BF=99=E4=BA=9B=E6=96=B9=E6=B3=95=EF=BC=8C=E5=B9=B6=E5=9C=A8?= =?UTF-8?q?=E6=89=BE=E5=88=B0=E6=97=B6=E4=BD=BF=E7=94=A8=20-=20=E4=B8=BA?= =?UTF-8?q?=20`EncryptedBytesIOSkel`=20=E6=B7=BB=E5=8A=A0=E4=BA=86=20`can?= =?UTF-8?q?=5Fencrypt()`=20=E5=92=8C=20`can=5Fdecrypt()`=20=E6=96=B9?= =?UTF-8?q?=E6=B3=95=EF=BC=8C=E7=94=A8=E4=BA=8E=E6=8C=87=E7=A4=BA=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E5=8F=AF=E4=BB=A5=E5=9C=A8=E5=8F=82=E6=95=B0=20`nocry?= =?UTF-8?q?ptlayer=3DTrue`=20=E6=97=B6=E8=AF=BB=E5=8F=96=E6=88=96=E5=86=99?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/prototypes.py | 148 ++++++++++++++++++++++++--------- 1 file changed, 108 insertions(+), 40 deletions(-) diff --git a/src/libtakiyasha/prototypes.py b/src/libtakiyasha/prototypes.py index b51acf8..93c6e48 100644 --- a/src/libtakiyasha/prototypes.py +++ b/src/libtakiyasha/prototypes.py @@ -24,8 +24,13 @@ class CipherSkel(metaclass=ABCMeta): - """适用于一般加密算法的框架类。子类必须实现 ``encrypt()`` 和 ``decrypt()`` 方法。""" + """适用于一般加密算法的框架类。子类必须实现 ``encrypt()``、``decrypt()`` + 和 ``getkey()`` 方法。 + 如果以上方法中的任何一个无法实现,应当在被调用时抛出 ``NotImplementedError``。 + """ + + @abstractmethod def getkey(self, keyname: str = 'master') -> bytes | None: raise NotImplementedError @@ -49,12 +54,19 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: class KeyStreamBasedStreamCipherSkel(metaclass=ABCMeta): - """适用于简单流式加密算法的框架类。子类必须实现 ``keystream()`` 方法。 + """适用于简单流式加密算法的框架类。子类必须实现 ``keystream()`` 和 ``getkey()`` 方法。 - 如果受限于技术原因无法在 ``keystream()`` 中实现逻辑,那么 - ``keystream()`` 需要引发 ``NotImplementedError``。 + 以下方法的实现是可选的,但如果实现了,就会被 ``encrypt()`` 和 ``decrypt()`` 使用: + + - ``prexor_encrypt()`` - 加密前对明文的预处理,被 ``encrypt()`` 使用 + - ``postxor_encrypt()`` - 加密后对密文的后处理,被 ``encrypt()`` 使用 + - ``prexor_decrypt()`` - 解密前对密文的预处理,被 ``decrypt()`` 使用 + - ``postxor_decrypt()`` - 解密后对明文的后处理,被 ``decrypt()`` 使用 + + 以上可选方法的实现必须接受一个类字节对象,并返回一个由整数组成的可迭代对象。 """ + @abstractmethod def getkey(self, keyname: str = 'master') -> bytes | None: raise NotImplementedError @@ -79,7 +91,21 @@ def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: plaindata = tobytes(plaindata) offset = toint(offset) - return bytes(pd_byte ^ ks_byte for pd_byte, ks_byte in zip(plaindata, self.keystream(len(plaindata), offset))) + prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'prexor_encrypt', None) + postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'postxor_encrypt', None) + keystream = self.keystream(len(plaindata), offset) + + if prexor: + pd_strm = prexor(plaindata) + else: + pd_strm = plaindata + cd_noxor_strm = (pd_byte ^ ks_byte for pd_byte, ks_byte in zip(pd_strm, keystream)) + if postxor: + cd_strm = postxor(cd_noxor_strm) + else: + cd_strm = cd_noxor_strm + + return bytes(cd_strm) def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: """解密密文 ``cipherdata`` 并返回解密结果。 @@ -91,7 +117,21 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: cipherdata = tobytes(cipherdata) offset = toint(offset) - return bytes(cd_byte ^ ks_byte for cd_byte, ks_byte in zip(cipherdata, self.keystream(len(cipherdata), offset))) + prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'prexor_decrypt', None) + postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'postxor_decrypt', None) + keystream = self.keystream(len(cipherdata), offset) + + if prexor: + cd_strm = prexor(cipherdata) + else: + cd_strm = cipherdata + pd_noxor_strm = (cd_byte ^ ks_byte for cd_byte, ks_byte in zip(cd_strm, keystream)) + if postxor: + pd_strm = postxor(pd_noxor_strm) + else: + pd_strm = pd_noxor_strm + + return bytes(pd_strm) class CryptLayerWrappedIOSkel(io.BytesIO): @@ -710,65 +750,79 @@ def _iterencrypt(self, plaindata: bytes, offset: int, /) -> Generator[int, None, yield pd_byte ^ ks_byte def _iterdecrypt(self, cipherdata: bytes, offset: int, /) -> Generator[int, None, None]: - decrypt = self._cipher.decrypt - iterprexor_cipherdata: Callable[[bytes], Iterator[int]] | None = getattr(self, '_iterprexor_cipherdata', None) - if not cipherdata: return - if not self._keystream_available: - yield from iter(decrypt(cipherdata, offset)) - elif iterprexor_cipherdata: - keystream = self._cipher.keystream - for prexor_cd_byte, ks_byte in zip(iterprexor_cipherdata(cipherdata), keystream(len(cipherdata), offset)): - yield prexor_cd_byte ^ ks_byte + if self._keystream_available: + prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self._cipher, 'prexor_decrypt', None) + postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self._cipher, 'postxor_decrypt', None) + keystream = self._cipher.keystream(len(cipherdata), offset) + + if prexor: + cd_strm = prexor(cipherdata) + else: + cd_strm = cipherdata + pd_noxor_strm = (cd_byte ^ ks_byte for cd_byte, ks_byte in zip(cd_strm, keystream)) + if postxor: + pd_strm = postxor(pd_noxor_strm) + else: + pd_strm = pd_noxor_strm else: - keystream = self._cipher.keystream - for cd_byte, ks_byte in zip(cipherdata, keystream(len(cipherdata), offset)): - yield cd_byte ^ ks_byte + pd_strm = self._cipher.decrypt(cipherdata, offset) - def _iterdecrypt_untilnl(self, cipherdata: bytes, offset: int, /) -> Generator[int, None, None]: - decrypt = self._cipher.decrypt - iterprexor_cipherdata: Callable[[bytes], Iterator[int]] | None = getattr(self, '_iterprexor_cipherdata', None) + yield from pd_strm + def _iterdecrypt_untilnl(self, cipherdata: bytes, offset: int, /) -> Generator[int, None, None]: if not cipherdata: return - if not self._keystream_available: - for pd_byte in iter(decrypt(cipherdata, offset)): - yield pd_byte - if pd_byte == 10: - return - elif iterprexor_cipherdata: - keystream = self._cipher.keystream - for prexor_cd_byte, ks_byte in zip(iterprexor_cipherdata(cipherdata), keystream(len(cipherdata), offset)): - yld = prexor_cd_byte ^ ks_byte - yield yld - if yld == 10: - return + if self._keystream_available: + prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self._cipher, 'prexor_decrypt', None) + postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self._cipher, 'postxor_decrypt', None) + keystream = self._cipher.keystream(len(cipherdata), offset) + + if prexor: + cd_strm = prexor(cipherdata) + else: + cd_strm = cipherdata + pd_noxor_strm = (cd_byte ^ ks_byte for cd_byte, ks_byte in zip(cd_strm, keystream)) + if postxor: + pd_strm = postxor(pd_noxor_strm) + else: + pd_strm = pd_noxor_strm else: - keystream = self._cipher.keystream - for cd_byte, ks_byte in zip(cipherdata, keystream(len(cipherdata), offset)): - yld = cd_byte ^ ks_byte - yield yld - if yld == 10: - return + pd_strm = self._cipher.decrypt(cipherdata, offset) + + for pd_byte in pd_strm: + yield pd_byte + if pd_byte == 10: + return + + def can_encrypt(self) -> bool: + return self._encrypt_available + + def can_decrypt(self) -> bool: + return self._decrypt_available def getbuffer(self, nocryptlayer: bool = False) -> memoryview: if nocryptlayer: return super().getbuffer() else: - raise NotImplementedError('memoryview with crypt layer is not supported') + raise io.UnsupportedOperation('memoryview with crypt layer is not supported') def getvalue(self, nocryptlayer: bool = False) -> bytes: if nocryptlayer: return super().getvalue() + elif not self.can_decrypt(): + raise io.UnsupportedOperation('getvalue with crypt layer') else: return bytes(self._iterdecrypt(super().getvalue(), 0)) def read(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) -> bytes: if nocryptlayer: return super().read(size) + elif not self.can_decrypt(): + raise io.UnsupportedOperation('read with crypt layer') else: curpos = self.tell() if size is None: @@ -786,11 +840,15 @@ def read(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) -> return result def read1(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) -> bytes: + if not (nocryptlayer or self.can_decrypt()): + raise io.UnsupportedOperation('read1 with crypt layer') return self.read(size, nocryptlayer) def readline(self, size: IntegerLike | None = -1, /, nocryptlayer: bool = False) -> bytes: if nocryptlayer: return super().readline(size) + elif not self.can_decrypt(): + raise io.UnsupportedOperation('readline with crypt layer') else: curpos = self.tell() if size is None: @@ -826,6 +884,8 @@ def readlines(self, ) -> list[bytes]: if nocryptlayer: return super().readlines(hint) + elif not self.can_decrypt(): + raise io.UnsupportedOperation('readlines with crypt layer') else: curpos = self.tell() max_read_size = len(super().getvalue()[curpos:]) @@ -852,6 +912,8 @@ def readlines(self, def readinto(self, buffer: WritableBuffer, /, nocryptlayer: bool = False) -> int: if nocryptlayer: return super().readinto(buffer) + elif not self.can_decrypt(): + raise io.UnsupportedOperation('readinto with crypt layer') else: if not isinstance(buffer, memoryview): buffer = memoryview(buffer) @@ -865,11 +927,15 @@ def readinto(self, buffer: WritableBuffer, /, nocryptlayer: bool = False) -> int return data_len def readinto1(self, buffer: WritableBuffer, /, nocryptlayer: bool = False) -> int: + if not (nocryptlayer or self.can_decrypt()): + raise io.UnsupportedOperation('readinto1 with crypt layer') return self.readinto(buffer, nocryptlayer) def write(self, data: BytesLike, /, nocryptlayer: bool = False) -> int: if nocryptlayer: return super().write(data) + elif not self.can_encrypt(): + raise io.UnsupportedOperation('write with crypt layer') else: curpos = self.tell() return super().write( @@ -879,6 +945,8 @@ def write(self, data: BytesLike, /, nocryptlayer: bool = False) -> int: ) def writelines(self, lines: Iterable[BytesLike], /, nocryptlayer: bool = False) -> None: + if not (nocryptlayer or self.can_encrypt()): + raise io.UnsupportedOperation('write with crypt layer') for data in lines: self.write(data, nocryptlayer) From 5a85cd3e57862fad92fe73aecfe0434273a7c7f0 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 19 Dec 2022 18:25:42 +0800 Subject: [PATCH 18/58] =?UTF-8?q?=E4=B8=BA=20stdciphers.py=20=E4=B8=AD?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=9A=84=20Cipher=20=E9=83=BD=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E4=BA=86=201c83b0f2057b9f6f0f0e5a853360aacb9522b4ca?= =?UTF-8?q?=20=E7=9A=84=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/stdciphers.py | 52 +++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index dc9afe0..1908b74 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -34,9 +34,17 @@ class StreamedAESWithModeECB(CipherSkel): def blocksize(self) -> int: return 16 - @property - def master_key(self) -> bytes: - return self._key + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._key + elif isinstance(keyname, str): + raise ValueError( + f"'keyname' must be 'master', not {repr(keyname)}" + ) + else: + raise TypeError( + f"'keyname' must be str, not {type(keyname).__name__}" + ) def __init__(self, key: BytesLike, /) -> None: self._key = tobytes(key) @@ -59,10 +67,17 @@ class TEAWithModeECB(CipherSkel): def blocksize(self) -> int: return 16 - @property - def master_key(self) -> bytes: - """主要的密钥。""" - return self._key + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._key + elif isinstance(keyname, str): + raise ValueError( + f"'keyname' must be 'master', not {repr(keyname)}" + ) + else: + raise TypeError( + f"'keyname' must be str, not {type(keyname).__name__}" + ) def __init__(self, key: BytesLike, @@ -91,7 +106,8 @@ def transvalues(cls, data: bytes, key: bytes) -> tuple[int, int, int, int, int, return v0, v1, k0, k1, k2, k3 def encrypt(self, plaindata: BytesLike, /) -> bytes: - v0, v1, k0, k1, k2, k3 = self.transvalues(tobytes(plaindata), self.master_key) + master_key = self.getkey('master') + v0, v1, k0, k1, k2, k3 = self.transvalues(tobytes(plaindata), master_key) delta = self._delta rounds = self._rounds @@ -108,7 +124,8 @@ def encrypt(self, plaindata: BytesLike, /) -> bytes: return v0.to_bytes(4, 'big') + v1.to_bytes(4, 'big') def decrypt(self, cipherdata: BytesLike, /) -> bytes: - v0, v1, k0, k1, k2, k3 = self.transvalues(tobytes(cipherdata), self.master_key) + master_key = self.getkey('master') + v0, v1, k0, k1, k2, k3 = self.transvalues(tobytes(cipherdata), master_key) delta = self._delta rounds = self._rounds @@ -126,6 +143,18 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: class TarsCppTCTEAWithModeCBC(CipherSkel): + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._lower_level_tea_cipher.getkey('master') + elif isinstance(keyname, str): + raise ValueError( + f"'keyname' must be 'master', not {repr(keyname)}" + ) + else: + raise TypeError( + f"'keyname' must be str, not {type(keyname).__name__}" + ) + @CachedClassInstanceProperty def blocksize(self) -> int: return 8 @@ -142,11 +171,6 @@ def salt_len(self) -> int: def zero_len(self) -> int: return 7 - @property - def master_key(self) -> bytes: - """主要的密钥。""" - return self._lower_level_tea_cipher.master_key - @property def lower_level_cipher(self) -> TEAWithModeECB: """使用的下层 Cipher。""" From 9e5ccac7aa0776bb5576de38d1e49eeea8e37a16 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 19 Dec 2022 18:31:51 +0800 Subject: [PATCH 19/58] =?UTF-8?q?=E4=B8=BA=20`StreamCipherBasedCryptedIOPr?= =?UTF-8?q?oto`=20=E6=B7=BB=E5=8A=A0=E5=8F=AA=E8=AF=BB=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=20`master=5Fkey`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/typedefs.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libtakiyasha/typedefs.py b/src/libtakiyasha/typedefs.py index 513df7f..f6302d7 100644 --- a/src/libtakiyasha/typedefs.py +++ b/src/libtakiyasha/typedefs.py @@ -85,6 +85,10 @@ class StreamCipherBasedCryptedIOProto(Protocol): def cipher(self) -> StreamCipherProto | KeyStreamBasedStreamCipherProto: raise NotImplementedError + @property + def master_key(self) -> bytes | None: + raise NotImplementedError + def read(self, size: IntegerLike = -1, /) -> bytes: raise NotImplementedError From dad78f9450bbe9fde8ca22670b61d8acb8a8ef5d Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 19 Dec 2022 18:53:46 +0800 Subject: [PATCH 20/58] =?UTF-8?q?=E4=B8=BA=20qmckeyciphers.py=20=E4=B8=AD?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=9A=84=20Cipher=20=E9=83=BD=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E4=BA=86=201c83b0f2057b9f6f0f0e5a853360aacb9522b4ca?= =?UTF-8?q?=20=E7=9A=84=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/qmckeyciphers.py | 64 +++++++++++++++++++-------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/libtakiyasha/qmc/qmckeyciphers.py b/src/libtakiyasha/qmc/qmckeyciphers.py index 7c46d97..128bc1d 100644 --- a/src/libtakiyasha/qmc/qmckeyciphers.py +++ b/src/libtakiyasha/qmc/qmckeyciphers.py @@ -22,6 +22,18 @@ def make_simple_key(salt: int, length: int) -> bytes: class QMCv2KeyEncryptV1(CipherSkel): + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._simple_key + elif isinstance(keyname, str): + raise ValueError( + f"'keyname' must be 'master', not {repr(keyname)}" + ) + else: + raise TypeError( + f"'keyname' must be str, not {type(keyname).__name__}" + ) + @property def simple_key(self) -> bytes: return self._simple_key @@ -76,22 +88,30 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: class QMCv2KeyEncryptV2(QMCv2KeyEncryptV1): - @property - def mix_key1(self) -> bytes: - return self._mix_key1 - - @property - def mix_key2(self) -> bytes: - return self._mix_key2 - - def __init__(self, simple_key: BytesLike, mix_key1: BytesLike, mix_key2: BytesLike, /): - self._mix_key1 = tobytes(mix_key1) - self._mix_key2 = tobytes(mix_key2) - - self._encrypt_stage1_decrypt_stage2_tea_cipher = TarsCppTCTEAWithModeCBC(self._mix_key2, + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._simple_key + elif keyname == 'garble1': + return self._garble_key1 + elif keyname == 'garble2': + return self._garble_key2 + elif isinstance(keyname, str): + raise ValueError( + f"'keyname' must be 'master', 'mix1', or 'mix2', not {repr(keyname)}" + ) + else: + raise TypeError( + f"'keyname' must be str, not {type(keyname).__name__}" + ) + + def __init__(self, simple_key: BytesLike, garble_key1: BytesLike, garble_key2: BytesLike, /): + self._garble_key1 = tobytes(garble_key1) + self._garble_key2 = tobytes(garble_key2) + + self._encrypt_stage1_decrypt_stage2_tea_cipher = TarsCppTCTEAWithModeCBC(self._garble_key2, rounds=32 ) - self._encrypt_stage2_decrypt_stage1_tea_cipher = TarsCppTCTEAWithModeCBC(self._mix_key1, + self._encrypt_stage2_decrypt_stage1_tea_cipher = TarsCppTCTEAWithModeCBC(self._garble_key1, rounds=32 ) @@ -104,11 +124,15 @@ def encrypt(self, plaindata: BytesLike, /) -> bytes: qmcv2_key_encv1_key_encrypted_b64encoded = b64encode(qmcv2_key_encv1_key_encrypted) try: - encrypt_stage1 = self._encrypt_stage1_decrypt_stage2_tea_cipher.encrypt(qmcv2_key_encv1_key_encrypted_b64encoded) + encrypt_stage1 = self._encrypt_stage1_decrypt_stage2_tea_cipher.encrypt( + qmcv2_key_encv1_key_encrypted_b64encoded + ) except Exception as exc: raise CipherEncryptingError('QMCv2 key encrypt v2 stage 1 key encrypt failed') from exc try: - encrypt_stage2 = self._encrypt_stage2_decrypt_stage1_tea_cipher.encrypt(encrypt_stage1) + encrypt_stage2 = self._encrypt_stage2_decrypt_stage1_tea_cipher.encrypt( + encrypt_stage1 + ) except Exception as exc: raise CipherEncryptingError('QMCv2 key encrypt v2 stage 2 key encrypt failed') from exc @@ -119,11 +143,15 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: # cipherdata 应当是 b64decode 之后,去除了开头 18 个字符的结果 try: - decrypt_stage1: bytes = self._encrypt_stage2_decrypt_stage1_tea_cipher.decrypt(cipherdata, zero_check=True) + decrypt_stage1: bytes = self._encrypt_stage2_decrypt_stage1_tea_cipher.decrypt( + cipherdata, zero_check=True + ) except Exception as exc: raise CipherDecryptingError('QMCv2 key encrypt v2 stage 1 key decrypt failed') from exc try: - decrypt_stage2: bytes = self._encrypt_stage1_decrypt_stage2_tea_cipher.decrypt(decrypt_stage1, zero_check=True) # 实际上就是 QMCv2 Key Encrypt V1 的密钥 + decrypt_stage2: bytes = self._encrypt_stage1_decrypt_stage2_tea_cipher.decrypt( + decrypt_stage1, zero_check=True + ) # 实际上就是 QMCv2 Key Encrypt V1 的密钥 except Exception as exc: raise CipherDecryptingError('QMCv2 key encrypt v2 stage 2 key decrypt failed') from exc From c29ef40a7b7cf9098b0ca3e91e39a2307a56e209 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 19 Dec 2022 21:05:47 +0800 Subject: [PATCH 21/58] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BA=86=20`=5F=5Fall?= =?UTF-8?q?=5F=5F`=20=E9=81=97=E6=BC=8F=E7=9A=84=20`probe()`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index b13d456..c8d8ba6 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -20,7 +20,7 @@ from .typeutils import isfilepath, tobytes, verify_fileobj from .warns import CrypterCreatingWarning -__all__ = ['CloudMusicIdentifier', 'NCM'] +__all__ = ['CloudMusicIdentifier', 'NCM', 'probe'] MODULE_BINARIES_ROOTDIR = BINARIES_ROOTDIR / Path(__file__).stem From 49f45c65f690330b8a81af9c451e5bc8a0db66b3 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 19 Dec 2022 22:29:02 +0800 Subject: [PATCH 22/58] =?UTF-8?q?=E4=B8=BA=20qmcdataciphers.py=20=E4=B8=AD?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=9A=84=20Cipher=20=E9=83=BD=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E4=BA=86=201c83b0f2057b9f6f0f0e5a853360aacb9522b4ca?= =?UTF-8?q?=20=E7=9A=84=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/qmcdataciphers.py | 190 +++++++++++++------------ 1 file changed, 102 insertions(+), 88 deletions(-) diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index 9629776..ccb97dd 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -9,21 +9,80 @@ from ..typedefs import BytesLike, IntegerLike from ..typeutils import CachedClassInstanceProperty, tobytes, toint -__all__ = [ - 'HardenedRC4', - 'Mask128' -] - class Mask128(KeyStreamBasedStreamCipherSkel): - @property - def original_master_key(self) -> bytes | None: - if hasattr(self, '_original_master_key'): - return self._original_master_key + def __init__(self, mask128: BytesLike, /): + self._mask128 = tobytes(mask128) + if len(self._mask128) != 128: + raise ValueError(f"invalid mask length: should be 128, got {len(self._mask128)}") - @property - def mask128(self) -> bytes: - return self._mask128 + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._mask128 + elif keyname == 'original': + return getattr(self, '_original_qmcv2_key256', None) + elif isinstance(keyname, str): + raise ValueError( + f"'keyname' must be 'master' or 'original', not {repr(keyname)}" + ) + else: + raise TypeError( + f"'keyname' must be str, not {type(keyname).__name__}" + ) + + @classmethod + def cls_keystream(cls, + mask128: BytesLike, + nbytes: IntegerLike, + offset: IntegerLike, / + ) -> Generator[int, None, None]: + mask = tobytes(mask128) + if len(mask) != 128: + raise ValueError(f"invalid mask length: should be 128, got {len(mask)}") + nbytes = toint(nbytes) + offset = toint(offset) + if offset < 0: + raise ValueError("second argument 'offset' must be a non-negative integer") + if nbytes < 0: + raise ValueError("first argument 'nbytes' must be a non-negative integer") + + firstblk_data = mask * 256 # 前 32768 字节 + secondblk_data = firstblk_data[1:-1] # 第 32769 至 65535 字节 + startblk_data = firstblk_data + secondblk_data # 初始块:前 65535 字节 + startblk_len = len(startblk_data) + commonblk_data = firstblk_data[:-1] # 普通块:第 65536 字节往后每一个 32767 大小的块 + commonblk_len = len(commonblk_data) + + if 0 <= offset < startblk_len: + max_target_in_startblk_len = startblk_len - offset + target_in_commonblk_len = nbytes - max_target_in_startblk_len + target_in_startblk_len = min(nbytes, max_target_in_startblk_len) + yield from startblk_data[offset:offset + target_in_startblk_len] + if target_in_commonblk_len <= 0: + return + else: + offset = 0 + else: + offset -= startblk_len + target_in_commonblk_len = nbytes + + target_offset_in_commonblk = offset % commonblk_len + if target_offset_in_commonblk == 0: + target_before_commonblk_area_len = 0 + else: + target_before_commonblk_area_len = commonblk_len - target_offset_in_commonblk + yield from commonblk_data[target_offset_in_commonblk:target_offset_in_commonblk + target_before_commonblk_area_len] + target_in_commonblk_len -= target_before_commonblk_area_len + + target_overrided_whole_commonblk_count = target_in_commonblk_len // commonblk_len + target_after_commonblk_area_len = target_in_commonblk_len % commonblk_len + + for _ in range(target_overrided_whole_commonblk_count): + yield from commonblk_data + yield from commonblk_data[:target_after_commonblk_area_len] + + def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + yield from self.cls_keystream(self._mask128, nbytes, offset) @classmethod def from_qmcv1_mask44(cls, mask44: BytesLike) -> Mask128: @@ -74,70 +133,44 @@ def from_qmcv2_key256(cls, key256: BytesLike) -> Mask128: mask128[idx128] = ((value << rotate) % 256) | ((value >> rotate) % 256) instance = cls(mask128) - instance._original_master_key = key256 + instance._original_qmcv2_key256 = key256 return instance - def __init__(self, mask128: BytesLike, /): - self._mask128 = tobytes(mask128) - if len(self._mask128) != 128: - raise ValueError(f"invalid mask length: should be 128, got {len(self._mask128)}") - - @classmethod - def cls_keystream(cls, nbytes: IntegerLike, offset: IntegerLike, /, mask128: BytesLike) -> Generator[int, None, None]: - mask128: bytes = tobytes(mask128) - if len(mask128) != 128: - raise ValueError(f"invalid mask length (should be 128, got {len(mask128)})") - firstblk_data = mask128 * 256 # 前 32768 字节 - secondblk_data = firstblk_data[1:-1] # 第 32769 至 65535 字节 - startblk_data = firstblk_data + secondblk_data # 初始块:前 65535 字节 - startblk_len = len(startblk_data) - commonblk_data = firstblk_data[:-1] # 普通块:第 65536 字节往后每一个 32767 大小的块 - commonblk_len = len(commonblk_data) - offset = toint(offset) - nbytes = toint(nbytes) - if offset < 0: - raise ValueError("second argument 'offset' must be a non-negative integer") - if nbytes < 0: - raise ValueError("first argument 'nbytes' must be a non-negative integer") - if 0 <= offset < startblk_len: - max_target_in_startblk_len = startblk_len - offset - target_in_commonblk_len = nbytes - max_target_in_startblk_len - target_in_startblk_len = min(nbytes, max_target_in_startblk_len) - yield from startblk_data[offset:offset + target_in_startblk_len] - if target_in_commonblk_len <= 0: - return - else: - offset = 0 - else: - offset -= startblk_len - target_in_commonblk_len = nbytes - - target_offset_in_commonblk = offset % commonblk_len - if target_offset_in_commonblk == 0: - target_before_commonblk_area_len = 0 - else: - target_before_commonblk_area_len = commonblk_len - target_offset_in_commonblk - yield from commonblk_data[target_offset_in_commonblk:target_offset_in_commonblk + target_before_commonblk_area_len] - target_in_commonblk_len -= target_before_commonblk_area_len - - target_overrided_whole_commonblk_count = target_in_commonblk_len // commonblk_len - target_after_commonblk_area_len = target_in_commonblk_len % commonblk_len +class HardenedRC4(KeyStreamBasedStreamCipherSkel): + def __init__(self, key: BytesLike, /): + self._key = tobytes(key) - for _ in range(target_overrided_whole_commonblk_count): - yield from commonblk_data - yield from commonblk_data[:target_after_commonblk_area_len] + key_len = len(self._key) + if key_len == 0: + raise ValueError("first argument 'key' cannot be an empty bytestring") + if b'\x00' in self._key: + raise ValueError("first argument 'key' cannot contain null bytes") - def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: - yield from self.cls_keystream(nbytes, offset, mask128=self._mask128) + self._box = bytearray(i % 256 for i in range(key_len)) + j = 0 + for i in range(key_len): + j = (j + self._box[i] + self._key[i % key_len]) % key_len + self._box[i], self._box[j] = self._box[j], self._box[i] + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._key + elif isinstance(keyname, str): + raise ValueError( + f"'keyname' must be 'master', not {repr(keyname)}" + ) + else: + raise TypeError( + f"'keyname' must be str, not {type(keyname).__name__}" + ) -class HardenedRC4(KeyStreamBasedStreamCipherSkel): @property + @lru_cache def hash_base(self) -> int: base = 1 - key = self._master_key + key = self._key for i in range(len(key)): v: int = key[i] @@ -157,29 +190,10 @@ def first_segment_size(self) -> int: def common_segment_size(self) -> int: return 5120 - @property - def master_key(self) -> bytes: - return self._master_key - - def __init__(self, master_key: BytesLike, /): - self._master_key = tobytes(master_key) - - key_len = len(self._master_key) - if key_len == 0: - raise ValueError("first argument 'master_key' cannot be an empty bytestring") - if b'\x00' in self._master_key: - raise ValueError("b'\\x00' cannot appear in the first argument 'master_key'") - - self._box = bytearray(i % 256 for i in range(key_len)) - j = 0 - for i in range(key_len): - j = (j + self._box[i] + self._master_key[i % key_len]) % key_len - self._box[i], self._box[j] = self._box[j], self._box[i] - @lru_cache(maxsize=65536) def _get_segment_skip(self, value: int) -> int: - key = self._master_key - key_len = len(self._master_key) + key = self._key + key_len = len(self._key) seed = key[value % key_len] idx = int(self.hash_base / ((value + 1) * seed) * 100) @@ -190,7 +204,7 @@ def _yield_first_segment_keystream(self, blksize: int, offset: int ) -> Generator[int, None, None]: - key = self._master_key + key = self._key for i in range(offset, offset + blksize): yield key[self._get_segment_skip(i)] @@ -198,7 +212,7 @@ def _yield_common_segment_keystream(self, blksize: int, offset: int ) -> Generator[int, None, None]: - key_len = len(self._master_key) + key_len = len(self._key) box = self._box.copy() j, k = 0, 0 From 182a1706a6bd7d570ddd9ceed74c1257856490d5 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Tue, 20 Dec 2022 17:04:59 +0800 Subject: [PATCH 23/58] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=BA=86=E5=AF=B9=E2=80=9Csimple=20key=E2=80=9D=E7=9A=84?= =?UTF-8?q?=E7=A7=B0=E8=B0=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/qmckeyciphers.py | 28 ++++++++++++--------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/libtakiyasha/qmc/qmckeyciphers.py b/src/libtakiyasha/qmc/qmckeyciphers.py index 128bc1d..ca6947c 100644 --- a/src/libtakiyasha/qmc/qmckeyciphers.py +++ b/src/libtakiyasha/qmc/qmckeyciphers.py @@ -11,20 +11,20 @@ from ..typeutils import tobytes __all__ = [ - 'make_simple_key', + 'make_core_key', 'QMCv2KeyEncryptV1', 'QMCv2KeyEncryptV2' ] -def make_simple_key(salt: int, length: int) -> bytes: +def make_core_key(salt: int, length: int) -> bytes: return bytes(int(abs(tan(salt + _ * 0.1) * 100)) for _ in range(length)) class QMCv2KeyEncryptV1(CipherSkel): def getkey(self, keyname: str = 'master') -> bytes | None: if keyname == 'master': - return self._simple_key + return self._core_key elif isinstance(keyname, str): raise ValueError( f"'keyname' must be 'master', not {repr(keyname)}" @@ -34,22 +34,18 @@ def getkey(self, keyname: str = 'master') -> bytes | None: f"'keyname' must be str, not {type(keyname).__name__}" ) - @property - def simple_key(self) -> bytes: - return self._simple_key - - def __init__(self, simple_key: BytesLike, /): - self._simple_key = tobytes(simple_key) + def __init__(self, key: BytesLike, /): + self._core_key = tobytes(key) self._half_of_keysize = TarsCppTCTEAWithModeCBC.master_key_size // 2 - if len(self._simple_key) != self._half_of_keysize: - raise ValueError(f"invalid length of simple key: " - f"should be {self._half_of_keysize}, not {len(self._simple_key)}" + if len(self._core_key) != self._half_of_keysize: + raise ValueError(f"invalid length of core key: " + f"should be {self._half_of_keysize}, not {len(self._core_key)}" ) self._key_buf = bytearray(TarsCppTCTEAWithModeCBC.master_key_size) for idx in range(TarsCppTCTEAWithModeCBC.blocksize): - self._key_buf[idx << 1] = self._simple_key[idx] + self._key_buf[idx << 1] = self._core_key[idx] def encrypt(self, plaindata: BytesLike, /) -> bytes: plaindata = tobytes(plaindata) @@ -90,7 +86,7 @@ def decrypt(self, cipherdata: BytesLike, /) -> bytes: class QMCv2KeyEncryptV2(QMCv2KeyEncryptV1): def getkey(self, keyname: str = 'master') -> bytes | None: if keyname == 'master': - return self._simple_key + return self._core_key elif keyname == 'garble1': return self._garble_key1 elif keyname == 'garble2': @@ -104,7 +100,7 @@ def getkey(self, keyname: str = 'master') -> bytes | None: f"'keyname' must be str, not {type(keyname).__name__}" ) - def __init__(self, simple_key: BytesLike, garble_key1: BytesLike, garble_key2: BytesLike, /): + def __init__(self, key: BytesLike, garble_key1: BytesLike, garble_key2: BytesLike, /): self._garble_key1 = tobytes(garble_key1) self._garble_key2 = tobytes(garble_key2) @@ -115,7 +111,7 @@ def __init__(self, simple_key: BytesLike, garble_key1: BytesLike, garble_key2: B rounds=32 ) - super().__init__(simple_key) + super().__init__(key) def encrypt(self, plaindata: BytesLike, /) -> bytes: plaindata = tobytes(plaindata) From 52392fbfba23ac00d4888df4a87a7bee6c828678 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Tue, 20 Dec 2022 17:09:32 +0800 Subject: [PATCH 24/58] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=E6=89=80?= =?UTF-8?q?=E6=9C=89=20Cipher=20=E7=B1=BB=E7=9A=84=20`getkey()`=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E7=9A=84=E5=8F=82=E6=95=B0=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/qmcdataciphers.py | 16 ------------- src/libtakiyasha/qmc/qmckeyciphers.py | 16 ------------- src/libtakiyasha/stdciphers.py | 32 -------------------------- 3 files changed, 64 deletions(-) diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index ccb97dd..929ac3a 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -21,14 +21,6 @@ def getkey(self, keyname: str = 'master') -> bytes | None: return self._mask128 elif keyname == 'original': return getattr(self, '_original_qmcv2_key256', None) - elif isinstance(keyname, str): - raise ValueError( - f"'keyname' must be 'master' or 'original', not {repr(keyname)}" - ) - else: - raise TypeError( - f"'keyname' must be str, not {type(keyname).__name__}" - ) @classmethod def cls_keystream(cls, @@ -157,14 +149,6 @@ def __init__(self, key: BytesLike, /): def getkey(self, keyname: str = 'master') -> bytes | None: if keyname == 'master': return self._key - elif isinstance(keyname, str): - raise ValueError( - f"'keyname' must be 'master', not {repr(keyname)}" - ) - else: - raise TypeError( - f"'keyname' must be str, not {type(keyname).__name__}" - ) @property @lru_cache diff --git a/src/libtakiyasha/qmc/qmckeyciphers.py b/src/libtakiyasha/qmc/qmckeyciphers.py index ca6947c..7174708 100644 --- a/src/libtakiyasha/qmc/qmckeyciphers.py +++ b/src/libtakiyasha/qmc/qmckeyciphers.py @@ -25,14 +25,6 @@ class QMCv2KeyEncryptV1(CipherSkel): def getkey(self, keyname: str = 'master') -> bytes | None: if keyname == 'master': return self._core_key - elif isinstance(keyname, str): - raise ValueError( - f"'keyname' must be 'master', not {repr(keyname)}" - ) - else: - raise TypeError( - f"'keyname' must be str, not {type(keyname).__name__}" - ) def __init__(self, key: BytesLike, /): self._core_key = tobytes(key) @@ -91,14 +83,6 @@ def getkey(self, keyname: str = 'master') -> bytes | None: return self._garble_key1 elif keyname == 'garble2': return self._garble_key2 - elif isinstance(keyname, str): - raise ValueError( - f"'keyname' must be 'master', 'mix1', or 'mix2', not {repr(keyname)}" - ) - else: - raise TypeError( - f"'keyname' must be str, not {type(keyname).__name__}" - ) def __init__(self, key: BytesLike, garble_key1: BytesLike, garble_key2: BytesLike, /): self._garble_key1 = tobytes(garble_key1) diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index 1908b74..63f772b 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -37,14 +37,6 @@ def blocksize(self) -> int: def getkey(self, keyname: str = 'master') -> bytes | None: if keyname == 'master': return self._key - elif isinstance(keyname, str): - raise ValueError( - f"'keyname' must be 'master', not {repr(keyname)}" - ) - else: - raise TypeError( - f"'keyname' must be str, not {type(keyname).__name__}" - ) def __init__(self, key: BytesLike, /) -> None: self._key = tobytes(key) @@ -70,14 +62,6 @@ def blocksize(self) -> int: def getkey(self, keyname: str = 'master') -> bytes | None: if keyname == 'master': return self._key - elif isinstance(keyname, str): - raise ValueError( - f"'keyname' must be 'master', not {repr(keyname)}" - ) - else: - raise TypeError( - f"'keyname' must be str, not {type(keyname).__name__}" - ) def __init__(self, key: BytesLike, @@ -146,14 +130,6 @@ class TarsCppTCTEAWithModeCBC(CipherSkel): def getkey(self, keyname: str = 'master') -> bytes | None: if keyname == 'master': return self._lower_level_tea_cipher.getkey('master') - elif isinstance(keyname, str): - raise ValueError( - f"'keyname' must be 'master', not {repr(keyname)}" - ) - else: - raise TypeError( - f"'keyname' must be str, not {type(keyname).__name__}" - ) @CachedClassInstanceProperty def blocksize(self) -> int: @@ -413,14 +389,6 @@ def __init__(self, key: BytesLike, /) -> None: def getkey(self, keyname: str = 'master') -> bytes: if keyname == 'master': return self._key - elif isinstance(keyname, str): - raise ValueError( - f"'keyname' must be 'master', not {repr(keyname)}" - ) - else: - raise TypeError( - f"'keyname' must be str, not {type(keyname).__name__}" - ) def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: offset = toint(offset) From 285bc9cd54108e96a5c2d3e9b50cca83ff84f311 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Tue, 20 Dec 2022 19:21:54 +0800 Subject: [PATCH 25/58] =?UTF-8?q?=E7=8E=B0=E5=9C=A8=20`NCM.open()`=20?= =?UTF-8?q?=E5=92=8C=20`NCM.from=5Ffile()`=20=E5=8F=AF=E6=8E=A5=E5=8F=97?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E5=8F=82=E6=95=B0=20`master=5Fkey`=20?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E4=B8=BB=E5=AF=86=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 52 ++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index c8d8ba6..dd70368 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -306,21 +306,26 @@ class NCM(EncryptedBytesIOSkel): @classmethod def from_file(cls, - file_or_info: tuple[Path | IO[bytes], NCMFileInfo | None] | FilePath | IO[bytes], /, - core_key: BytesLike, - tag_key: BytesLike = None + filething_or_info: tuple[Path | IO[bytes], NCMFileInfo | None] | FilePath | IO[bytes], /, + core_key: BytesLike = None, + tag_key: BytesLike = None, + master_key: BytesLike = None ): """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``NCM.open()`` 代替。""" warnings.warn( - DeprecationWarning('NCM.from_file() is deprecated and no longer used. Use NCM.open() instead.') + DeprecationWarning( + f'{cls.__name__}.from_file() is deprecated and no longer used. ' + f'Use {cls.__name__}.open() instead.' + ) ) - return cls.open(file_or_info, core_key=core_key, tag_key=tag_key) + return cls.open(filething_or_info, core_key=core_key, tag_key=tag_key, master_key=master_key) @classmethod def open(cls, filething_or_info: tuple[Path | IO[bytes], NCMFileInfo | None] | FilePath | IO[bytes], /, - core_key: BytesLike, - tag_key: BytesLike = None + core_key: BytesLike = None, + tag_key: BytesLike = None, + master_key: BytesLike = None ): """打开一个 NCM 文件,并返回一个 ``NCM`` 对象。 @@ -331,25 +336,43 @@ def open(cls, ``filething_or_info`` 也可以接受 ``probe()`` 函数的返回值: 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 - 第二个参数 ``core_key`` 是必需的,用于解密文件内嵌的主密钥。 + 第二个参数 ``core_key`` 一般情况下是必需的,用于解密文件内嵌的主密钥。 + 例外:如果你提供了第四个参数 ``master_key``,那么它是可选的。 第三个参数 ``tag_key`` 可选,用于解密文件内嵌的歌曲信息。如果留空,则使用默认值: ``b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28'`` + 第四个参数 ``master_key`` 可选,如果提供,将会被作为主密钥使用, + 而文件内置的主密钥会被忽略,``core_key`` 也不再是必需参数。 + 一般不需要填写此参数,因为 NCM 文件总是内嵌加密的主密钥,从而可以轻松地获得。 + Args: filething_or_info: 源文件的路径或文件对象,或者 probe() 的返回值 core_key: 核心密钥,用于解密文件内嵌的主密钥 tag_key: 歌曲信息密钥,用于解密文件内嵌的歌曲信息 + master_key: 如果提供,将会被作为主密钥使用,而文件内置的主密钥会被忽略 + Raises: + TypeError: 参数 core_key 和 master_key 都未提供 """ - core_key = tobytes(core_key) + if core_key is not None: + core_key = tobytes(core_key) if tag_key is not None: tag_key = tobytes(tag_key) + if master_key is not None: + master_key = tobytes(master_key) + if master_key is None and core_key is None: + raise TypeError( + f"{cls.__name__}.open() missing 1 argument: 'core_key'" + ) def operation(fd: IO[bytes]) -> cls: - master_key = StreamedAESWithModeECB(core_key).decrypt( - fileinfo.master_key_encrypted - )[17:] # 去除开头的 b'neteasecloudmusic' - cipher = fileinfo.cipher_ctor(master_key) + if master_key is None: + target_master_key = StreamedAESWithModeECB(core_key).decrypt( + fileinfo.master_key_encrypted + )[17:] # 去除开头的 b'neteasecloudmusic' + else: + target_master_key = master_key + cipher = fileinfo.cipher_ctor(target_master_key) try: ncm_tag = CloudMusicIdentifier.from_ncm_163key( @@ -418,7 +441,8 @@ def to_file(self, ) -> None: """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``NCM.save()`` 代替。""" warnings.warn( - DeprecationWarning('NCM.to_file() is deprecated and no longer used. Use NCM.save() instead.') + f'{type(self).__name__}.from_file() is deprecated and no longer used. ' + f'Use {type(self).__name__}.save() instead.' ) return self.save(core_key, filething=filething, tag_key=tag_key) From 5728b786fd06ebcc2be137487cb1fb634eccaa80 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Wed, 21 Dec 2022 00:52:53 +0800 Subject: [PATCH 26/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=20Deprecation?= =?UTF-8?q?Warning=20=E4=B8=8D=E7=94=9F=E6=95=88=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=9B=E4=BF=AE=E5=A4=8D=E4=BA=86=20`NCM.save()`=20?= =?UTF-8?q?=E7=9A=84=20filething=20=E5=8F=82=E6=95=B0=E5=9C=A8=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=BA=20None=20=E6=97=B6=E5=8F=AF=E8=83=BD?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9B=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=20`NCM.save()`=20=E7=9A=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index dd70368..cb3ef53 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -20,6 +20,8 @@ from .typeutils import isfilepath, tobytes, verify_fileobj from .warns import CrypterCreatingWarning +warnings.filterwarnings(action='default', category=DeprecationWarning, module=__name__) + __all__ = ['CloudMusicIdentifier', 'NCM', 'probe'] MODULE_BINARIES_ROOTDIR = BINARIES_ROOTDIR / Path(__file__).stem @@ -441,8 +443,10 @@ def to_file(self, ) -> None: """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``NCM.save()`` 代替。""" warnings.warn( - f'{type(self).__name__}.from_file() is deprecated and no longer used. ' - f'Use {type(self).__name__}.save() instead.' + DeprecationWarning( + f'{type(self).__name__}.from_file() is deprecated and no longer used. ' + f'Use {type(self).__name__}.save() instead.' + ) ) return self.save(core_key, filething=filething, tag_key=tag_key) @@ -455,7 +459,7 @@ def save(self, 第一个参数 ``core_key`` 是必需的,用于加密主密钥,以便嵌入到文件。 - 第二个参数 ``filething`` 需要是一个文件路径或文件对象。 + 第二个参数 ``filething`` 是可选的,如果提供此参数,需要是一个文件路径或文件对象。 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 @@ -500,6 +504,14 @@ def operation(fd: IO[bytes]) -> None: fd.write(self.getvalue(nocryptlayer=True)) + if filething is None: + if self.source is None: + raise TypeError( + "attribute 'self.source' and argument 'filething' are empty, " + "don't know which file to save to" + ) + filething = self.source + if isfilepath(filething): with open(filething, mode='wb') as fileobj: return operation(fileobj) From 15c209fcc6b062d48a1561bb79b15c50c6b7f216 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Wed, 21 Dec 2022 01:37:05 +0800 Subject: [PATCH 27/58] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E9=87=8D=E5=86=99?= =?UTF-8?q?=E4=BA=86=20QMC=20=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E5=88=86=E7=A6=BB=E4=BA=86=20QMCv2=20=E7=9A=84=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/__init__.py | 1253 +++++++++++++++--------------- 1 file changed, 648 insertions(+), 605 deletions(-) diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 08ff10b..4ebca1c 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -4,107 +4,205 @@ import warnings from base64 import b64decode, b64encode from dataclasses import dataclass -from secrets import token_bytes -from typing import IO, Literal +from pathlib import Path +from typing import Callable, IO, Literal, NamedTuple from .qmcdataciphers import HardenedRC4, Mask128 from .qmckeyciphers import QMCv2KeyEncryptV1, QMCv2KeyEncryptV2 -from ..exceptions import CrypterCreatingError, CrypterSavingError -from ..keyutils import make_random_ascii_string -from ..prototypes import CryptLayerWrappedIOSkel -from ..typedefs import BytesLike, FilePath, IntegerLike -from ..typeutils import isfilepath, tobytes, toint, verify_fileobj -from ..warns import CrypterCreatingWarning, CrypterSavingWarning +from ..exceptions import CrypterCreatingError +from ..keyutils import make_random_ascii_string, make_salt +from ..prototypes import EncryptedBytesIOSkel +from ..typedefs import BytesLike, FilePath +from ..typeutils import isfilepath, tobytes, verify_fileobj +from ..warns import CrypterSavingWarning + +warnings.filterwarnings(action='default', category=CrypterSavingWarning, module=__name__) +warnings.filterwarnings(action='default', category=DeprecationWarning, module=__name__) __all__ = [ - 'QMCv2QTag', - 'QMCv2STag', + 'probe_qmcv2', 'QMCv1', - 'QMCv2' + 'QMCv2', + 'QMCv2QTag', + 'QMCv2STag' ] @dataclass class QMCv2QTag: - """解析、存储和重建 QMCv2 文件末尾的 QTag 数据。""" - master_key_encrypted_b64encoded: bytes - song_id: int - unknown_value1: bytes + """解析、存储和重建 QMCv2 文件末尾的 QTag 数据。不包括主密钥。""" + song_id: int = 0 + unknown: int = 2 @classmethod - def from_bytes(cls, bytestring: BytesLike) -> QMCv2QTag: - segments = tobytes(bytestring).split(b',') - if len(segments) != 3: + def load(cls, qtag_serialized: BytesLike, /): + qtag_serialized = tobytes(qtag_serialized) + qtag_serialized_splitted = qtag_serialized.split(b',') + if len(qtag_serialized_splitted) != 3: raise ValueError('invalid QMCv2 QTag data: the counts of splitted segments ' - f'should be equal to 3, not {len(segments)}' + f'should be equal to 3, not {len(qtag_serialized_splitted)}' ) - - master_key_encrypted_b64encoded = segments[0] - song_id = int(segments[1]) - unknown_value1 = segments[2] - - return cls(master_key_encrypted_b64encoded, song_id, unknown_value1) - - def to_bytes(self, with_tail: bool = False) -> bytes: - ret = b','.join([ - self.master_key_encrypted_b64encoded, - str(self.song_id).encode('utf-8'), - self.unknown_value1 - ] + master_key_encrypted_b64encoded = qtag_serialized_splitted[0] + song_id: int = int(qtag_serialized_splitted[1]) + unknown: int = int(qtag_serialized_splitted[2]) + + return master_key_encrypted_b64encoded, cls(song_id=song_id, unknown=unknown) + + def dump(self, master_key_encrypted_b64encoded: BytesLike, /) -> bytes: + return b','.join( + [ + tobytes(master_key_encrypted_b64encoded), + str(self.song_id).encode('ascii'), + str(self.unknown).encode('ascii') + ] ) - if with_tail: - return ret + len(ret).to_bytes(4, 'big') + b'QTag' - else: - return ret - - @classmethod - def new(cls, master_key: BytesLike, simple_key: BytesLike, song_id: IntegerLike, unknown_value1: BytesLike) -> QMCv2QTag: - master_key_encrypted = QMCv2KeyEncryptV1(simple_key).encrypt(master_key) - master_key_encrypted_b64encoded = b64encode(master_key_encrypted) - - return cls(master_key_encrypted_b64encoded, - toint(song_id), - tobytes(unknown_value1) - ) @dataclass class QMCv2STag: """解析、存储和重建 QMCv2 文件末尾的 STag 数据。""" - song_id: int - unknown_value1: bytes - song_mid: str + song_id: int = 0 + unknown: int = 2 + song_mid: str = '0' * 14 @classmethod - def from_bytes(cls, bytestring: BytesLike) -> QMCv2STag: - segments = tobytes(bytestring).split(b',') - if len(segments) != 3: + def load(cls, stag_serialized: BytesLike, /): + stag_serialized = tobytes(stag_serialized) + stag_serialized_splitted = stag_serialized.split(b',') + if len(stag_serialized_splitted) != 3: raise ValueError('invalid QMCv2 STag data: the counts of splitted segments ' - f'should be equal to 3, not {len(segments)}' + f'should be equal to 3, not {len(stag_serialized_splitted)}' ) + song_id: int = int(stag_serialized_splitted[0]) + unknown: int = int(stag_serialized_splitted[1]) + song_mid: str = str(stag_serialized_splitted[2], encoding='ascii') + + return cls(song_id=song_id, unknown=unknown, song_mid=song_mid) + + def dump(self) -> bytes: + return b','.join( + [ + str(self.song_id).encode('ascii'), + str(self.unknown).encode('ascii'), + str(self.song_mid).encode('ascii') + ] + ) - song_id = int(segments[0]) - unknown_value1 = segments[1] - song_mid = segments[2].decode('utf-8') - return cls(song_id, unknown_value1, song_mid) +class QMCv2FileInfo(NamedTuple): + """用于存储 QMCv2 文件的信息。""" + cipher_ctor: Callable[[...], HardenedRC4] | Callable[[...], Mask128] | None + cipher_data_len: int + master_key_encrypted: bytes | None + master_key_encryption_ver: int | None + extra_info: QMCv2QTag | QMCv2STag | None - def to_bytes(self, with_tail: bool = False) -> bytes: - raw_song_id = str(self.song_id).encode('utf-8') - raw_song_mid = self.song_mid.encode('utf-8') - ret = b','.join([raw_song_id, self.unknown_value1, raw_song_mid]) - if with_tail: - return ret + len(ret).to_bytes(4, 'big') + b'STag' +def _guess_cipher_ctor(master_key: BytesLike, /, + is_encrypted: bool = True + ) -> Callable[[...], HardenedRC4] | Callable[[...], Mask128] | None: + if is_encrypted: + expected_keylen_mask128 = (272, 392) + expected_keylen_hardened_rc4 = (528, 736) + else: + expected_keylen_mask128 = (256, 256) + expected_keylen_hardened_rc4 = (512, 512) + + master_key = tobytes(master_key) + if len(master_key) in expected_keylen_mask128: + return Mask128.from_qmcv2_key256 + elif len(master_key) in expected_keylen_hardened_rc4: + return HardenedRC4 + elif len(master_key) == 128 and not is_encrypted: + return Mask128 + + +def probe_qmcv2(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], QMCv2FileInfo | None]: + """探测源文件 ``filething`` 是否为一个 QMCv2 文件。 + + 返回一个 2 个元素长度的元组:第一个元素为 ``filething``;如果 + ``filething`` 是 QMCv2 文件,那么第二个元素为一个 ``QMCv2FileInfo`` 对象;否则为 ``None``。 + + 本方法的返回值可以用于 ``QMCv2.open()`` 的第一个位置参数。 + + 本方法不适用于 QMCv1 文件的探测。 + + Args: + filething: 源文件的路径或文件对象 + Returns: + 一个 2 个元素长度的元组:第一个元素为 filething;如果 + filething 是 QMCv2 文件,那么第二个元素为一个 QMCv2FileInfo 对象;否则为 None。 + """ + + def operation(fd: IO[bytes]) -> QMCv2FileInfo | None: + total_size = fd.seek(-4, 2) + 4 + tail_data = fd.read(4) + + if tail_data == b'STag': + fd.seek(-8, 2) + tag_serialized_len = int.from_bytes(fd.read(4), 'big') + if tag_serialized_len > (total_size - 8): + return + cipher_data_len = fd.seek(-(tag_serialized_len + 8), 2) + extra_info = QMCv2STag.load(fd.read(tag_serialized_len)) + + cipher_ctor = None + master_key_encrypted = None + master_key_encryption_ver = None + elif tail_data == b'QTag': + fd.seek(-8, 2) + tag_serialized_len = int.from_bytes(fd.read(4), 'big') + if tag_serialized_len > (total_size - 8): + return + cipher_data_len = fd.seek(-(tag_serialized_len + 8), 2) + master_key_encrypted_b64encoded, extra_info = QMCv2QTag.load(fd.read(tag_serialized_len)) + master_key_encrypted = b64decode(master_key_encrypted_b64encoded) + + cipher_ctor = _guess_cipher_ctor(master_key_encrypted) + master_key_encryption_ver = 1 else: - return ret + extra_info = None + master_key_encrypted_b64encoded_len = int.from_bytes(tail_data, 'little') + if master_key_encrypted_b64encoded_len > total_size - 4: + return + cipher_data_len = fd.seek(-(master_key_encrypted_b64encoded_len + 4), 2) + master_key_encrypted_b64encoded = fd.read(master_key_encrypted_b64encoded_len) + try: + master_key_encrypted_b64encoded.decode('ascii') + except UnicodeDecodeError: + return + master_key_encrypted_b64decoded = b64decode(master_key_encrypted_b64encoded) + if master_key_encrypted_b64decoded.startswith(b'QQMusic EncV2,Key:'): + master_key_encrypted = master_key_encrypted_b64decoded[18:] + master_key_encryption_ver = 2 + else: + master_key_encrypted = master_key_encrypted_b64decoded + master_key_encryption_ver = 1 + cipher_ctor = _guess_cipher_ctor(master_key_encrypted) + + return QMCv2FileInfo(cipher_ctor=cipher_ctor, + cipher_data_len=cipher_data_len, + master_key_encrypted=master_key_encrypted, + master_key_encryption_ver=master_key_encryption_ver, + extra_info=extra_info + ) - @classmethod - def new(cls, song_id: IntegerLike, unknown_value1: BytesLike, song_mid: str) -> QMCv2STag: - return cls(toint(song_id), tobytes(unknown_value1), str(song_mid)) + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + return Path(filething), operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_origpos = fileobj.tell() + prs = operation(fileobj) + fileobj.seek(fileobj_origpos, 0) + return fileobj, prs -class QMCv1(CryptLayerWrappedIOSkel): + +class QMCv1(EncryptedBytesIOSkel): """基于 BytesIO 的 QMCv1 透明加密二进制流。 所有读写相关方法都会经过透明加密层处理: @@ -114,114 +212,140 @@ class QMCv1(CryptLayerWrappedIOSkel): 可绕过透明加密层,访问缓冲区内的原始加密数据。 如果你要新建一个 QMCv1 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``QMCv1.new()`` 和 ``QMCv1.from_file()`` 新建或打开已有 QMCv1 文件, - 使用已有 QMCv1 对象的 ``self.to_file()`` 方法将其保存到文件。 + ``QMCv1.new()`` 和 ``QMCv1.open()`` 新建或打开已有 QMCv1 文件, + 使用已有 QMCv1 对象的 ``save()`` 方法将其保存到文件。 """ @property - def cipher(self) -> Mask128: - return self._cipher + def acceptable_ciphers(self): + return [Mask128] - @property - def master_key(self): - return self.cipher.mask128 + @classmethod + def from_file(cls, + filething: FilePath | IO[bytes], /, + mask: BytesLike + ): + """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``QMCv1.open()`` 代替。""" + warnings.warn( + DeprecationWarning( + f'{cls.__name__}.from_file() is deprecated and no longer used. ' + f'Use {cls.__name__}.open() instead.' + ) + ) + return cls.open(filething, mask=mask) - def __init__(self, cipher: Mask128, /, initial_bytes: BytesLike = b'') -> None: - """基于 BytesIO 的 QMCv1 透明加密二进制流。 + @classmethod + def open(cls, + filething: FilePath | IO[bytes], /, + mask: BytesLike + ): + """打开一个 QMCv1 文件,并返回一个 ``QMCv1`` 对象。 + + 第一个位置参数 ``filething`` 需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + + 第二个参数 ``mask`` 是必需的,用于主密钥。其长度必须为 44、128 或 256 位。 + + Args: + filething: 源文件的路径或文件对象 + mask: 文件的主密钥,其长度必须为 44、128 或 256 位 + Raises: + ValueError: mask 的长度不符合上述要求 + """ + mask = tobytes(mask) + + def operation(fd: IO[bytes]) -> cls: + if len(mask) == 44: + cipher = Mask128.from_qmcv1_mask44(mask) + elif len(mask) == 128: + cipher = Mask128(mask) + elif len(mask) == 256: + cipher = Mask128.from_qmcv1_mask256(mask) + else: + raise ValueError( + f"the length of argument 'mask' must be 44, 128, or 256, not {len(mask)}" + ) - 所有读写相关方法都会经过透明加密层处理: - 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + fd.seek(0, 0) + return cls(cipher, fd.read()) - 调用读写相关方法时,附加参数 ``nocryptlayer=True`` - 可绕过透明加密层,访问缓冲区内的原始加密数据。 + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + instance = operation(fileobj) + instance._name = Path(filething) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_sourcefile = getattr(fileobj, 'name', None) + instance = operation(fileobj) - 如果你要新建一个 QMCv1 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``QMCv1.new()`` 和 ``QMCv1.from_file()`` 新建或打开已有 QMCv1 文件, - 使用 ``self.to_file()`` 将已有 QMCv1 对象保存到文件。 - """ - super().__init__(cipher, initial_bytes) - if not isinstance(cipher, Mask128): - raise TypeError(f"'{type(self).__name__}' " - f"only support cipher '{Mask128.__module__}.{Mask128.__name__}', " - f"not '{type(self._cipher).__name__}'" - ) + if fileobj_sourcefile is not None: + instance._name = Path(fileobj_sourcefile) - @classmethod - def new(cls) -> QMCv1: - """创建并返回一个全新的空 QMCv1 对象。""" - master_key = token_bytes(128) + return instance - return cls(Mask128(master_key)) + def to_file(self, filething: FilePath | IO[bytes] = None, /) -> None: + """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``QMCv1.save()`` 代替。""" + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.from_file() is deprecated and no longer used. ' + f'Use {type(self).__name__}.save() instead.' + ) + ) + return self.save(filething) - @classmethod - def from_file(cls, - qmcv1_filething: FilePath | IO[bytes], /, - master_key: BytesLike - ) -> QMCv1: - """打开一个 QMCv1 文件或文件对象 ``qmcv1_filething``。 + def save(self, filething: FilePath | IO[bytes] = None, /) -> None: + """将当前对象保存为一个新 QMCv1 文件。 - 第一个位置参数 ``qmcv1_filething`` 可以是文件路径(``str``、``bytes`` - 或任何拥有方法 ``__fspath__()`` 的对象)。``qmcv1_filething`` - 也可以是一个文件对象,但必须可读。 + 第一个参数 ``filething`` 是可选的,如果提供此参数,需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 - 第二个位置参数 ``master_key`` 用于解密音频数据,长度仅限 44、128 或 256 位。 - 如果不符合长度要求,会触发 ``ValueError``。 - """ - master_key = tobytes(master_key) - if len(master_key) == 44: - cipher = Mask128.from_qmcv1_mask44(master_key) - elif len(master_key) == 128: - cipher = Mask128(master_key) - elif len(master_key) == 256: - cipher = Mask128.from_qmcv1_mask256(master_key) - else: - raise ValueError("the length of second argument 'master_key' " - f"must be 44, 128 or 256, not {len(master_key)}" - ) + Args: + filething: 目标文件的路径或文件对象 - if isfilepath(qmcv1_filething): - with open(qmcv1_filething, mode='rb') as qmcv1_fileobj: - instance = cls(cipher, qmcv1_fileobj.read()) - else: - qmcv1_fileobj = verify_fileobj(qmcv1_filething, 'binary', - verify_readable=True - ) - instance = cls(cipher, qmcv1_fileobj.read()) + Raises: + TypeError: 当前对象的属性 source 和参数 filething 都为空,无法保存文件 + """ - instance._name = getattr(qmcv1_fileobj, 'name', None) + def operation(fd: IO[bytes]): + fd.write(self.getvalue(nocryptlayer=True)) - return instance + if filething is None: + if self.source is None: + raise TypeError( + "attribute 'self.source' and argument 'filething' are empty, " + "don't know which file to save to" + ) + filething = self.source - def to_file(self, qmcv1_filething: FilePath | IO[bytes] = None, /) -> None: - """将当前 QMCv1 对象的内容保存到文件 ``qmcv1_filething``。 + if isfilepath(filething): + with open(filething, mode='wb') as fileobj: + return operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_seekable=True, + verify_writable=True + ) + return operation(fileobj) - 第一个位置参数 ``qmcv1_filething`` 可以是文件路径(``str``、``bytes`` - 或任何拥有方法 ``__fspath__()`` 的对象)。``qmcv1_filething`` - 也可以是一个文件对象,但必须可写。 + @classmethod + def new(cls, mask: BytesLike = None): + """返回一个空 QMCv1 对象。 - 本方法会首先尝试写入 ``qmcv1_filething`` 指向的文件。 - 如果未提供 ``qmcv1_filething``,则会尝试写入 ``self.name`` - 指向的文件。如果两者都为空或未提供,则会触发 ``CrypterSavingError``。 + 第一个参数 ``mask`` 是可选的,如果提供,将被用作主密钥。 """ - if qmcv1_filething is None: - if self.name is None: - raise CrypterSavingError( - "cannot determine the target file: " - "argument 'qmcv1_filething' and attribute self.name are None or unspecified" - ) - qmcv1_filething = self.name - - if isfilepath(qmcv1_filething): - with open(qmcv1_filething, mode='wb') as qmcv1_fileobj: - qmcv1_fileobj.write(self.getvalue(nocryptlayer=True)) + if mask is None: + mask = make_salt(128) else: - qmcv1_fileobj = verify_fileobj(qmcv1_filething, 'binary', - verify_writable=True - ) - qmcv1_fileobj.write(self.getvalue(nocryptlayer=True)) + mask = tobytes(mask) + return cls(Mask128(mask)) -class QMCv2(CryptLayerWrappedIOSkel): +class QMCv2(EncryptedBytesIOSkel): """基于 BytesIO 的 QMCv2 透明加密二进制流。 所有读写相关方法都会经过透明加密层处理: @@ -231,123 +355,15 @@ class QMCv2(CryptLayerWrappedIOSkel): 可绕过透明加密层,访问缓冲区内的原始加密数据。 如果你要新建一个 QMCv2 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``QMCv2.new()`` 和 ``QMCv2.from_file()`` 新建或打开已有 QMCv2 文件, - 使用已有 QMCv2 对象的 ``self.to_file()`` 方法将其保存到文件。 + ``QMCv2.new()`` 和 ``QMCv2.open()`` 新建或打开已有 QMCv2 文件, + 使用已有 QMCv2 对象的 ``save()`` 方法将其保存到文件。 """ @property - def cipher(self) -> HardenedRC4 | Mask128: - return self._cipher - - @property - def master_key(self) -> bytes: - if isinstance(self.cipher, HardenedRC4): - return self.cipher.master_key - elif isinstance(self.cipher, Mask128): - if self.cipher.original_master_key is None: - return self.cipher.mask128 - return self.cipher.original_master_key - - @property - def simple_key(self) -> bytes | None: - return self._simple_key - - @simple_key.setter - def simple_key(self, value: BytesLike) -> None: - self._simple_key = tobytes(value) - - @simple_key.deleter - def simple_key(self) -> None: - self._simple_key = None - - @property - def mix_key1(self) -> bytes | None: - return self._mix_key1 - - @mix_key1.setter - def mix_key1(self, value: BytesLike) -> None: - self._mix_key1 = tobytes(value) - - @mix_key1.deleter - def mix_key1(self) -> None: - self._mix_key1 = None - - @property - def mix_key2(self) -> bytes | None: - return self._mix_key2 - - @mix_key2.setter - def mix_key2(self, value: BytesLike) -> None: - self._mix_key2 = tobytes(value) - - @mix_key2.deleter - def mix_key2(self) -> None: - self._mix_key2 = None - - @property - def song_id(self) -> int: - return self._song_id - - @song_id.setter - def song_id(self, value: IntegerLike) -> None: - self._song_id = toint(value) - - @song_id.deleter - def song_id(self) -> None: - self._song_id = 0 - - @property - def song_mid(self) -> str: - return self._song_mid - - @song_mid.setter - def song_mid(self, value: str) -> None: - self._song_mid = str(value) - - @song_mid.deleter - def song_mid(self) -> None: - self._song_mid = '0' * 14 - - @property - def unknown_value1(self) -> bytes: - return self._unknown_value1 + def acceptable_ciphers(self): + return [HardenedRC4, Mask128] - @unknown_value1.setter - def unknown_value1(self, value: BytesLike) -> None: - self._unknown_value1 = tobytes(value) - - @unknown_value1.deleter - def unknown_value1(self) -> None: - self._unknown_value1 = b'2' - - @property - def qtag(self) -> QMCv2QTag | None: - if self.simple_key is not None and len(self.master_key) in (256, 512): - return QMCv2QTag.new( - master_key=self.master_key, - simple_key=self.simple_key, - song_id=self.song_id, - unknown_value1=self.unknown_value1 - ) - - @property - def stag(self) -> QMCv2STag: - return QMCv2STag( - song_id=self.song_id, - unknown_value1=self.unknown_value1, - song_mid=self.song_mid - ) - - def __init__(self, - cipher: HardenedRC4 | Mask128, /, - initial_bytes: BytesLike = b'', - simple_key: BytesLike = None, - mix_key1: BytesLike = None, - mix_key2: BytesLike = None, *, - song_id: IntegerLike = 0, - song_mid: str = '0' * 14, - unknown_value1: BytesLike = b'2' - ) -> None: + def __init__(self, cipher: HardenedRC4 | Mask128, /, initial_bytes: BytesLike = b'') -> None: """基于 BytesIO 的 QMCv2 透明加密二进制流。 所有读写相关方法都会经过透明加密层处理: @@ -357,373 +373,400 @@ def __init__(self, 可绕过透明加密层,访问缓冲区内的原始加密数据。 如果你要新建一个 QMCv2 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``QMCv2.new()`` 和 ``QMCv2.from_file()`` 新建或打开已有 QMCv2 文件, - 使用已有 QMCv2 对象的 ``self.to_file()`` 方法将其保存到文件。 + ``QMCv2.new()`` 和 ``QMCv2.open()`` 新建或打开已有 QMCv2 文件, + 使用已有 QMCv2 对象的 ``save()`` 方法将其保存到文件。 """ super().__init__(cipher, initial_bytes) - if not isinstance(cipher, (HardenedRC4, Mask128)): - raise TypeError(f'unsupported Cipher: ' - f'supports ' - f'{Mask128.__module__}.{Mask128.__name__} and ' - f'{HardenedRC4.__module__}.{HardenedRC4.__name__}, ' - f'not {type(cipher).__name__}' - ) - - if simple_key is None: - self._simple_key = None - else: - self._simple_key = tobytes(simple_key) - if mix_key1 is None: - self._mix_key1 = None - else: - self._mix_key1 = tobytes(mix_key1) - if mix_key2 is None: - self._mix_key2 = None - else: - self._mix_key2 = tobytes(mix_key2) - self._song_id = toint(song_id) - self._song_mid = str(song_mid) - self._unknown_value1 = tobytes(unknown_value1) - @classmethod - def new(cls, - cipher_type: Literal['mask', 'rc4'], /, - simple_key: BytesLike = None, - mix_key1: BytesLike = None, - mix_key2: BytesLike = None, *, - song_id: IntegerLike = 0, - song_mid: str = '0' * 14, - unknown_value1: BytesLike = b'2' - ) -> QMCv2: - """创建并返回一个全新的空 QMCv2 对象。 - - 第一个位置参数 ``cipher_type`` 决定此 QMCv2 对象的透明加密层使用哪种加密算法, - 仅支持 ``'mask'`` 和 ``'rc4'``。 - - 位置参数 ``simple_key``、``mix_key1``、``mix_key2`` - 都是可选参数,但已经在这里填写的参数,在将此 QMCv2 对象保存到文件时不必再填写。 - - 关键字参数 ``song_id``、``song_mid``、``unknown_value1`` 也是可选参数。 - 这些参数是无关紧要的。 - """ - if cipher_type == 'mask': - cipher = Mask128.from_qmcv2_key256(make_random_ascii_string(256).encode('utf-8')) - elif cipher_type == 'rc4': - cipher = HardenedRC4(make_random_ascii_string(512).encode('utf-8')) - elif isinstance(cipher_type, str): - raise ValueError(f"first argument 'cipher_type' must be 'mask' or 'rc4', not {cipher_type}") - else: - raise TypeError(f"first argument 'cipher_type' must be str, " - f"not {type(cipher_type).__name__}" - ) - - return cls(cipher, - simple_key=simple_key, - mix_key1=mix_key1, - mix_key2=mix_key2, - song_id=song_id, - song_mid=song_mid, - unknown_value1=unknown_value1 - ) + self._extra_info: QMCv2QTag | QMCv2STag | None = None + + @property + def extra_info(self) -> QMCv2QTag | QMCv2STag | None: + return self._extra_info + + @extra_info.setter + def extra_info(self, value: QMCv2QTag | QMCv2STag | None) -> None: + if value is None or isinstance(value, (QMCv2QTag, QMCv2STag)): + self._extra_info = value + raise TypeError( + f"attribute 'extra_info' must be QMCv2QTag, QMCv2STag, or None, not {repr(value)}" + ) + + @property + def master_key(self) -> bytes | None: + if isinstance(self.cipher, Mask128): + ret = self.cipher.getkey('original') + if ret: + return ret + + return super().master_key @classmethod def from_file(cls, - qmcv2_filething: FilePath | IO[bytes], /, - simple_key: BytesLike = None, - mix_key1: BytesLike = None, - mix_key2: BytesLike = None, *, - master_key: BytesLike = None, - ) -> QMCv2: - """打开一个 QMCv2 文件或文件对象 ``qmcv2_filething``。 - - 第一个位置参数 ``qmcv2_filething`` 可以是文件路径(``str``、``bytes`` - 或任何拥有方法 ``__fspath__()`` 的对象)。``qmcv2_filething`` - 也可以是一个文件对象,但必须可读、可跳转(``qmcv2_filething.seekable() == True``)。 - - 本方法会寻找文件内嵌主密钥的位置和加密方式,进而判断所用加密算法的类型。 - - 如果提供了参数 ``master_key``,那么此参数将会被视为主密钥, - 用于判断加密算法类型和解密音频数据,同时会跳过其他步骤。 - 其必须是类字节对象,且转换为 ``bytes`` 的长度必须是 128、256 - 或 512 位。如果不符合长度要求,会触发 ``ValueError``。否则: - - - 如果未能找到文件内嵌的主密钥,那么参数 ``master_key`` 是必需的。 - - 如果文件内嵌的主密钥,其加密版本为 V1,那么参数 ``simple_key`` 是必需的。 - - 如果文件内嵌的主密钥,其加密版本为 V2,那么除了 ``simple_key``,参数``mix_key1``、``mix_key2`` 也是必需的。 - - 以上特定条件中的必需参数,如果缺失,则会触发 ``ValueError``。 - """ - - def operation(fileobj: IO[bytes]) -> QMCv2: - fileobj_endpos = fileobj.seek(0, 2) - fileobj.seek(-4, 2) - tail_data = fileobj.read(4) + filething_or_info: tuple[Path | IO[bytes], QMCv2FileInfo | None] | FilePath | IO[bytes], /, + core_key: BytesLike = None, + garble_key1: BytesLike = None, + garble_key2: BytesLike = None, + master_key: BytesLike = None + ): + """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``QMCv2.open()`` 代替。""" + warnings.warn( + DeprecationWarning( + f'{cls.__name__}.from_file() is deprecated and no longer used. ' + f'Use {cls.__name__}.open() instead.' + ) + ) + return cls.open(filething_or_info, + core_key=core_key, + garble_key1=garble_key1, + garble_key2=garble_key2, + master_key=master_key + ) - song_id = 0 - song_mid = '0' * 14 - unknown_value1 = b'2' + @classmethod + def open(cls, + filething_or_info: tuple[Path | IO[bytes], QMCv2FileInfo | None] | FilePath | IO[bytes], /, + core_key: BytesLike = None, + garble_key1: BytesLike = None, + garble_key2: BytesLike = None, + master_key: BytesLike = None, + encrypt_method: Literal['map', 'mask', 'rc4'] = None + ): + """打开一个 QMCv2 文件,并返回一个 ``QMCv2`` 对象。 + + 第一个位置参数 ``filething_or_info`` 需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + + ``filething_or_info`` 也可以接受 ``probe_qmcv2()`` 函数的返回值: + 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 + + 第二个参数 ``core_key`` 一般情况下是必需的,用于解密文件内嵌的主密钥。 + 例外:如果你提供了第五个参数 ``master_key``,那么它是可选的。 + + 第三、第四个参数 ``garble_key1`` 和 ``garble_key2``,仅在探测到文件内嵌的主密钥使用了 + V2 加密时是必需的。在其他情况下,它们的值会被忽略。 + + 第五个参数 ``master_key`` 可选,如果提供,将会被作为主密钥使用, + 而文件内置的主密钥会被忽略,``core_key``、``garble_key1`` 和 ``garble_key2`` + 也不再是必需参数。 + 例外:如果探测到文件未嵌入任何形式的密钥,那么此参数是必需的。 + + 第六个参数 ``encrypt_method`` 用于指定文件数据使用的加密方式,支持以下值: + + - ``'map'`` 或 ``'mask'`` - 掩码表(Mask128) + - ``'rc4'`` - 强化版 RC4(HardenedRC4) + - ``None`` - 不指定,由 ``probe_qmcv2()`` 自行探测 + + 此参数的设置会覆盖 ``probe_qmcv2()`` 的探测结果。 + + Args: + filething_or_info: 源文件的路径或文件对象,或者 probe_qmcv2() 的返回值 + core_key: 核心密钥,用于解密文件内嵌的主密钥 + garble_key1: 混淆密钥 1,用于解密使用 V2 加密的主密钥 + garble_key2: 混淆密钥 2,用于解密使用 V2 加密的主密钥 + master_key: 如果提供,将会被作为主密钥使用,而文件内置的主密钥会被忽略 + encrypt_method: 用于指定文件数据使用的加密方式,支持 'map'、'mask'、'rc4' 或 None + Raises: + TypeError: 参数 core_key 和 master_key 都未提供,或者缺少 garble_key1 或 garble_key2 用于解密 V2 加密的主密钥 + ValueError: encrypt_method 的值不符合上述条件 + CrypterCreatingError: probe_qmcv2() 返回的文件信息中,master_key_encryption_ver 的值是当前不支持的 + """ + if core_key is not None: + core_key = tobytes(core_key) + if garble_key1 is not None: + garble_key1 = tobytes(garble_key1) + if garble_key2 is not None: + garble_key2 = tobytes(garble_key2) + if master_key is not None: + master_key = tobytes(master_key) + if encrypt_method is not None: + if encrypt_method not in ('map', 'mask', 'rc4'): + if isinstance(encrypt_method, str): + raise ValueError( + f"argument 'encrypt_method' must be 'map', 'mask', or 'rc4', " + f"not {repr(encrypt_method)}" + ) + else: + raise TypeError( + f"argument 'encrypt_method' must be str, " + f"not {type(encrypt_method).__name__}" + ) - if tail_data == b'STag': - if master_key is None: - raise ValueError("'master_key' is required for QMCv2 file with STag " - "audio data encryption/decryption" - ) - fileobj.seek(-8, 2) - stag_len = int.from_bytes(fileobj.read(4), 'big') - if stag_len + 8 > fileobj_endpos: - raise CrypterCreatingError( - f'{repr(qmcv2_filething)} is not a valid QMCv2 file: ' - f'QMCv2 STag data length ({stag_len + 8}) ' - f'is greater than file length ({fileobj_endpos})' + def operation(fd: IO[bytes]) -> cls: + cipher_data_len = fileinfo.cipher_data_len + extra_info = fileinfo.extra_info + master_key_encrypted = fileinfo.master_key_encrypted + master_key_encryption_ver = fileinfo.master_key_encryption_ver + cipher_ctor = fileinfo.cipher_ctor + + if master_key is None: + if isinstance(extra_info, QMCv2STag): + raise TypeError( + "argument 'master_key' is required to " + "QMCv2 file ends with STag" ) - audio_encrypted_len = fileobj.seek(-(stag_len + 8), 2) - stag = QMCv2STag.from_bytes(fileobj.read(stag_len)) - song_id = stag.song_id - song_mid = stag.song_mid - unknown_value1 = stag.unknown_value1 - target_master_key = master_key - fileobj.seek(0, 0) - initial_bytes = fileobj.read(audio_encrypted_len) - else: - if simple_key is None and master_key is None: - raise ValueError("'simple_key' is required for QMCv2 file master key decryption") - if tail_data == b'QTag': - fileobj.seek(-8, 2) - qtag_len = int.from_bytes(fileobj.read(4), 'big') - if qtag_len + 8 > fileobj_endpos: - raise CrypterCreatingError( - f'{repr(qmcv2_filething)} is not a valid QMCv2 file: ' - f'QMCv2 QTag data length ({qtag_len + 8}) ' - f'is greater than file length ({fileobj_endpos})' + if core_key is None: + raise TypeError( + "argument 'core_key' is required to " + "decrypt the protected master key" + ) + if master_key_encryption_ver == 1: + target_master_key = QMCv2KeyEncryptV1(core_key).decrypt( + master_key_encrypted + ) + elif master_key_encryption_ver == 2: + if garble_key1 is None and garble_key2 is None: + raise TypeError( + "argument 'garble_key1' and 'garble_key2' is required to " + "decrypt the QMCv2 Key Encryption V2 protected master key" ) - audio_encrypted_len = fileobj.seek(-(qtag_len + 8), 2) - qtag = QMCv2QTag.from_bytes(fileobj.read(qtag_len)) - master_key_encrypted_b64encoded = qtag.master_key_encrypted_b64encoded - song_id = qtag.song_id - unknown_value1 = qtag.unknown_value1 - target_master_key = master_key - if target_master_key is None: - target_master_key = QMCv2KeyEncryptV1(simple_key).decrypt( - b64decode(master_key_encrypted_b64encoded) + elif garble_key1 is None: + raise TypeError( + "argument 'garble_key1' is required to " + "decrypt the QMCv2 Key Encryption V2 protected master key" ) - fileobj.seek(0, 0) - initial_bytes = fileobj.read(audio_encrypted_len) - else: - master_key_encrypted_b64encoded_len = int.from_bytes(tail_data, 'little') - if master_key_encrypted_b64encoded_len + 4 > fileobj_endpos: - raise CrypterCreatingError( - f'{repr(qmcv2_filething)} is not a valid QMCv2 file: ' - f'QMCv2 QTag data length ({master_key_encrypted_b64encoded_len + 4}) ' - f'is greater than file length ({fileobj_endpos})' + elif garble_key2 is None: + raise TypeError( + "argument 'garble_key2' is required to " + "decrypt the QMCv2 Key Encryption V2 protected master key" ) - audio_encrypted_len = fileobj.seek(-(master_key_encrypted_b64encoded_len + 4), 2) - master_key_encrypted_b64encoded = fileobj.read(master_key_encrypted_b64encoded_len) - target_master_key = master_key - if target_master_key is None: - master_key_encrypted = b64decode(master_key_encrypted_b64encoded, validate=False) - if master_key_encrypted.startswith(b'QQMusic EncV2,Key:'): - missing_mix_key_msg = '{} is required for QMCv2 file ' \ - 'with master key encryption V2 decryption' - missed_mix_keys = None - if mix_key1 is None and mix_key2 is None: - missed_mix_keys = "'mix_key1' and 'mix_key2'" - elif mix_key1 is None: - missed_mix_keys = "'mix_key1'" - elif mix_key2 is None: - missed_mix_keys = "'mix_key2'" - if missed_mix_keys: - raise ValueError(missing_mix_key_msg.format(missed_mix_keys)) - target_master_key = QMCv2KeyEncryptV2( - simple_key, - mix_key1, - mix_key2 - ).decrypt(master_key_encrypted[18:]) - else: - target_master_key = QMCv2KeyEncryptV1(simple_key).decrypt(master_key_encrypted) - - fileobj.seek(0, 0) - initial_bytes = fileobj.read(audio_encrypted_len) - - if len(target_master_key) == 128: - cipher = Mask128(target_master_key) - warnings.warn(CrypterCreatingWarning( - 'maskey length is 128, most likely obtained by other means, ' - 'such as known plaintext attack. ' - 'Unable to recover and save the original master key from this key.' + target_master_key = QMCv2KeyEncryptV2( + core_key, garble_key1, garble_key2 + ).decrypt(master_key_encrypted) + else: + raise CrypterCreatingError( + f"unsupported master key encryption version {master_key_encryption_ver}" + ) + else: + target_master_key = master_key + cipher_ctor = _guess_cipher_ctor(target_master_key, is_encrypted=False) + + if encrypt_method in ('map', 'mask'): + cipher_ctor = Mask128 + elif encrypt_method == 'rc4': + cipher_ctor = HardenedRC4 + + if cipher_ctor is None: + raise TypeError( + "don't know which cipher to use, " + f"please try {cls.__name__}.open() again " + f"with argument 'encrypt_method'" ) + + cipher = cipher_ctor(target_master_key) + fd.seek(0, 0) + inst = cls(cipher, fd.read(cipher_data_len)) + inst._extra_info = extra_info + + return inst + + if isinstance(filething_or_info, tuple): + filething_or_info: tuple[Path | IO[bytes], QMCv2FileInfo | None] + if len(filething_or_info) != 2: + raise TypeError( + "first argument 'filething_or_info' must be a file path, a file object, " + "or a tuple of probe_qmcv2() returns" ) - elif len(target_master_key) == 256: - cipher = Mask128.from_qmcv2_key256(target_master_key) - elif len(target_master_key) == 512: - cipher = HardenedRC4(target_master_key) - else: + filething, fileinfo = filething_or_info + if fileinfo is None: raise CrypterCreatingError( - 'invalid master key length: should be 128 (unrecommend), 256 or 512, ' - f'not {len(target_master_key)}' + f"{repr(filething)} is not a QMCv2 file" ) + else: + filething, fileinfo = probe_qmcv2(filething_or_info) - return cls(cipher, - initial_bytes, - simple_key=simple_key, - mix_key1=mix_key1, - mix_key2=mix_key2, - song_id=song_id, - song_mid=song_mid, - unknown_value1=unknown_value1 - ) - - if simple_key is not None: - simple_key = tobytes(simple_key) - if mix_key1 is not None: - mix_key1 = tobytes(mix_key1) - if mix_key2 is not None: - mix_key2 = tobytes(mix_key2) - if master_key is not None: - master_key = tobytes(master_key) - - if isfilepath(qmcv2_filething): - with open(qmcv2_filething, mode='rb') as qmcv2_fileobj: - instance = operation(qmcv2_fileobj) + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + instance = operation(fileobj) + instance._name = Path(filething) else: - qmcv2_fileobj = verify_fileobj(qmcv2_filething, 'binary', - verify_readable=True, - verify_seekable=True - ) - instance = operation(qmcv2_fileobj) + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_sourcefile = getattr(fileobj, 'name', None) + instance = operation(fileobj) - instance._name = getattr(qmcv2_fileobj, 'name', None) + if fileobj_sourcefile is not None: + instance._name = Path(fileobj_sourcefile) return instance def to_file(self, - qmcv2_filething: FilePath | IO[bytes] = None, /, - tag_type: Literal['qtag', 'stag'] = None, - simple_key: BytesLike = None, - master_key_enc_ver: IntegerLike = 1, - mix_key1: BytesLike = None, - mix_key2: BytesLike = None + core_key: BytesLike = None, + filething: FilePath | IO[bytes] = None, + garble_key1: BytesLike = None, + garble_key2: BytesLike = None, + with_extra_info: bool = False ) -> None: - """将当前 QMCv2 对象的内容保存到文件 ``qmcv2_filething``。 + """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``QMCv2.save()`` 代替。""" + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.from_file() is deprecated and no longer used. ' + f'Use {type(self).__name__}.save() instead.' + ) + ) + return self.save(core_key=core_key, + filething=filething, + garble_key1=garble_key1, + garble_key2=garble_key2, + with_extra_info=with_extra_info + ) + + def save(self, + core_key: BytesLike = None, + filething: FilePath | IO[bytes] = None, + garble_key1: BytesLike = None, + garble_key2: BytesLike = None, + with_extra_info: bool = False + ) -> None: + """将当前对象保存为一个新 QMCv2 文件。 + + 第一个参数 ``core_key`` 一般是必需的,用于加密主密钥,以便嵌入到文件。 + 例外:参数 ``with_extra_info=True`` 且当前对象的属性 ``extra_info`` + 是一个 ``QMCv2STag`` 对象,此时它是可选的,其值会被忽略。 + + 第二个参数 ``filething`` 是可选的,如果提供此参数,需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + 如果未提供此参数,那么将会尝试使用当前对象的 ``source`` 属性;如果后者也不可用,则引发 + ``TypeError``。 + + 第三、第四个参数 ``garble_key1`` 和 ``garble_key2``,决定对主密钥进行加密的方法; + 如果提供,则需要两个一起提供,将会对主密钥采用 V2 加密;否则,对主密钥采用 V1 加密。 + 如果参数 ``with_extra_info=True`` 且当前对象的属性 ``extra_info`` + 是一个 ``QMCv2STag`` 对象,它们的值会被忽略。 + + 第五个参数 ``with_extra_info`` 如果为 ``True``,且当前对象的属性 ``extra_info`` 是 + ``QMCv2QTag`` 或 ``QMCv2STag`` 对象,那么这些对象将会被序列化后嵌入文件。 + + Args: + core_key: 核心密钥,用于加密主密钥,以便嵌入到文件 + filething: 目标文件的路径或文件对象 + garble_key1: 混淆密钥 1,用于使用 V2 加密方式加密主密钥 + garble_key2: 混淆密钥 2,用于使用 V2 加密方式加密主密钥 + with_extra_info: 是否在文件末尾添加额外信息(self.extra_info) + + Raises: + TypeError: 当前对象的属性 source 和参数 filething 都为空,无法保存文件;参数 core_key 和 master_key 都未提供,或者缺少 garble_key1 或 garble_key2 用于使用 V2 方式加密主密钥 + """ + if core_key is not None: + core_key = tobytes(core_key) + if garble_key1 is not None: + garble_key1 = tobytes(garble_key1) + if garble_key2 is not None: + garble_key2 = tobytes(garble_key2) + + def operation(fd: IO[bytes]) -> None: + fd.seek(0, 0) + extra_info = self.extra_info + + if with_extra_info: + if isinstance(extra_info, QMCv2STag): + warnings.warn( + CrypterSavingWarning( + "Extra info (self.extra_info) will be export to STag data, " + "which cannot save the master key. " + "So you should save the master key in other way. " + "Use 'self.master_key' to get it." + ) + ) + tag_serialized = extra_info.dump() + fd.write(self.getvalue(nocryptlayer=True)) + fd.write(tag_serialized) + fd.write(len(tag_serialized).to_bytes(4, 'big')) + fd.write(b'STag') + + return + + master_key = self.master_key + if core_key is None: + raise TypeError( + "argument 'core_key' is required to encrypt the master key " + "before embed to file" + ) + if with_extra_info: + if isinstance(extra_info, QMCv2QTag): + master_key_encrypted = QMCv2KeyEncryptV1(core_key).encrypt(master_key) + master_key_encrypted_b64encoded = b64encode(master_key_encrypted) + tag_serialized = extra_info.dump(master_key_encrypted_b64encoded) + fd.write(self.getvalue(nocryptlayer=True)) + fd.write(tag_serialized) + fd.write(len(tag_serialized).to_bytes(4, 'big')) + fd.write(b'QTag') + + return + + if garble_key1 is None and garble_key2 is None: # QMCv2 KeyencV1 + master_key_encrypted = QMCv2KeyEncryptV1(core_key).encrypt(master_key) + master_key_encrypted_b64encoded = b64encode(master_key_encrypted) + else: # QMCv2 KeyEncV2 + if garble_key1 is None: + raise TypeError( + "argument 'garble_key1' is required to encrypt the master key " + "with QMCv2 Key Encryption V2 before embed to file" + ) + if garble_key2 is None: + raise TypeError( + "argument 'garble_key2' is required to encrypt the master key " + "with QMCv2 Key Encryption V2 before embed to file" + ) + master_key_encrypted = QMCv2KeyEncryptV2( + core_key, garble_key1, garble_key2 + ).encrypt(master_key) + master_key_encrypted_b64encoded = b64encode( + b'QQMusic EncV2,Key:' + master_key_encrypted + ) + fd.write(self.getvalue(nocryptlayer=True)) + fd.write(master_key_encrypted_b64encoded) + fd.write(len(master_key_encrypted_b64encoded).to_bytes(4, 'little')) - 第一个位置参数 ``qmcv2_filething`` 可以是文件路径(``str``、``bytes`` - 或任何拥有方法 ``__fspath__()`` 的对象)。``qmcv2_filething`` - 也可以是一个文件对象,但必须可写。 + return - 本方法会首先尝试写入 ``qmcv2_filething`` 指向的文件。 - 如果未提供 ``qmcv2_filething``,则会尝试写入 ``self.name`` - 指向的文件。如果两者都为空或未提供,则会触发 ``CrypterSavingError``。 + if filething is None: + if self.source is None: + raise TypeError( + "attribute 'self.source' and argument 'filething' are empty, " + "don't know which file to save to" + ) + filething = self.source - 参数 ``tag_type`` 决定在文件末尾附加的内容,仅支持以下值: - - ``None`` - 将主密钥加密后直接附加在文件末尾。 - - ``qtag`` - 将主密钥加密后封装在 QTag 信息中,附加在文件末尾。 - - ``stag`` - 将 STag 信息附加在文件末尾。 - - 注意:选择 STag 意味着文件内不会内嵌主密钥,你需要自己记下主密钥。 - - 访问属性 ``self.master_key`` 获取主密钥。 + if isfilepath(filething): + with open(filething, mode='wb') as fileobj: + return operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_seekable=True, + verify_writable=True + ) + return operation(fileobj) - 如果 ``tag_type`` 为其他值,会触发 ``ValueError``。 + @classmethod + def new(cls, encrypt_method: Literal['map', 'mask', 'rc4'], /): + """返回一个空 QMCv2 对象。 - 无论 ``tag_type`` 为何值(``stag`` 除外),都需要使用 ``simple_key`` 加密主密钥。 - 如果参数 ``master_key_enc_ver=2``,还需要 ``mix_key1`` 和 ``mix_key2``。 - 如果未提供这些参数,则会使用当前 QMCv2 对象的同名属性代替。 - 如果两者都为 ``None`` 或未提供,则会触发 ``CrypterSavingError``。 - """ + 第一个位置参数 ``encrypt_method`` 是必需的,用于指示使用的加密方式,支持以下值: - def operation(fileobj: IO[bytes]) -> None: - if tag_type == 'stag': - warnings.warn(CrypterSavingWarning( - 'the STag embedded in the file does not contain the master key. ' - 'You need to remember the master key yourself. ' - "Access the attribute 'self.master_key' to get the master key." - ) - ) - fileobj.write(self.getvalue(nocryptlayer=True)) - fileobj.write(self.stag.to_bytes(with_tail=True)) - elif tag_type == 'qtag': - if self.qtag is None: - raise CrypterCreatingError("unable to save the file: cannot generate QTag") - fileobj.write(self.getvalue(nocryptlayer=True)) - fileobj.write(self.qtag.to_bytes(with_tail=True)) - elif tag_type is None: - target_simple_key = simple_key - if target_simple_key is None: - if self.simple_key is None: - raise CrypterSavingError( - "argument 'simple_key' and attribute self.simple_key is not available, " - 'but it is required for the master key encryption' - ) - target_simple_key = self.simple_key - if len(self.master_key) == 128: - raise CrypterSavingError( - 'master key is not available: ' - 'maskey length is 128, most likely obtained by other means, ' - 'such as known plaintext attack. ' - 'Unable to recover the original master key from this key.' - ) - master_key = self.master_key - if master_key_enc_ver == 1: - master_key_encrypted = QMCv2KeyEncryptV1(target_simple_key).encrypt(master_key) - elif master_key_enc_ver == 2: - missing_mix_key_msg = '{names} not available, but {appell} required for ' \ - 'the master key encryption V2 encryption' - missed_mix_keys_appell = {} - target_mix_key1 = mix_key1 - if target_mix_key1 is None: - target_mix_key1 = self.mix_key1 - target_mix_key2 = mix_key2 - if target_mix_key2 is None: - target_mix_key2 = self.mix_key2 - if target_mix_key1 is None and target_mix_key2 is None: - missed_mix_keys_appell['names'] = \ - "argument 'mix_key1' and attribute self.mix_key1, " \ - "argument 'mix_key2' and attribute self.mix_key2 are" - missed_mix_keys_appell['appell'] = 'they are' - elif target_mix_key1 is None or target_mix_key2 is None: - missed_mix_keys_appell['appell'] = 'it is' - if target_mix_key1 is None: - missed_mix_keys_appell['names'] = \ - "argument 'mix_key1' and attribute self.mix_key1 is" - elif target_mix_key2 is None: - missed_mix_keys_appell['names'] = \ - "argument 'mix_key2' and attribute self.mix_key2 is" - print(missed_mix_keys_appell) - if missed_mix_keys_appell: - raise CrypterSavingError( - missing_mix_key_msg.format_map(missed_mix_keys_appell) - ) - master_key_encrypted = b'QQMusic EncV2,Key:' + QMCv2KeyEncryptV2( - target_simple_key, target_mix_key1, target_mix_key2 - ).encrypt(master_key) - else: - raise ValueError("argument 'master_key_enc_ver' must be 1 or 2, " - f"not {master_key_enc_ver}" - ) - master_key_encrypted_b64encoded = b64encode(master_key_encrypted) - master_key_encrypted_b64encoded_len = len(master_key_encrypted_b64encoded) - fileobj.write(self.getvalue(nocryptlayer=True)) - fileobj.write(master_key_encrypted_b64encoded) - fileobj.write(master_key_encrypted_b64encoded_len.to_bytes(4, 'little')) - elif isinstance(tag_type, str): - raise ValueError("argument 'tag_type' must be 'qtag', 'stag' or None, " - f"not {tag_type}" - ) - else: - raise TypeError(f"argument 'tag_type' must be str or None, " - f"not {type(tag_type).__name__}" - ) - - master_key_enc_ver = toint(master_key_enc_ver) - if simple_key is not None: - simple_key = tobytes(simple_key) - if mix_key1 is not None: - mix_key1 = tobytes(mix_key1) - if mix_key2 is not None: - mix_key2 = tobytes(mix_key2) - - if isfilepath(qmcv2_filething): - with open(qmcv2_filething, mode='wb') as qmcv2_fileobj: - operation(qmcv2_fileobj) + - ``'map'`` 或 ``'mask'`` - 掩码表(Mask128) + - ``'rc4'`` - 强化版 RC4(HardenedRC4) + + Raises: + ValueError: encrypt_method 的值不符合上述条件 + """ + if encrypt_method in ('map', 'mask'): + cipher = Mask128.from_qmcv2_key256(make_random_ascii_string(256).encode('ascii')) + elif encrypt_method == 'rc4': + cipher = HardenedRC4(make_random_ascii_string(512).encode('ascii')) + elif isinstance(encrypt_method, str): + raise ValueError( + f"argument 'encrypt_method' must be 'map', 'mask', or 'rc4', " + f"not {repr(encrypt_method)}" + ) else: - qmcv2_fileobj = verify_fileobj(qmcv2_filething, 'binary', - verify_writable=True - ) - operation(qmcv2_fileobj) + raise TypeError( + f"argument 'encrypt_method' must be str, " + f"not {type(encrypt_method).__name__}" + ) + + return cls(cipher) From bed3dccd600f3f6f465d256f95188c9ea7f2e1cd Mon Sep 17 00:00:00 2001 From: nukemiko Date: Wed, 21 Dec 2022 01:37:54 +0800 Subject: [PATCH 28/58] =?UTF-8?q?=E5=86=8D=E6=AC=A1=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BA=86=20`NCM.save()`=20=E7=9A=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index cb3ef53..54be375 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -462,6 +462,8 @@ def save(self, 第二个参数 ``filething`` 是可选的,如果提供此参数,需要是一个文件路径或文件对象。 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + 如果未提供此参数,那么将会尝试使用当前对象的 ``source`` 属性;如果后者也不可用,则引发 + ``TypeError``。 第三个参数 ``tag_key`` 可选,用于加密歌曲信息,以便嵌入到文件。如果留空,则使用默认值: ``b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28'`` From e5c422c6affc2e32b3ce73383d8798d526f1c3b6 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Wed, 21 Dec 2022 01:45:26 +0800 Subject: [PATCH 29/58] =?UTF-8?q?=E6=94=B9=E5=8F=98=E4=BA=86=20Key256Mappi?= =?UTF-8?q?ngData=20=E6=89=80=E5=9C=A8=E7=9A=84=E7=9B=AE=E5=BD=95=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qmc/qmcconsts}/Key256MappingData | Bin src/libtakiyasha/qmc/qmcconsts.py | 6 +++++- 2 files changed, 5 insertions(+), 1 deletion(-) rename src/libtakiyasha/{qmc/binaries => binaries/qmc/qmcconsts}/Key256MappingData (100%) diff --git a/src/libtakiyasha/qmc/binaries/Key256MappingData b/src/libtakiyasha/binaries/qmc/qmcconsts/Key256MappingData similarity index 100% rename from src/libtakiyasha/qmc/binaries/Key256MappingData rename to src/libtakiyasha/binaries/qmc/qmcconsts/Key256MappingData diff --git a/src/libtakiyasha/qmc/qmcconsts.py b/src/libtakiyasha/qmc/qmcconsts.py index dbafe4d..245fa66 100644 --- a/src/libtakiyasha/qmc/qmcconsts.py +++ b/src/libtakiyasha/qmc/qmcconsts.py @@ -5,8 +5,12 @@ from pathlib import Path from typing import Final +from ..miscutils import BINARIES_ROOTDIR + __all__ = ['KEY256_MAPPING'] +MODULE_BINARIES_ROOTDIR = BINARIES_ROOTDIR / 'qmc' / Path(__file__).stem + # KEY256_MAPPING = [[]] * 256 # # for i in range(128): @@ -17,5 +21,5 @@ # KEY256_MAPPING[real_idx].append(i) # # KEY256_MAPPING 可使用以上代码生成 -with open(Path(__file__).parent / 'binaries/Key256MappingData', 'rb') as f: +with open(MODULE_BINARIES_ROOTDIR / 'Key256MappingData', 'rb') as f: KEY256_MAPPING: Final[list[list[int]]] = pickle.load(f) From e81d4703088d043ce70f85f350f4f21d265a7898 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 22 Dec 2022 18:02:52 +0800 Subject: [PATCH 30/58] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E9=99=A4=E4=BD=99?= =?UTF-8?q?=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/qmcdataciphers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index 929ac3a..a3711e4 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -143,7 +143,7 @@ def __init__(self, key: BytesLike, /): self._box = bytearray(i % 256 for i in range(key_len)) j = 0 for i in range(key_len): - j = (j + self._box[i] + self._key[i % key_len]) % key_len + j = (j + self._box[i] + self._key[i]) % key_len self._box[i], self._box[j] = self._box[j], self._box[i] def getkey(self, keyname: str = 'master') -> bytes | None: From 5a147ddfb063b5100ee73c30c895fa2b42d3c0ed Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 22 Dec 2022 18:08:33 +0800 Subject: [PATCH 31/58] =?UTF-8?q?=E5=B0=86=20setuptools=20=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=E8=BD=AF=E4=BB=B6=E5=8C=85=E4=BE=9D=E8=B5=96=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E3=80=81=E8=AE=BE=E4=B8=BA=E5=AE=89=E8=A3=85=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 6 ++++-- requirements.txt | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8d5065e..082dc21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,15 @@ dynamic = ["dependencies", "readme", "version"] authors = [ { name = "nukemiko" }, ] -description = "Python 音乐加解密工具库" +description = "多种加密方案的 Python 实现" license = { file = "LICENSE" } requires-python = ">=3.8" classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Multimedia :: Sound/Audio", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10" @@ -24,8 +26,8 @@ keywords = ["unlock", "music", "audio", "qmc", "ncm", "mflac", "mgg", "netease", "Releases" = "https://github.com/nukemiko/libtakiyasha/releases" [build-system] -requires = ["setuptools >= 46.4.0"] build-backend = "setuptools.build_meta" +requires = ["setuptools >= 46.4.0"] [tool.setuptools] include-package-data = true diff --git a/requirements.txt b/requirements.txt index 8d0a1cd..67db29a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ pyaes -setuptools mutagen From 76bfb0f6cb4f670549fae6fc16a1351cc56b7abe Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 22 Dec 2022 18:08:55 +0800 Subject: [PATCH 32/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E6=89=93?= =?UTF-8?q?=E5=8C=85=E5=90=8E=E7=9A=84=20wheel=20=E5=8C=85=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=20VERSION=20=E6=96=87=E4=BB=B6=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MANIFEST.in | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 1275a9d..2cdbc0a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include src/libtakiyasha/qmc/binaries/* +recursive-include src/libtakiyasha/binaries/* +include MANIFEST.in src/libtakiyasha/VERSION From 0ff19f86cc9fa1ab6662b8450b88ab73e5f5f508 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 22 Dec 2022 18:09:23 +0800 Subject: [PATCH 33/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=92=8C=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 109 +++++++-------------------------------- src/libtakiyasha/VERSION | 2 +- 2 files changed, 21 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index f90578d..6ed1da7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# libtakiyasha ![](https://img.shields.io/badge/Version-2.0.1-green) ![](https://img.shields.io/badge/Python-3.8%2B-blue) +# libtakiyasha ![](https://img.shields.io/badge/Version-2.1.0a1-yellow) ![](https://img.shields.io/badge/Python-3.8%2B-blue) `libtakiyasha` 是一个 Python 音频加密/解密工具库(当然也可用于加密非音频数据),支持多种加密文件格式。 @@ -10,10 +10,10 @@ 本项目的设计灵感,以及部分解密方案,来源于: -- [Unlock Music - Web Edition](https://git.unlock-music.dev/um/web) +- [Unlock Music Project - CLI Edition](https://git.unlock-music.dev/um/cli) - [jixunmoe/qmc2](https://github.com/jixunmoe/qmc2) -**本项目不会内置任何解密所需的密钥。你需要自行寻找解密所需密钥或加密参数,在调用时作为参数传入。** +**本项目没有所谓的“内置密钥”,打开任何类型的加密文件都需要你提供对应的密钥。你需要自行寻找解密所需密钥或加密参数,在调用时作为参数传入。** 你可以在内容提供商的应用程序中查找这些必需参数,或寻求同类项目以及他人的帮助。**但请不要在 Issues/讨论区向作者索要所谓“缺失”的“内置密钥”,你的此类想法不会被满足。** @@ -26,116 +26,47 @@ - 纯 Python 实现(包括所有依赖关系),无 C/C++ 扩展模块,跨平台可用 - 支持多种加密文件格式的加密和解密 -## 当前版本:[2.0.1](https://github.com/nukemiko/libtakiyasha/releases/tag/2.0.1) +## 当前版本:[2.1.0a1](https://github.com/nukemiko/libtakiyasha/releases/tag/2.1.0a1) -此版本为正式版,但仍有不完美之处。如果发现任何 `libtakiyasha` 自身的问题,欢迎[提交 Issue](https://github.com/nukemiko/libtakiyasha/issues)。 +此版本为测试版。如果发现任何 `libtakiyasha` 自身的问题,欢迎[提交 Issue](https://github.com/nukemiko/libtakiyasha/issues)。 **`libtakiyasha` 2.x 版本和 1.x 版本之间的接口并不兼容,使用 1.x 版本的应用程序需要进行大量改造,才能使用 2.x 版本。** -另外,`libtakiyasha` 3.x 版本正在开发,有兴趣者可以切换分支查看。 +### 变更日志 + +详见[版本发布页](https://github.com/nukemiko/libtakiyasha/releases/tag/2.1.0a1)。 ### 支持的格式 -请在[此处](https://github.com/nukemiko/libtakiyasha/wiki/%E6%94%AF%E6%8C%81%E7%9A%84%E6%A0%BC%E5%BC%8F%E5%92%8C%E6%89%80%E9%9C%80%E5%AF%86%E9%92%A5%E5%8F%82%E6%95%B0)查看。 +~~请在[此处](https://github.com/nukemiko/libtakiyasha/wiki/%E6%94%AF%E6%8C%81%E7%9A%84%E6%A0%BC%E5%BC%8F%E5%92%8C%E6%89%80%E9%9C%80%E5%AF%86%E9%92%A5%E5%8F%82%E6%95%B0)查看。~~ + +鉴于以上信息无法正确反映当前版本,请以当前版本的文档为准(可在 Python 交互式终端中使用 `help(<函数/方法/对象>)` 查看)。 ### 兼容性 -到目前为止(版本 2.0.1),`libtakiyasha` 已在以下 Python 实现中通过了测试: +到目前为止(版本 2.1.0a1),`libtakiyasha` 已在以下 Python 实现中通过了测试: -- [CPython(官方实现)](https://www.python.org) 3.8 至 3.10,可能支持 3.11 +- [CPython(官方实现)](https://www.python.org)3.8 至 3.10,可能支持 3.11 - [Pyston](https://github.com/pyston/pyston) [2.3.5](https://github.com/pyston/pyston/releases/tag/pyston_2.3.5)(基于 CPython 3.8.12),其他版本或许也可用 -- [PyPy](https://www.pypy.org/) 7.3.9([CPython 3.8 兼容版本、CPython 3.9 兼容版本](https://downloads.python.org/pypy/)) +- [PyPy](https://www.pypy.org/) 7.3.9([CPython 3.8 兼容版本、CPython 3.9 兼容版本](https://downloads.python.org/pypy/)),其他版本或许也可用 **注意:`libtakiyasha` 所需的最低 Python 版本为 3.8,因为它使用的很多 Python 特性从 Python 3.8 开始才出现,这意味着使用更低的 Python 版本会出现大量不可预知的错误。** -提示:在作者运行的测试中,CPython 实现是速度最慢的;PyPy 比 Pyston 快了大约两倍,比 CPython 快了接近五倍。 +提示:在作者运行的测试中(仅测试了 NCM),CPython 实现是速度最慢的;PyPy 比 Pyston 快了大约两倍(两者都内置了不同形式的 JIT),比 CPython 快了接近五倍。 ### 安装 -- 运行命令:`pip install -U libtakiyasha==2.0.1` -- 或者前往 [GitHub 发布页](https://github.com/nukemiko/libtakiyasha/releases/tag/2.0.1) 下载安装 +- 运行命令:`pip install -U libtakiyasha==2.1.0a1` +- 或者前往 [GitHub 发布页](https://github.com/nukemiko/libtakiyasha/releases/tag/2.1.0a1) 下载安装 #### 所需依赖关系 -- `pyaes` - AES 加解密支持 - `setuptools` - 安装依赖 +- `pyaes` - AES 加解密支持 +- `mutagen` - 导出网易云音乐使用的 163key 数据 如果你是通过[上文提到的方式](#安装)安装的 `libtakiyasha`,这些依赖会被自动安装。 ### 基本使用方法 -提取加密文件里的音频内容: - -```python -from libtakiyasha.ncm import NCM -from libtakiyasha.qmc import QMCv2 - -... # 定义你提供的核心密钥 your_core_key、your_simple_key、your_mix_key1 和 your_mix_key2 - -# 打开 NCM 文件 -ncmfile = NCM.from_file('source.ncm', core_key=your_core_key) -target_file_format = ncm.ncm_tag.format - -with open('target_from_ncm.' + target_file_format, mode='wb') as fd: - # libtakiyasha 的所有透明加密文件对象(NCM、QMCv1、QMCv2、KGMorVPR、KWM 等)默认以固定大小的块(io.DEFAULT_BUFFER_SIZE)为单位进行迭代 - # 通过修改对象的 iter_mode 属性为 'line',可以使其以一行为单位进行迭代 - # 不过按行迭代会导致性能大幅下降,不推荐使用 - for block in ncmfile: - fd.write(block) - -# 打开 QMCv2 文件 -qmcv2file = QMCv2.from_file('source.mflac', simple_key=your_simple_key) -target_file_format = 'flac' - -with open('target_from_mflac.' + target_file_format, mode='wb') as fd: - for block in qmcv2file: - fd.write(block) - -# 也可以打开来自 QQ 音乐 PC 客户端 18.57 及更新版本的 QMCv2 文件, -# 但需要正确的 mix_key1 和 mix_key2 参数 -qmcv2file_keyencv2 = QMCv2.from_file('source.mflac', simple_key=your_simple_key, mix_key1=your_mix_key1, mix_key2=your_mix_key2) -target_file_format = 'flac' - -with open('target_from_mflac.' + target_file_format, mode='wb') as fd: - for block in qmcv2file_keyencv2: - fd.write(block) -``` - -- 打开加密文件时,如果不提供核心密钥,会报错而无法继续: - - ```pycon - - >>> from libtakiyasha import QMCv2 - >>> qmcv2file = QMCv2.from_file('source.mflac') - Traceback (most recent call last): - File "", line 1, in - <...> - ValueError: 'simple_key' is required for QMCv2 file master key decryption - >>> - ``` - - 你需要向 `QMCv2.from_file()` 传入正确的 `simple_key` 参数才能打开文件。 - - 同样,你需要向 `NCM.from_file()` 传入正确的 `core_key` 参数才能打开 NCM 文件。 - -生成加密文件(以 QMCv2 为例): - -```python -from libtakiyasha.qmc import QMCv2 - -... # 定义你的 your_simple_key、your_mix_key1 和 your_mix_key2 - -new_qmcv2 = QMCv2.new() - -new_qmcv2.simple_key = your_simple_key # 可选,但如果跳过此步骤,在保存到文件时需要填写参数 simple_key - -with open('plain.flac', 'rb') as fd: - for line in fd: - new_qmcv2.write(line) - -# 保存为 QMCv2 KeyEncV1 -new_qmcv2.to_file('encrypted.mflac') - -# 也可以保存为 QMCv2 KeyEncV2 - QQ 音乐 PC 端 18.57 及更高版本的格式 -new_qmcv2.to_file('encrypted-keyencv2.mflac', master_key_enc_ver=2, mix_key1=your_mix_key1, mix_key2=your_mix_key2) -``` +_未完待续_ diff --git a/src/libtakiyasha/VERSION b/src/libtakiyasha/VERSION index 38f77a6..0e4065c 100644 --- a/src/libtakiyasha/VERSION +++ b/src/libtakiyasha/VERSION @@ -1 +1 @@ -2.0.1 +2.1.0a1 From 4d96d6579f0df652b00d82fcd49c8e67f7d5fe9d Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sat, 24 Dec 2022 20:09:28 +0800 Subject: [PATCH 34/58] =?UTF-8?q?=E4=B8=BA=20`NCM`=20=E5=92=8C=20`CloudMus?= =?UTF-8?q?icIdentifier`=20=E8=BF=9B=E8=A1=8C=E4=BA=86=E6=94=B9=E9=80=A0?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E5=85=B6=E4=B8=8E=202.0.x=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=EF=BC=9B=E8=A1=A5=E5=85=85=E4=BA=86=E5=BC=83=E7=94=A8?= =?UTF-8?q?=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 177 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 164 insertions(+), 13 deletions(-) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index 54be375..efc6f59 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -211,6 +211,77 @@ def operation() -> bytes: return operation() + def to_mutagen_style_dict(self): + """(已弃用,且将会在后续版本中删除。请尽快使用 + ``CloudMusicIdentifier.to_mutagen_tag()`` 代替,以便极大简化步骤。) + + 根据当前对象储存的解析结果,构建并返回一个 Mutagen VorbisComment/ID3 风格的字典。 + + 此方法需要当前对象的 ``format`` 属性来决定构建何种风格的字典, + 并且只支持 ``'flac'`` (VorbisComment) 和 ``'mp3'`` (ID3)。 + + 本方法不支持嵌入封面图像,你需要通过其他手段做到。 + + 配合 Mutagen 使用(以 FLAC 为例): + + >>> from mutagen import flac # type: ignore + >>> ncm_tag = CloudMusicIdentifier(format='flac') + >>> mutagen_flac = mutagen.flac.FLAC('target.flac') # type: ignore + >>> mutagen_flac.clear() # 可选,此步骤将会清空 mutagen_flac 已有的数据 + >>> mutagen_flac.update(ncm_tag.to_mutagen_style_dict()) + >>> mutagen_flac.save() + >>> + + 配合 Mutagen 使用(以 MP3 为例,稍微麻烦一些): + + >>> from mutagen import id3, mp3 # type: ignore + >>> ncm_tag = CloudMusicIdentifier(format='mp3') + >>> mutagen_mp3 = mutagen.mp3.MP3('target.mp3') # type: ignore + >>> mutagen_mp3.clear() # 可选,此步骤将会清空 mutagen_mp3 已有的数据 + >>> for key, value in ncm_tag.to_mutagen_style_dict().items(): + ... id3frame_cls = getattr(id3, key[:4]) + ... id3frame = mutagen_mp3.get(key) + ... if id3frame is None: + ... mutagen_mp3[key] = id3frame_cls(text=value, desc='comment') + ... elif id3frame.text: + ... id3frame.text = value + ... mutagen_mp3[key] = id3frame + ... + >>> mutagen_mp3.save() + >>> + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.to_mutagen_style_dict() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'Use {type(self).__name__}.to_mutagen_tag() instead.' + ) + ) + + comment = self.to_ncm_163key().decode('utf-8') + if not isinstance(self.format, str): + raise TypeError(f"'self.format' must be str, not {type(self.format)}") + elif self.format.lower() == 'flac': + ret = { + 'title' : [self.musicName], + 'artist': [artistinfo[0] for artistinfo in self.artist], + 'album' : [self.album], + } + if comment is not None: + ret['comment'] = [comment] + elif self.format.lower() == 'mp3': + ret = { + 'TIT2': [self.musicName], + 'TPE1': [artistinfo[0] for artistinfo in self.artist], + 'TALB': [self.album] + } + if comment is not None: + ret['TXXX::comment'] = [comment] + else: + raise ValueError(f"unsupported tag format '{self.format}'") + + return ret + class NCMFileInfo(NamedTuple): """用于储存 NCM 文件的信息。""" @@ -308,19 +379,29 @@ class NCM(EncryptedBytesIOSkel): @classmethod def from_file(cls, - filething_or_info: tuple[Path | IO[bytes], NCMFileInfo | None] | FilePath | IO[bytes], /, - core_key: BytesLike = None, - tag_key: BytesLike = None, - master_key: BytesLike = None + ncm_filething: FilePath | IO[bytes], /, + core_key: BytesLike, ): - """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``NCM.open()`` 代替。""" + """(已弃用,且将会在后续版本中删除。请尽快使用 ``NCM.open()`` 代替。) + + 打开一个已有的 NCM 文件 ``ncm_filething``。 + + 第一个位置参数 ``ncm_filething`` 可以是 ``str``、``bytes`` 或任何拥有 ``__fspath__`` + 属性的路径对象。``ncm_filething`` 也可以是文件对象,该对象必须可读和可跳转 + (``ncm_filething.seekable() == True``)。 + + 本方法需要在文件中寻找并解密主密钥,随后使用主密钥解密音频数据。 + + 核心密钥 ``core_key`` 是第二个参数,用于解密找到的主密钥。 + """ warnings.warn( DeprecationWarning( - f'{cls.__name__}.from_file() is deprecated and no longer used. ' + f'{cls.__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' f'Use {cls.__name__}.open() instead.' ) ) - return cls.open(filething_or_info, core_key=core_key, tag_key=tag_key, master_key=master_key) + return cls.open(ncm_filething, core_key=core_key) @classmethod def open(cls, @@ -437,18 +518,36 @@ def operation(fd: IO[bytes]) -> cls: return instance def to_file(self, - core_key: BytesLike, - filething: FilePath | IO[bytes] = None, - tag_key: BytesLike | None = None + ncm_filething: FilePath | IO[bytes] = None, /, + core_key: BytesLike = None ) -> None: - """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``NCM.save()`` 代替。""" + """(已弃用,且将会在后续版本中删除。请尽快使用 ``NCM.save()`` 代替。) + + 将当前 NCM 对象保存到文件 ``filething``。 + 此过程会向 ``ncm_filething`` 写入 NCM 文件结构。 + + 第一个位置参数 ``ncm_filething`` 可以是 ``str``、``bytes`` 或任何拥有 ``__fspath__`` + 属性的路径对象。``ncm_filething`` 也可以是文件对象,该对象必须可写。 + + 第二个位置参数 ``core_key`` 是可选的。 + 如果提供此参数,本方法会将其作为核心密钥来加密主密钥;否则,使用 + ``self.core_key`` 代替。如果两者都为 ``None`` 或未提供,触发 ``ValueError``。 + + 如果提供了 ``ncm_filething``,本方法将会把数据写入 ``ncm_filething`` + 指向的文件。否则,本方法以写入模式打开一个指向 ``self.name`` + 的文件对象,将数据写入此文件对象。如果两者都为空或未提供,则会触发 + ``ValueError``。 + """ warnings.warn( DeprecationWarning( - f'{type(self).__name__}.from_file() is deprecated and no longer used. ' + f'{type(self).__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' f'Use {type(self).__name__}.save() instead.' ) ) - return self.save(core_key, filething=filething, tag_key=tag_key) + if not core_key: + core_key = self.core_key + return self.save(core_key, filething=ncm_filething) def save(self, core_key: BytesLike, @@ -557,6 +656,58 @@ def __init__(self, cipher: ARC4, /, initial_bytes=b''): self._cover_data: bytes | None = None self._ncm_tag: CloudMusicIdentifier = CloudMusicIdentifier() self._sourcefile: Path | None = None + self._core_key_deprecated: bytes | None = None + + @property + def core_key(self) -> bytes | None: + """(已弃用,且将会在后续版本中删除。) + + 核心密钥,用于加/解密主密钥。 + + ``NCM.from_file()`` 会在当前对象被创建时设置此属性;而 ``NCM.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.core_key is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage the core key by your self.' + ) + ) + return self._core_key_deprecated + + @core_key.setter + def core_key(self, value: BytesLike) -> None: + """(已弃用,且将会在后续版本中删除。) + + 核心密钥,用于加/解密主密钥。 + + ``NCM.from_file()`` 会在当前对象被创建时设置此属性;而 ``NCM.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.core_key is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage the core key by your self.' + ) + ) + self._core_key_deprecated = tobytes(value) + + @core_key.deleter + def core_key(self) -> None: + """(已弃用,且将会在后续版本中删除。) + + 核心密钥,用于加/解密主密钥。 + + ``NCM.from_file()`` 会在当前对象被创建时设置此属性;而 ``NCM.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.core_key is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage the core key by your self.' + ) + ) + self._core_key_deprecated = None @property def cover_data(self) -> bytes | None: From 94a347b16b35960355980fe520e94cdeb8ec20b9 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sat, 24 Dec 2022 20:12:15 +0800 Subject: [PATCH 35/58] =?UTF-8?q?`CloudMusicIdentifier`=EF=BC=9A=E6=96=B9?= =?UTF-8?q?=E6=B3=95=20`to=5Fmutagen=5Ftag()`=20=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=9C=A8=E6=A0=87=E7=AD=BE=E4=B8=AD=E5=B5=8C?= =?UTF-8?q?=E5=85=A5=20163key=EF=BC=9B=E4=BF=AE=E5=A4=8D=E4=BA=86=E6=96=B9?= =?UTF-8?q?=E6=B3=95=20`to=5Fncm=5F163key()`=20=E7=9A=84=E6=96=87=E6=A1=A3?= =?UTF-8?q?=EF=BC=8C=E4=BB=A5=E5=8F=8A=E4=BF=AE=E6=94=B9=E4=BA=86=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index efc6f59..ca72eda 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -53,7 +53,12 @@ def __post_init__(self) -> None: alias: list[str] = dcfield(default_factory=list) transNames: list[str] = dcfield(default_factory=list) - def to_mutagen_tag(self, tag_type: Literal['FLAC', 'ID3'] = None) -> flac.FLAC | id3.ID3: + def to_mutagen_tag(self, + tag_type: Literal['FLAC', 'ID3'] = None, + with_ncm_163key: bool = True, + tag_key: BytesLike | None = None, + return_cached_first: bytes = True + ) -> flac.FLAC | id3.ID3: """将 CloudMusicIdentifier 对象导出为 Mutagen 库使用的标签格式实例: ``mutagen.flac.FLAC`` 和 ``mutagen.id3.ID3``。 @@ -62,6 +67,9 @@ def to_mutagen_tag(self, tag_type: Literal['FLAC', 'ID3'] = None) -> flac.FLAC | Args: tag_type: 需要导出为何种格式的标签实例,仅支持 'FLAC' 和 'ID3' + with_ncm_163key: 是否在导出的标签中嵌入 163key + tag_key: (仅当 with_163key=True)歌曲信息密钥,用于加密 163key,以便将其写入注释 + return_cached_first: (仅当 with_163key=True)在满足特定条件时,将缓存的 163key 写入注释,而不是重新生成一个 Examples: >>> from mutagen import flac, mp3 @@ -131,6 +139,15 @@ def to_mutagen_tag(self, tag_type: Literal['FLAC', 'ID3'] = None) -> flac.FLAC | if attr: tag[tagkey] = constructor(attr) + if with_ncm_163key: + ncm_163key = self.to_ncm_163key(tag_key=tag_key, + return_cached_first=return_cached_first + ) + if isinstance(tag, flac.FLAC): + tag['description'] = [ncm_163key.decode('ascii')] + elif isinstance(tag, id3.ID3): + tag['TXXX::comment'] = id3.TXXX(encoding=3, desc='comment', text=[ncm_163key.decode('ascii')]) + return tag @classmethod @@ -166,7 +183,10 @@ def from_ncm_163key(cls, ncm_163key: str | BytesLike, /, tag_key: BytesLike = No instance._orig_tag_key = tag_key return instance - def to_ncm_163key(self, tag_key: BytesLike = None, return_cached: bytes = True) -> bytes: + def to_ncm_163key(self, + tag_key: BytesLike = None, + return_cached_first: bytes = True + ) -> bytes: """将 CloudMusicIdentifier 对象导出为 163key。 第一个参数 ``tag_key`` 用于解密 163key。如果留空,则使用默认值: @@ -181,15 +201,15 @@ def to_ncm_163key(self, tag_key: BytesLike = None, return_cached: bytes = True) 如果以上条件中的任意一条未被满足,转而返回一个根据当前对象重新生成的 163key。 Args: - tag_key: 歌曲信息密钥,用于解密 163key - return_cached: 在满足特定条件时,返回缓存的 163key,而不是重新生成一个 + tag_key: 歌曲信息密钥,用于加密 163key + return_cached_first: 在满足特定条件时,返回缓存的 163key,而不是重新生成一个 """ if tag_key is None: tag_key = b'\x23\x31\x34\x6c\x6a\x6b\x5f\x21\x5c\x5d\x26\x30\x55\x3c\x27\x28' else: tag_key = tobytes(tag_key) - if return_cached and self._orig_ncm_tag: + if return_cached_first and self._orig_ncm_tag: target_ncm_tag = {_ck: _cv for _ck, _cv in asdict(self).items() if _cv or _ck in self._orig_ncm_tag} else: target_ncm_tag = asdict(self) @@ -201,7 +221,7 @@ def operation() -> bytes: return ncm_163key - if return_cached and tag_key == self._orig_tag_key and self._orig_ncm_tag and self._orig_ncm_163key: + if return_cached_first and tag_key == self._orig_tag_key and self._orig_ncm_tag and self._orig_ncm_163key: if len(target_ncm_tag) == len(self._orig_ncm_tag): for k, v in self._orig_ncm_tag.items(): if target_ncm_tag[k] != v: From 128a5e53eca3a2ebcc01d6c16c5c20cc5624cd40 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sat, 24 Dec 2022 20:15:07 +0800 Subject: [PATCH 36/58] =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E7=BB=99=20`NCM.ncm=5Ftag`=20=E8=B5=8B?= =?UTF-8?q?=E5=80=BC=EF=BC=8C=E4=B8=8D=E8=BF=87=E5=BF=85=E9=A1=BB=E6=98=AF?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=20`CloudMusicIdentifier`=20=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index ca72eda..50fc604 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -748,3 +748,11 @@ def cover_data(self) -> None: def ncm_tag(self) -> CloudMusicIdentifier: """163key 的解析结果。""" return self._ncm_tag + + @ncm_tag.setter + def ncm_tag(self, value: CloudMusicIdentifier) -> None: + """163key 的解析结果。""" + if not isinstance(value, CloudMusicIdentifier): + raise TypeError( + f"attribute 'ncm_tag' must be CloudMusicIdentifier, not {type(value).__name__}" + ) From 620c9b06d77e5fd1f85c787508ec16482e296b68 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sat, 24 Dec 2022 22:50:57 +0800 Subject: [PATCH 37/58] =?UTF-8?q?=E6=94=B9=E9=80=A0=E4=BA=86=20`QMCv1`=20?= =?UTF-8?q?=E4=BD=BF=E5=AE=83=E5=92=8C=202.0.x=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/__init__.py | 41 +++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 4ebca1c..a5724ad 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -222,17 +222,28 @@ def acceptable_ciphers(self): @classmethod def from_file(cls, - filething: FilePath | IO[bytes], /, - mask: BytesLike + qmcv1_filething: FilePath | IO[bytes], /, + master_key: BytesLike ): - """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``QMCv1.open()`` 代替。""" + """(已弃用,且将会在后续版本中删除。请尽快使用 ``QMCv1.open()`` 代替。) + + 打开一个 QMCv1 文件或文件对象 ``qmcv1_filething``。 + + 第一个位置参数 ``qmcv1_filething`` 可以是文件路径(``str``、``bytes`` + 或任何拥有方法 ``__fspath__()`` 的对象)。``qmcv1_filething`` + 也可以是一个文件对象,但必须可读。 + + 第二个位置参数 ``master_key`` 用于解密音频数据,长度仅限 44、128 或 256 位。 + 如果不符合长度要求,会触发 ``ValueError``。 + """ warnings.warn( DeprecationWarning( - f'{cls.__name__}.from_file() is deprecated and no longer used. ' + f'{cls.__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' f'Use {cls.__name__}.open() instead.' ) ) - return cls.open(filething, mask=mask) + return cls.open(qmcv1_filething, mask=master_key) @classmethod def open(cls, @@ -287,15 +298,27 @@ def operation(fd: IO[bytes]) -> cls: return instance - def to_file(self, filething: FilePath | IO[bytes] = None, /) -> None: - """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``QMCv1.save()`` 代替。""" + def to_file(self, qmcv1_filething: FilePath | IO[bytes] = None, /) -> None: + """(已弃用,且将会在后续版本中删除。请尽快使用 ``QMCv1.save()`` 代替。) + + 将当前 QMCv1 对象的内容保存到文件 ``qmcv1_filething``。 + + 第一个位置参数 ``qmcv1_filething`` 可以是文件路径(``str``、``bytes`` + 或任何拥有方法 ``__fspath__()`` 的对象)。``qmcv1_filething`` + 也可以是一个文件对象,但必须可写。 + + 本方法会首先尝试写入 ``qmcv1_filething`` 指向的文件。 + 如果未提供 ``qmcv1_filething``,则会尝试写入 ``self.name`` + 指向的文件。如果两者都为空或未提供,则会触发 ``CrypterSavingError``。 + """ warnings.warn( DeprecationWarning( - f'{type(self).__name__}.from_file() is deprecated and no longer used. ' + f'{type(self).__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' f'Use {type(self).__name__}.save() instead.' ) ) - return self.save(filething) + return self.save(qmcv1_filething) def save(self, filething: FilePath | IO[bytes] = None, /) -> None: """将当前对象保存为一个新 QMCv1 文件。 From e89510dfa67e2caad692472a4fe7a43a5cb51a07 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 26 Dec 2022 14:40:23 +0800 Subject: [PATCH 38/58] =?UTF-8?q?=E6=94=B9=E9=80=A0=E4=BA=86=20`QMCv2`=20?= =?UTF-8?q?=E4=BD=BF=E5=AE=83=E5=92=8C=202.0.x=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=EF=BC=9B`QMCv2.extra=5Finfo`=20=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E7=8E=B0=E5=9C=A8=E6=98=AF=E5=8F=AF=E8=B5=8B=E5=80=BC?= =?UTF-8?q?=E5=92=8C=E5=8F=AF=E5=88=A0=E9=99=A4=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/__init__.py | 345 ++++++++++++++++++++++++++++--- 1 file changed, 314 insertions(+), 31 deletions(-) diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index a5724ad..2c06168 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -12,7 +12,7 @@ from ..exceptions import CrypterCreatingError from ..keyutils import make_random_ascii_string, make_salt from ..prototypes import EncryptedBytesIOSkel -from ..typedefs import BytesLike, FilePath +from ..typedefs import BytesLike, FilePath, IntegerLike from ..typeutils import isfilepath, tobytes, verify_fileobj from ..warns import CrypterSavingWarning @@ -299,7 +299,7 @@ def operation(fd: IO[bytes]) -> cls: return instance def to_file(self, qmcv1_filething: FilePath | IO[bytes] = None, /) -> None: - """(已弃用,且将会在后续版本中删除。请尽快使用 ``QMCv1.save()`` 代替。) + """v 将当前 QMCv1 对象的内容保存到文件 ``qmcv1_filething``。 @@ -403,17 +403,34 @@ def __init__(self, cipher: HardenedRC4 | Mask128, /, initial_bytes: BytesLike = self._extra_info: QMCv2QTag | QMCv2STag | None = None + self._core_key_deprecated: bytes | None = None + self._garble_key1_deprecated: bytes | None = None + self._garble_key2_deprecated: bytes | None = None + @property def extra_info(self) -> QMCv2QTag | QMCv2STag | None: + """源文件末尾的附加信息(如果有),根据类型可分为 QTag 或 STag。""" return self._extra_info @extra_info.setter - def extra_info(self, value: QMCv2QTag | QMCv2STag | None) -> None: - if value is None or isinstance(value, (QMCv2QTag, QMCv2STag)): + def extra_info(self, value: QMCv2QTag | QMCv2STag) -> None: + """源文件末尾的附加信息(如果有),根据类型可分为 QTag 或 STag。""" + if isinstance(value, (QMCv2QTag, QMCv2STag)): self._extra_info = value - raise TypeError( - f"attribute 'extra_info' must be QMCv2QTag, QMCv2STag, or None, not {repr(value)}" - ) + elif value is None: + raise TypeError( + f"None cannot be assigned to attribute 'extra_info'. " + f"Use `del self.extra_info` instead" + ) + else: + raise TypeError( + f"attribute 'extra_info' must be QMCv2QTag or QMCv2STag, not {repr(value)}" + ) + + @extra_info.deleter + def extra_info(self) -> None: + """源文件末尾的附加信息(如果有),根据类型可分为 QTag 或 STag。""" + self._extra_info = None @property def master_key(self) -> bytes | None: @@ -424,27 +441,234 @@ def master_key(self) -> bytes | None: return super().master_key + @property + def core_key(self) -> bytes | None: + """(已弃用,且将会在后续版本中删除。) + + 核心密钥,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.core_key or {type(self).__name__}.simple_key' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage the core key by your self.' + ) + ) + return self._core_key_deprecated + + @core_key.setter + def core_key(self, value: BytesLike) -> None: + """(已弃用,且将会在后续版本中删除。) + + 核心密钥,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.core_key or {type(self).__name__}.simple_key' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage the core key by your self.' + ) + ) + if value is None: + raise TypeError( + f"None cannot be assigned to attribute 'core_key'. " + f"Use `del self.core_key` instead" + ) + self._core_key_deprecated = tobytes(value) + + @core_key.deleter + def core_key(self) -> None: + """(已弃用,且将会在后续版本中删除。) + + 核心密钥,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.core_key or {type(self).__name__}.simple_key' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage the core key by your self.' + ) + ) + self._core_key_deprecated = None + + simple_key = core_key + + @property + def garble_key1(self) -> bytes | None: + """(已弃用,且将会在后续版本中删除。) + + 混淆密钥 1,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.garble_key1 or {type(self).__name__}.mix_key1' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage garble keys by your self.' + ) + ) + return self._garble_key1_deprecated + + @garble_key1.setter + def garble_key1(self, value: BytesLike) -> None: + """(已弃用,且将会在后续版本中删除。) + + 混淆密钥 1,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.garble_key1 or {type(self).__name__}.mix_key1' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage garble keys by your self.' + ) + ) + if value is None: + raise TypeError( + f"None cannot be assigned to attribute 'garble_key1'. " + f"Use `del self.core_key` instead" + ) + self._garble_key1_deprecated = tobytes(value) + + @garble_key1.deleter + def garble_key1(self): + """(已弃用,且将会在后续版本中删除。) + + 混淆密钥 1,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.garble_key1 or {type(self).__name__}.mix_key1' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage garble keys by your self.' + ) + ) + self._garble_key1_deprecated = None + + mix_key1 = garble_key1 + + @property + def garble_key2(self) -> bytes | None: + """(已弃用,且将会在后续版本中删除。) + + 混淆密钥 2,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.garble_key2 or {type(self).__name__}.mix_key2' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage garble keys by your self.' + ) + ) + return self._garble_key2_deprecated + + @garble_key2.setter + def garble_key2(self, value: BytesLike) -> None: + """(已弃用,且将会在后续版本中删除。) + + 混淆密钥 2,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.garble_key2 or {type(self).__name__}.mix_key2' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage garble keys by your self.' + ) + ) + if value is None: + raise TypeError( + f"None cannot be assigned to attribute 'garble_key2'. " + f"Use `del self.core_key` instead" + ) + self._garble_key2_deprecated = tobytes(value) + + @garble_key2.deleter + def garble_key2(self): + """(已弃用,且将会在后续版本中删除。) + + 混淆密钥 2,用于加/解密主密钥。 + + ``QMCv2.from_file()`` 会在当前对象被创建时设置此属性;而 ``QMCv2.open()`` 则不会。 + """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.garble_key2 or {type(self).__name__}.mix_key2' + f'is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'You need to manage garble keys by your self.' + ) + ) + self._garble_key2_deprecated = None + + mix_key2 = garble_key2 + @classmethod def from_file(cls, - filething_or_info: tuple[Path | IO[bytes], QMCv2FileInfo | None] | FilePath | IO[bytes], /, - core_key: BytesLike = None, - garble_key1: BytesLike = None, - garble_key2: BytesLike = None, - master_key: BytesLike = None + qmcv2_filething: FilePath | IO[bytes], /, + simple_key: BytesLike = None, + mix_key1: BytesLike = None, + mix_key2: BytesLike = None, *, + master_key: BytesLike = None, ): - """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``QMCv2.open()`` 代替。""" + """(已弃用,且将会在后续版本中删除。请尽快使用 ``QMCv2.open()`` 代替。) + + 打开一个 QMCv2 文件或文件对象 ``qmcv2_filething``。 + + 第一个位置参数 ``qmcv2_filething`` 可以是文件路径(``str``、``bytes`` + 或任何拥有方法 ``__fspath__()`` 的对象)。``qmcv2_filething`` + 也可以是一个文件对象,但必须可读、可跳转(``qmcv2_filething.seekable() == True``)。 + + 本方法会寻找文件内嵌主密钥的位置和加密方式,进而判断所用加密算法的类型。 + + 如果提供了参数 ``master_key``,那么此参数将会被视为主密钥, + 用于判断加密算法类型和解密音频数据,同时会跳过其他步骤。 + 其必须是类字节对象,且转换为 ``bytes`` 的长度必须是 128、256 + 或 512 位。如果不符合长度要求,会触发 ``ValueError``。否则: + + - 如果未能找到文件内嵌的主密钥,那么参数 ``master_key`` 是必需的。 + - 如果文件内嵌的主密钥,其加密版本为 V1,那么参数 ``simple_key`` 是必需的。 + - 如果文件内嵌的主密钥,其加密版本为 V2,那么除了 ``simple_key``,参数``mix_key1``、``mix_key2`` 也是必需的。 + + 以上特定条件中的必需参数,如果缺失,则会触发 ``ValueError``。 + """ warnings.warn( DeprecationWarning( - f'{cls.__name__}.from_file() is deprecated and no longer used. ' + f'{cls.__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' f'Use {cls.__name__}.open() instead.' ) ) - return cls.open(filething_or_info, - core_key=core_key, - garble_key1=garble_key1, - garble_key2=garble_key2, - master_key=master_key - ) + instance = cls.open(qmcv2_filething, + core_key=simple_key, + garble_key1=mix_key1, + garble_key2=mix_key2, + master_key=master_key + ) + instance._core_key_deprecated = tobytes(simple_key) + instance._garble_key1_deprecated = tobytes(mix_key1) + instance._garble_key2_deprecated = tobytes(mix_key2) @classmethod def open(cls, @@ -617,23 +841,82 @@ def operation(fd: IO[bytes]) -> cls: return instance def to_file(self, - core_key: BytesLike = None, - filething: FilePath | IO[bytes] = None, - garble_key1: BytesLike = None, - garble_key2: BytesLike = None, - with_extra_info: bool = False + qmcv2_filething: FilePath | IO[bytes] = None, /, + tag_type: Literal['qtag', 'stag'] = None, + simple_key: BytesLike = None, + master_key_enc_ver: IntegerLike = 1, + mix_key1: BytesLike = None, + mix_key2: BytesLike = None ) -> None: - """本方法已被废弃,并且可能会在未来版本中被移除。请尽快使用 ``QMCv2.save()`` 代替。""" + """(已弃用,且将会在后续版本中删除。请尽快使用 ``QMCv2.save()`` 代替。) + + 将当前 QMCv2 对象的内容保存到文件 ``qmcv2_filething``。 + + 第一个位置参数 ``qmcv2_filething`` 可以是文件路径(``str``、``bytes`` + 或任何拥有方法 ``__fspath__()`` 的对象)。``qmcv2_filething`` + 也可以是一个文件对象,但必须可写。 + + 本方法会首先尝试写入 ``qmcv2_filething`` 指向的文件。 + 如果未提供 ``qmcv2_filething``,则会尝试写入 ``self.name`` + 指向的文件。如果两者都为空或未提供,则会触发 ``CrypterSavingError``。 + + 参数 ``tag_type`` 决定在文件末尾附加的内容,仅支持以下值: + - ``None`` - 将主密钥加密后直接附加在文件末尾。 + - ``qtag`` - 将主密钥加密后封装在 QTag 信息中,附加在文件末尾。 + - ``stag`` - 将 STag 信息附加在文件末尾。 + - 注意:选择 STag 意味着文件内不会内嵌主密钥,你需要自己记下主密钥。 + - 访问属性 ``self.master_key`` 获取主密钥。 + + 如果 ``tag_type`` 为其他值,会触发 ``ValueError``。 + + 无论 ``tag_type`` 为何值(``stag`` 除外),都需要使用 ``simple_key`` 加密主密钥。 + 如果参数 ``master_key_enc_ver=2``,还需要 ``mix_key1`` 和 ``mix_key2``。 + 如果未提供这些参数,则会使用当前 QMCv2 对象的同名属性代替。 + 如果两者都为 ``None`` 或未提供,则会触发 ``CrypterSavingError``。 + """ warnings.warn( DeprecationWarning( - f'{type(self).__name__}.from_file() is deprecated and no longer used. ' + f'{type(self).__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' f'Use {type(self).__name__}.save() instead.' ) ) - return self.save(core_key=core_key, - filething=filething, - garble_key1=garble_key1, - garble_key2=garble_key2, + with_extra_info = False + if isinstance(self.extra_info, (QMCv2QTag, QMCv2QTag)) and tag_type: + with_extra_info = True + if master_key_enc_ver == 1: + mix_key1 = None + mix_key2 = None + elif master_key_enc_ver == 2: + if mix_key1 is None: + mix_key1 = self.garble_key1 + if mix_key2 is None: + mix_key2 = self.garble_key2 + if mix_key1 is None and mix_key2 is None: + raise TypeError( + "argument 'mix_key1' and 'mix_key2' is required to " + "decrypt the QMCv2 Key Encryption V2 protected master key" + ) + elif mix_key1 is None: + raise TypeError( + "argument 'mix_key1' is required to " + "decrypt the QMCv2 Key Encryption V2 protected master key" + ) + elif mix_key2 is None: + raise TypeError( + "argument 'mix_key2' is required to " + "decrypt the QMCv2 Key Encryption V2 protected master key" + ) + else: + raise ValueError("argument 'master_key_enc_ver' must be 1 or 2, " + f"not {master_key_enc_ver}" + ) + if simple_key is None: + simple_key = self.core_key + return self.save(core_key=simple_key, + filething=qmcv2_filething, + garble_key1=mix_key1, + garble_key2=mix_key2, with_extra_info=with_extra_info ) From b54963d33b070de195f9465eb0082c76190aa500 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 26 Dec 2022 14:41:04 +0800 Subject: [PATCH 39/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=20`TypeError`=20=E7=9A=84=E9=94=99=E8=AF=AF=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/ncm.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index 50fc604..469d8f8 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -710,6 +710,11 @@ def core_key(self, value: BytesLike) -> None: f'You need to manage the core key by your self.' ) ) + if value is None: + raise TypeError( + f"None cannot be assigned to attribute 'core_key'. " + f"Use `del self.core_key` instead" + ) self._core_key_deprecated = tobytes(value) @core_key.deleter @@ -737,6 +742,11 @@ def cover_data(self) -> bytes | None: @cover_data.setter def cover_data(self, value: BytesLike) -> None: """封面图像数据。""" + if value is None: + raise TypeError( + f"None cannot be assigned to attribute 'cover_data'. " + f"Use `del self.cover_data` instead" + ) self._cover_data = tobytes(value) @cover_data.deleter From 53b823cc7a2557cd26a43b685f33deb67f0dda78 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 30 Dec 2022 15:18:35 +0800 Subject: [PATCH 40/58] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E5=AF=B9=20KW?= =?UTF-8?q?M=20=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=E7=9A=84=E6=94=B9?= =?UTF-8?q?=E9=80=A0=EF=BC=9B=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E6=80=A7=E7=9A=84=20KWM=20=E6=96=87=E4=BB=B6=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kwm/__init__.py | 409 ++++++++++++++++++++----- src/libtakiyasha/kwm/kwmdataciphers.py | 93 +++--- 2 files changed, 381 insertions(+), 121 deletions(-) diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index 9f5a576..ea29b05 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -1,84 +1,206 @@ # -*- coding: utf-8 -*- from __future__ import annotations -from typing import IO +import warnings +from math import log10 +from pathlib import Path +from typing import IO, NamedTuple -from .kwmdataciphers import Mask32 +from .kwmdataciphers import Mask32, Mask32FromRecipe +from ..exceptions import CrypterCreatingError, CrypterSavingError from ..keyutils import make_salt -from ..prototypes import CryptLayerWrappedIOSkel -from ..typedefs import BytesLike, FilePath -from ..typeutils import isfilepath, tobytes, verify_fileobj +from ..prototypes import EncryptedBytesIOSkel +from ..typedefs import BytesLike, FilePath, IntegerLike, KeyStreamBasedStreamCipherProto, StreamCipherProto +from ..typeutils import isfilepath, tobytes, toint, verify_fileobj +DIGIT_CHARS = b'0123456789' +ASCII_LETTER_CHARS = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' -class KWM(CryptLayerWrappedIOSkel): - """基于 BytesIO 的 KWM 透明加密二进制流。 - 所有读写相关方法都会经过透明加密层处理: - 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 +class KWMFileInfo(NamedTuple): + mask_recipe: bytes + cipher_data_offset: int + cipher_data_len: int + bitrate: int | None + suffix: str - 调用读写相关方法时,附加参数 ``nocryptlayer=True`` - 可绕过透明加密层,访问缓冲区内的原始加密数据。 - 如果你要新建一个 KWM 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``KWM.new()`` 和 ``KWM.from_file()`` 新建或打开已有 KWM 文件。 +def probe(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], KWMFileInfo | None]: + """探测源文件 ``filething`` 是否为一个 KWM 文件。 - 已有 KWM 对象的 ``self.to_file()`` 方法可用于将对象内数据保存到文件,但目前尚未实现。 - 尝试调用此方法会触发 ``NotImplementedError``。 + 返回一个 2 个元素长度的元组:第一个元素为 ``filething``;如果 + ``filething`` 是 KWM 文件,那么第二个元素为一个 ``KWMFileInfo`` 对象;否则为 ``None``。 + + 本方法的返回值可以用于 ``KWM.open()`` 的第一个位置参数。 + + Args: + filething: 源文件的路径或文件对象 + Returns: + 一个 2 个元素长度的元组:第一个元素为 filething;如果 + filething 是 KWM 文件,那么第二个元素为一个 KWMFileInfo 对象;否则为 None。 """ + def operation(fd: IO[bytes]) -> KWMFileInfo | None: + fd.seek(0, 0) + + header_data = fd.read(1024) + cipher_data_offset = fd.tell() + cipher_data_len = fd.seek(0, 2) - cipher_data_offset + + if not header_data.startswith(b'yeelion-kuwo'): + return + + mask_recipe = header_data[24:32] + + bitrate = None + bitrate_suffix_serialized = header_data[48:56].rstrip(b'\x00') + bitrate_serialized_len = 0 + for byte in bitrate_suffix_serialized: + if byte in DIGIT_CHARS: + bitrate_serialized_len += 1 + if bitrate_serialized_len > 0: + bitrate = int(bitrate_suffix_serialized[:bitrate_serialized_len]) * 1000 + + suffix = None + suffix_serialized = bitrate_suffix_serialized[bitrate_serialized_len:] + suffix_serialized_len = 0 + for byte in suffix_serialized: + if byte in ASCII_LETTER_CHARS: + suffix_serialized_len += 1 + if suffix_serialized_len > 0: + suffix = suffix_serialized[:suffix_serialized_len].decode('ascii') + + return KWMFileInfo( + mask_recipe=mask_recipe, + cipher_data_offset=cipher_data_offset, + cipher_data_len=cipher_data_len, + bitrate=bitrate, + suffix=suffix + ) + + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + return Path(filething), operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_origpos = fileobj.tell() + prs = operation(fileobj) + fileobj.seek(fileobj_origpos, 0) + + return fileobj, prs + + +class KWM(EncryptedBytesIOSkel): @property - def cipher(self) -> Mask32: - return self._cipher + def acceptable_ciphers(self): + return [Mask32FromRecipe] - @property - def core_key(self) -> bytes: - return self.cipher.core_key + def __init__(self, + cipher: StreamCipherProto | KeyStreamBasedStreamCipherProto, /, + initial_bytes: BytesLike = b'' + ): + super().__init__(cipher, initial_bytes=initial_bytes) + + self._bitrate: int | None = None + self._suffix: str | None = None @property - def master_key(self) -> bytes: - return self.cipher.master_key + def bitrate(self) -> int | None: + """音频的比特率。如果要用作显示用途,需要除以 1000。 - def __init__(self, cipher: Mask32, /, initial_bytes: BytesLike = b'') -> None: - """基于 BytesIO 的 KWM 透明加密二进制流。 + 不可设置为负数;如果不为 ``None``,其字面量长度与后缀 ``self.suffix`` 的长度不可超过 8。 + """ + return self._bitrate + + @bitrate.setter + def bitrate(self, value: IntegerLike) -> None: + """音频的比特率。如果要用作显示用途,需要除以 1000。 - 所有读写相关方法都会经过透明加密层处理: - 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + 不可设置为负数;如果不为 ``None``,其字面量长度与后缀 ``self.suffix`` 的长度不可超过 8。 + """ + if value is None: + raise TypeError( + f"None cannot be assigned to attribute 'bitrate'. " + f"Use `del self.bitrate` instead" + ) + br = toint(value) + if br < 0: + raise ValueError(f"attribute 'bitrate' must be a non-netagive integer, not {value}") + if self._suffix is None: + max_bitrate_len = 8 + else: + max_bitrate_len = 8 - len(self._suffix) + bitrate_len = int(log10(br // 1000)) + 1 + if bitrate_len > max_bitrate_len: + raise ValueError(f"attribute 'bitrate' must be less than {max_bitrate_len}, not {bitrate_len}") - 调用读写相关方法时,附加参数 ``nocryptlayer=True`` - 可绕过透明加密层,访问缓冲区内的原始加密数据。 + self._bitrate = br - 如果你要新建一个 KWM 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``KWM.new()`` 和 ``KWM.from_file()`` 新建或打开已有 KWM 文件。 + @bitrate.deleter + def bitrate(self) -> None: + """音频的比特率。本属性储存的是乘以 1000 后的结果。 - 已有 KWM 对象的 ``self.to_file()`` 方法可用于将对象内数据保存到文件,但目前尚未实现。 - 尝试调用此方法会触发 ``NotImplementedError``。 + 不可设置为负数;如果不为 ``None``,其整除 1000 后的字面量长度与后缀 + ``self.suffix`` 的长度之和不可大于 8。 """ - super().__init__(cipher, initial_bytes) - if not isinstance(cipher, Mask32): - raise TypeError('unsupported Cipher: ' - f'supports {Mask32.__module__}.{Mask32.__name__}, ' - f'not {type(cipher).__name__}' - ) + self._bitrate = None - @classmethod - def new(cls, core_key: BytesLike) -> KWM: - """创建并返回一个全新的空 KWM 对象。 + @property + def suffix(self) -> int | None: + """加密数据对应的文件应当使用的后缀。由于不够精确,不建议使用。 - 第一个参数 ``core_key`` 是必需的,它被用于还原和解密主密钥。 + 如果不为 None,其长度与比特率 ``self.bitrate`` 整除 1000 后的字面量长度之和不可大于 8。 """ - core_key = tobytes(core_key) + return self._suffix - master_key = make_salt(8) - cipher = Mask32(core_key, master_key) + @suffix.setter + def suffix(self, value: str) -> None: + """加密数据对应的文件应当使用的后缀。由于不够精确,不建议使用。 - return cls(cipher) + 如果不为 None,其长度与比特率 ``self.bitrate`` 整除 1000 后的字面量长度之和不可大于 8。 + """ + if value is None: + raise TypeError( + f"None cannot be assigned to attribute 'suffix'. " + f"Use `del self.suffix` instead" + ) + if not isinstance(value, str): + raise TypeError(f"attribute 'suffix' must be str, not {type(value).__name__}") + value = str(value) + if self._bitrate is None: + max_suffix_len = 8 + else: + max_suffix_len = 8 - (int(log10(self._bitrate // 1000)) + 1) + if len(value) > max_suffix_len: + raise ValueError( + f"attribute 'bitrate' must be less than {max_suffix_len}, not {len(value)}" + ) + for char in (ord(_) for _ in value): + if char not in ASCII_LETTER_CHARS: + raise ValueError( + f"attribute 'suffix' can only contains digits and ascii letters, but '{chr(char)}' found" + ) + self._suffix = value + + @suffix.deleter + def suffix(self) -> None: + """加密数据对应的文件应当使用的后缀。由于不够精确,不建议使用。 + + 如果不为 None,其长度与比特率 ``self.bitrate`` 整除 1000 后的字面量长度之和不可大于 8。 + """ + self._suffix = None @classmethod def from_file(cls, kwm_filething: FilePath | IO[bytes], /, core_key: BytesLike ): - """打开一个 KWM 文件或文件对象 ``kwm_filething``。 + """(已弃用,且将会在后续版本中删除。请尽快使用 ``KWM.open()`` 代替。) + + 打开一个 KWM 文件或文件对象 ``kwm_filething``。 第一个位置参数 ``kwm_filething`` 可以是文件路径(``str``、``bytes`` 或任何拥有方法 ``__fspath__()`` 的对象)。``kwm_filething`` @@ -86,46 +208,175 @@ def from_file(cls, 第二个参数 ``core_key`` 是必需的,它被用于还原和解密主密钥。 """ + warnings.warn( + DeprecationWarning( + f'{cls.__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'Use {cls.__name__}.open() instead.' + ) + ) - def operation(fileobj: IO[bytes]) -> cls: - if not fileobj.read(24).startswith(b'yeelion-kuwo-tme'): - raise ValueError(f"{repr(kwm_filething)} is not a KWM file") + return cls.open(kwm_filething, core_key=core_key) - master_key = fileobj.read(8) - cipher = Mask32(core_key, master_key) - - fileobj.seek(1024, 0) - initial_bytes = fileobj.read() - - return cls(cipher, initial_bytes) - - core_key = tobytes(core_key) + @classmethod + def open(cls, + filething_or_info: tuple[Path | IO[bytes], KWMFileInfo | None] | FilePath | IO[bytes], /, + core_key: BytesLike = None, + master_key: BytesLike = None + ): + """打开一个 KWM 文件,并返回一个 ``KWM`` 对象。 + + 第一个位置参数 ``filething_or_info`` 需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + + ``filething_or_info`` 也可以接受 ``probe()`` 函数的返回值: + 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 + + 第二个参数 ``core_key`` 一般情况下是必需的,用于解密文件内嵌的主密钥。 + 例外:如果你提供了第三个参数 ``mask``,那么它是可选的。 + + 第三个参数 ``mask`` 可选,如果提供,将会被作为主密钥使用, + 而文件内置的主密钥会被忽略,``core_key`` 也不再是必需参数。 + + Args: + filething_or_info: 源文件的路径或文件对象,或者 probe() 的返回值 + core_key: 核心密钥,用于生成文件内加密数据的主密钥 + master_key: 如果提供,将会被作为主密钥使用,而文件内置的主密钥会被忽略 + """ + if core_key is not None: + core_key = tobytes(core_key) + if master_key is not None: + master_key = tobytes(master_key) + + def operation(fd: IO[bytes]) -> cls: + fd.seek(1024, 0) + initial_bytes = fd.read() + + if master_key is not None: + cipher = Mask32(master_key) + elif core_key is None: + raise TypeError( + "argument 'core_key' is required to " + "generate the master key" + ) + else: + cipher = Mask32FromRecipe(fileinfo.mask_recipe, core_key) + + inst = cls(cipher, initial_bytes) + inst._bitrate = fileinfo.bitrate + inst._suffix = fileinfo.suffix + + return inst + + if isinstance(filething_or_info, tuple): + filething_or_info: tuple[Path | IO[bytes], KWMFileInfo | None] + if len(filething_or_info) != 2: + raise TypeError( + "first argument 'filething_or_info' must be a file path, a file object, " + "or a tuple of probe() returns" + ) + filething, fileinfo = filething_or_info + if fileinfo is None: + raise CrypterCreatingError( + f"{repr(filething)} is not a KWM file" + ) + else: + filething, fileinfo = probe(filething_or_info) - if isfilepath(kwm_filething): - with open(kwm_filething, mode='rb') as kwm_fileobj: - instance = operation(kwm_fileobj) + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + instance = operation(fileobj) + instance._name = Path(filething) else: - kwm_fileobj = verify_fileobj(kwm_filething, 'binary', - verify_readable=True, - verify_seekable=True - ) + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_sourcefile = getattr(fileobj, 'name', None) + instance = operation(fileobj) - instance._name = getattr(kwm_fileobj, 'name', None) + if fileobj_sourcefile is not None: + instance._name = Path(fileobj_sourcefile) return instance - def to_file(self, kwm_filething: FilePath | IO[bytes]) -> None: - """警告:尚未完全探明 KWM 文件的结构,因此本方法尚未实现,尝试调用会触发 - ``NotImplementedError``。预计的参数和行为如下: + def to_file(self, kwm_filething: FilePath | IO[bytes] = None) -> None: + """(已弃用,且将会在后续版本中删除。请尽快使用 ``KWM.save()`` 代替。)""" + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'Use {type(self).__name__}.save() instead.' + ) + ) + + return self.save(kwm_filething) + + def save(self, + filething: FilePath | IO[bytes] = None, + newer_magic_header: bool = False + ) -> None: + """(实验性功能)将当前对象保存为一个新 KWM 文件。 + + 第一个参数 ``filething`` 是可选的,如果提供此参数,需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + 如果未提供此参数,那么将会尝试使用当前对象的 ``source`` 属性;如果后者也不可用,则引发 + ``TypeError``。 + + 第二个参数 ``newer_magic_header`` 可选,如果为 ``True``,那么保存的文件会使用新版 KWM\ + 文件的文件头 ``b'yeelion-kuwo'``;否则使用 ``b'yeelion-kuwo-tme'``。 + + Args: + filething: 目标文件的路径或文件对象 + newer_magic_header: 是否使用新版 KWM 文件使用的文件头 + """ + + def operation(fd: IO[bytes]) -> None: + recipe = self.cipher.getkey('original') + if not recipe: + raise CrypterSavingError('cannot store a non-standard recipe into a KWM file') + + fd.seek(0, 0) + if newer_magic_header: + fd.write(b'yeelion-kuwo') + else: + fd.write(b'yeelion-kuwo-tme') + fd.seek(24, 0) + fd.write(recipe) + fd.seek(48, 0) + if self._bitrate is not None: + fd.write(str(self._bitrate // 1000).encode('ascii')) + if self._suffix is not None: + fd.write(self._suffix.encode('ascii')) + fd.seek(1024, 0) + fd.write(self.getvalue(nocryptlayer=True)) + + if filething is None: + if self.source is None: + raise TypeError( + "attribute 'self.source' and argument 'filething' are empty, " + "don't know which file to save to" + ) + filething = self.source + + if isfilepath(filething): + with open(filething, mode='wb') as fileobj: + return operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_seekable=True, + verify_writable=True + ) + return operation(fileobj) - 将当前 KWM 对象的内容保存到文件 ``kwm_filething``。 + @classmethod + def new(cls, core_key: BytesLike): + """返回一个空 KWM 对象。""" + core_key = tobytes(core_key) - 第一个位置参数 ``kwm_filething`` 可以是文件路径(``str``、``bytes`` - 或任何拥有方法 ``__fspath__()`` 的对象)。``kwm_filething`` - 也可以是一个文件对象,但必须可写。 + recipe = make_salt(8) + cipher = Mask32FromRecipe(recipe, core_key) - 本方法会首先尝试写入 ``kwm_filething`` 指向的文件。 - 如果未提供 ``kwm_filething``,则会尝试写入 ``self.name`` - 指向的文件。如果两者都为空或未提供,则会触发 ``CrypterSavingError``。 - """ - raise NotImplementedError('coming soon') + return cls(cipher) diff --git a/src/libtakiyasha/kwm/kwmdataciphers.py b/src/libtakiyasha/kwm/kwmdataciphers.py index f95cf56..cdc69d4 100644 --- a/src/libtakiyasha/kwm/kwmdataciphers.py +++ b/src/libtakiyasha/kwm/kwmdataciphers.py @@ -8,53 +8,21 @@ from ..typedefs import BytesLike, IntegerLike from ..typeutils import tobytes, toint -__all__ = ['Mask32'] +__all__ = ['Mask32', 'Mask32FromRecipe'] class Mask32(KeyStreamBasedStreamCipherSkel): - @property - def core_key(self) -> bytes: - return self._core_key + def __init__(self, mask32: BytesLike, /) -> None: + self._mask32 = tobytes(mask32) + if len(self._mask32) != 32: + raise ValueError(f"invalid mask length: should be 32, got {len(self._mask32)}") - @property - def master_key(self) -> bytes: - return self._master_key - - @property - def mask32(self) -> bytes: - return self._mask32 - - def __init__(self, core_key: BytesLike, master_key=BytesLike, /) -> None: - core_key = tobytes(core_key) - master_key = tobytes(master_key) - - for varname, var, expectlen in ('core_key', core_key, 32), ('master_key', master_key, 8): - if len(var) != expectlen: - f"invalid length of argument '{varname}': should be {expectlen}, not {len(var)}" - - self._core_key = core_key - self._master_key = master_key - - mask_stage1 = str(int.from_bytes(master_key, 'little')) - if len(mask_stage1) >= 32: - mask_stage2 = mask_stage1[:32] - else: - mask_stage2_pad_len = 32 - len(mask_stage1) - mask_stage2_stage1_fullpad_count = (mask_stage2_pad_len // len(mask_stage1)) - mask_stage2_stage1_fullpad_len = len(mask_stage1) * mask_stage2_stage1_fullpad_count - mask_stage2_remain_len = mask_stage2_pad_len - mask_stage2_stage1_fullpad_len - - mask_stage2_composition = [mask_stage1] - for _ in range(mask_stage2_stage1_fullpad_count): - mask_stage2_composition.append(mask_stage1) - mask_stage2_composition.append(mask_stage1[:mask_stage2_remain_len]) - mask_stage2 = ''.join(mask_stage2_composition).encode('utf-8') - - mask_final = bytestrxor(mask_stage2, core_key) - self._mask32 = mask_final + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._mask32 @classmethod - def cls_keystream(cls, nbytes: IntegerLike, offset: IntegerLike, /, mask32: BytesLike) -> Generator[int, None, None]: + def cls_keystream(cls, mask32: BytesLike, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: offset = toint(offset) nbytes = toint(nbytes) if offset < 0: @@ -83,4 +51,45 @@ def cls_keystream(cls, nbytes: IntegerLike, offset: IntegerLike, /, mask32: Byte yield from maskblk_data[:target_after_maskblk_area_len] def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: - yield from self.cls_keystream(nbytes, offset, self._mask32) + yield from self.cls_keystream(self._mask32, nbytes, offset) + + +class Mask32FromRecipe(Mask32): + def __init__(self, recipe: BytesLike, core_key: BytesLike, /) -> None: + recipe = tobytes(recipe) + core_key = tobytes(core_key) + + for varname, var, expectlen in ('core_key', core_key, 32), ('recipe', recipe, 8): + if len(var) != expectlen: + f"invalid length of argument '{varname}': should be {expectlen}, not {len(var)}" + + mask_recipe_unpacked = int.from_bytes(recipe, 'little') + mask_stage1 = str(mask_recipe_unpacked).encode('ascii') + if len(mask_stage1) >= 32: + mask_stage2 = mask_stage1[:32] + else: + mask_stage2_pad_len = 32 - len(mask_stage1) + mask_stage2_stage1_fullpad_count = (mask_stage2_pad_len // len(mask_stage1)) + mask_stage2_stage1_fullpad_len = len(mask_stage1) * mask_stage2_stage1_fullpad_count + mask_stage2_remain_len = mask_stage2_pad_len - mask_stage2_stage1_fullpad_len + + mask_stage2_composition = [mask_stage1] + for _ in range(mask_stage2_stage1_fullpad_count): + mask_stage2_composition.append(mask_stage1) + mask_stage2_composition.append(mask_stage1[:mask_stage2_remain_len]) + mask_stage2 = b''.join(mask_stage2_composition) + + mask_final = bytestrxor(mask_stage2, core_key) + + self._source_recipe = recipe + self._core_key = core_key + + super().__init__(mask_final) + + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'original': + return self._source_recipe + elif keyname == 'core': + return self._core_key + else: + return super().getkey(keyname) From bf28026e870d8866b93a97ca3a9da34ea90f3bae Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 30 Dec 2022 21:34:46 +0800 Subject: [PATCH 41/58] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=20`probe=5Fqm?= =?UTF-8?q?cv1()`=20=E7=94=A8=E4=BA=8E=E6=8E=A2=E6=B5=8B=20QMCv1=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E3=80=81`probe()`=20=E7=94=A8=E4=BA=8E?= =?UTF-8?q?=E7=BB=93=E5=90=88=20QMCv1=20=E5=92=8C=20QMCv2=20=E7=9A=84?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/qmc/__init__.py | 149 +++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 17 deletions(-) diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 2c06168..5361506 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import re import warnings from base64 import b64decode, b64encode from dataclasses import dataclass @@ -20,6 +21,8 @@ warnings.filterwarnings(action='default', category=DeprecationWarning, module=__name__) __all__ = [ + 'probe', + 'probe_qmcv1', 'probe_qmcv2', 'QMCv1', 'QMCv2', @@ -27,6 +30,8 @@ 'QMCv2STag' ] +QMCV1_SUFFIX_PATTERN = re.compile('\\.qmc[a-zA-Z0-9]{1,4}$', flags=re.IGNORECASE) + @dataclass class QMCv2QTag: @@ -89,9 +94,16 @@ def dump(self) -> bytes: ) +class QMCv1FileInfo(NamedTuple): + """用于存储 QMCv1 文件的信息。""" + cipher_data_offset: int + cipher_data_len: int + + class QMCv2FileInfo(NamedTuple): """用于存储 QMCv2 文件的信息。""" cipher_ctor: Callable[[...], HardenedRC4] | Callable[[...], Mask128] | None + cipher_data_offset: int cipher_data_len: int master_key_encrypted: bytes | None master_key_encryption_ver: int | None @@ -117,11 +129,69 @@ def _guess_cipher_ctor(master_key: BytesLike, /, return Mask128 +def probe_qmcv1(filething: FilePath | IO[bytes], /, is_qmcv1: bool = False) -> tuple[Path | IO[bytes], QMCv1FileInfo | None]: + """探测源文件 ``filething`` 是否为一个 QMCv1 文件。 + + 返回一个 2 个元素长度的元组: + + - 第一个元素为 ``filething``; + - 如果 ``filething`` 是 QMCv1 文件,那么第二个元素为一个 ``QMCv1FileInfo`` 对象; + - 否则为 ``None``。 + + 目前难以通过文件结构识别 QMCv1 文件,因此本方法通过文件扩展名判断是否为 QMCv1 文件。 + 只要文件扩展名匹配下列正则表达式模式(不区分大小写),本方法就会将此文件视为一个 QMCv1 文件: + + ``^\\.qmc[a-zA-Z0-9]{1,4}$`` + + 对于不匹配以上正则表达式的文件扩展名(或者无法获取到文件扩展名),如果参数 + ``is_qmcv1=True``,本方法会跳过探测过程,认为此文件是一个 QMCv1 文件,并直接返回结果。 + + 本方法的返回值可以用于 ``QMCv1.open()`` 的第一个位置参数。 + + 本方法不适用于 QMCv2 文件的探测。 + + Args: + filething: 源文件的路径或文件对象 + is_qmcv1: 跳过探测过程,认为源文件是一个 QMCv1 文件 + Returns: + 一个 2 个元素长度的元组:第一个元素为 filething;如果 + filething 是 QMCv1 文件,那么第二个元素为一个 QMCv1FileInfo 对象;否则为 None。 + """ + + def operation(fd: IO[bytes]) -> QMCv1FileInfo | None: + filename = getattr(fd, 'name', None) + if filename is None and not is_qmcv1: + return + filepath = Path(filename) + if QMCV1_SUFFIX_PATTERN.search(filepath.suffix) or is_qmcv1: + return QMCv1FileInfo( + cipher_data_offset=0, + cipher_data_len=fd.seek(0, 2) + ) + + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + return Path(filething), operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_origpos = fileobj.tell() + prs = operation(fileobj) + fileobj.seek(fileobj_origpos, 0) + + return fileobj, prs + + def probe_qmcv2(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], QMCv2FileInfo | None]: """探测源文件 ``filething`` 是否为一个 QMCv2 文件。 - 返回一个 2 个元素长度的元组:第一个元素为 ``filething``;如果 - ``filething`` 是 QMCv2 文件,那么第二个元素为一个 ``QMCv2FileInfo`` 对象;否则为 ``None``。 + 返回一个 2 个元素长度的元组: + + - 第一个元素为 ``filething``; + - 如果 ``filething`` 是 QMCv2 文件,那么第二个元素为一个 ``QMCv2FileInfo`` 对象; + - 否则为 ``None``。 本方法的返回值可以用于 ``QMCv2.open()`` 的第一个位置参数。 @@ -181,6 +251,7 @@ def operation(fd: IO[bytes]) -> QMCv2FileInfo | None: cipher_ctor = _guess_cipher_ctor(master_key_encrypted) return QMCv2FileInfo(cipher_ctor=cipher_ctor, + cipher_data_offset=0, cipher_data_len=cipher_data_len, master_key_encrypted=master_key_encrypted, master_key_encryption_ver=master_key_encryption_ver, @@ -202,6 +273,33 @@ def operation(fd: IO[bytes]) -> QMCv2FileInfo | None: return fileobj, prs +def probe( + filething: FilePath | IO[bytes], / +) -> tuple[Path | IO[bytes], QMCv1FileInfo | None] | tuple[Path | IO[bytes], QMCv2FileInfo | None]: + """探测源文件 ``filething`` 是否为一个 QMCv1 或 QMCv2 文件。 + + 返回一个 2 个元素长度的元组: + + - 第一个元素为 ``filething``; + - 如果 ``filething`` 是 QMCv1 文件,那么第二个元素为一个 ``QMCv1FileInfo`` 对象; + - 如果 ``filething`` 是 QMCv2 文件,那么第二个元素为一个 ``QMCv2FileInfo`` 对象; + - 如果都不是,则为 ``None``。 + + 本方法的返回值可以用于 ``QMCv1.open()`` 和 ``QMCv2.open()`` 的第一个位置参数。 + + Args: + filething: 源文件的路径或文件对象 + Returns: + 一个 2 个元素长度的元组:第一个元素为 filething;如果 + filething 是 QMCv1 文件,那么第二个元素为一个 QMCv1FileInfo 对象;如果 + filething 是 QMCv2 文件,那么第二个元素为一个 QMCv2FileInfo 对象;否则为 None。 + """ + fthing, fileinfo = probe_qmcv2(filething) + if fileinfo: + return fthing, fileinfo + return probe_qmcv1(filething) + + class QMCv1(EncryptedBytesIOSkel): """基于 BytesIO 的 QMCv1 透明加密二进制流。 @@ -247,7 +345,7 @@ def from_file(cls, @classmethod def open(cls, - filething: FilePath | IO[bytes], /, + filething_or_info: FilePath | IO[bytes], /, mask: BytesLike ): """打开一个 QMCv1 文件,并返回一个 ``QMCv1`` 对象。 @@ -259,7 +357,7 @@ def open(cls, 第二个参数 ``mask`` 是必需的,用于主密钥。其长度必须为 44、128 或 256 位。 Args: - filething: 源文件的路径或文件对象 + filething_or_info: 源文件的路径或文件对象 mask: 文件的主密钥,其长度必须为 44、128 或 256 位 Raises: ValueError: mask 的长度不符合上述要求 @@ -278,8 +376,24 @@ def operation(fd: IO[bytes]) -> cls: f"the length of argument 'mask' must be 44, 128, or 256, not {len(mask)}" ) - fd.seek(0, 0) - return cls(cipher, fd.read()) + fd.seek(fileinfo.cipher_data_offset, 0) + return cls(cipher, fd.read(fileinfo.cipher_data_len)) + + if isinstance(filething_or_info, tuple): + filething_or_info: tuple[Path | IO[bytes], QMCv1FileInfo | None] + if len(filething_or_info) != 2: + raise TypeError( + "first argument 'filething_or_info' must be a file path, a file object, " + "or a tuple of probe(), probe_qmcv1() returns" + ) + filething, fileinfo = filething_or_info + else: + filething, fileinfo = probe_qmcv1(filething_or_info) + + if not isinstance(fileinfo, QMCv1FileInfo): + raise CrypterCreatingError( + f"{repr(filething)} is not a QMCv1 file" + ) if isfilepath(filething): with open(filething, mode='rb') as fileobj: @@ -299,7 +413,7 @@ def operation(fd: IO[bytes]) -> cls: return instance def to_file(self, qmcv1_filething: FilePath | IO[bytes] = None, /) -> None: - """v + """(已弃用,且将会在后续版本中删除。请尽快使用 ``QMCv2.save()`` 代替。) 将当前 QMCv1 对象的内容保存到文件 ``qmcv1_filething``。 @@ -539,7 +653,7 @@ def garble_key1(self, value: BytesLike) -> None: if value is None: raise TypeError( f"None cannot be assigned to attribute 'garble_key1'. " - f"Use `del self.core_key` instead" + f"Use `del self.garble_key1` instead" ) self._garble_key1_deprecated = tobytes(value) @@ -600,7 +714,7 @@ def garble_key2(self, value: BytesLike) -> None: if value is None: raise TypeError( f"None cannot be assigned to attribute 'garble_key2'. " - f"Use `del self.core_key` instead" + f"Use `del self.garble_key2` instead" ) self._garble_key2_deprecated = tobytes(value) @@ -685,7 +799,7 @@ def open(cls, 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 - ``filething_or_info`` 也可以接受 ``probe_qmcv2()`` 函数的返回值: + ``filething_or_info`` 也可以接受 ``probe()`` 和 ``probe_qmcv2()`` 函数的返回值: 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 第二个参数 ``core_key`` 一般情况下是必需的,用于解密文件内嵌的主密钥。 @@ -705,10 +819,10 @@ def open(cls, - ``'rc4'`` - 强化版 RC4(HardenedRC4) - ``None`` - 不指定,由 ``probe_qmcv2()`` 自行探测 - 此参数的设置会覆盖 ``probe_qmcv2()`` 的探测结果。 + 此参数的设置会覆盖 ``probe()`` 或 ``probe_qmcv2()`` 的探测结果。 Args: - filething_or_info: 源文件的路径或文件对象,或者 probe_qmcv2() 的返回值 + filething_or_info: 源文件的路径或文件对象,或者 probe() 和 probe_qmcv2() 的返回值 core_key: 核心密钥,用于解密文件内嵌的主密钥 garble_key1: 混淆密钥 1,用于解密使用 V2 加密的主密钥 garble_key2: 混淆密钥 2,用于解密使用 V2 加密的主密钥 @@ -813,16 +927,17 @@ def operation(fd: IO[bytes]) -> cls: if len(filething_or_info) != 2: raise TypeError( "first argument 'filething_or_info' must be a file path, a file object, " - "or a tuple of probe_qmcv2() returns" + "or a tuple of probe(), probe_qmcv2() returns" ) filething, fileinfo = filething_or_info - if fileinfo is None: - raise CrypterCreatingError( - f"{repr(filething)} is not a QMCv2 file" - ) else: filething, fileinfo = probe_qmcv2(filething_or_info) + if not isinstance(fileinfo, QMCv2FileInfo): + raise CrypterCreatingError( + f"{repr(filething)} is not a QMCv2 file" + ) + if isfilepath(filething): with open(filething, mode='rb') as fileobj: instance = operation(fileobj) From afd486736e1c83a6fcf68abb9feb2785110e8a56 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 30 Dec 2022 21:48:58 +0800 Subject: [PATCH 42/58] =?UTF-8?q?=E4=B8=BA=20`NCM`=E3=80=81`KWM`=E3=80=81`?= =?UTF-8?q?QMCv1`=E3=80=81`QMCv2`=20=E5=9C=A8=20`open()`=20=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E7=9A=84=E7=AC=AC=E4=B8=80=E4=B8=AA=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E4=B8=BA=E5=85=83=E7=BB=84=E7=9A=84=E6=83=85=E5=86=B5=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BA=86=E6=9B=B4=E4=B8=BA=E4=B8=A5=E6=A0=BC=E7=9A=84?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kwm/__init__.py | 13 +++++++++---- src/libtakiyasha/ncm.py | 13 +++++++++---- src/libtakiyasha/qmc/__init__.py | 12 ++++++++++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index ea29b05..c31ff41 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -277,13 +277,18 @@ def operation(fd: IO[bytes]) -> cls: "or a tuple of probe() returns" ) filething, fileinfo = filething_or_info - if fileinfo is None: - raise CrypterCreatingError( - f"{repr(filething)} is not a KWM file" - ) else: filething, fileinfo = probe(filething_or_info) + if fileinfo is None: + raise CrypterCreatingError( + f"{repr(filething)} is not a KWM file" + ) + elif not isinstance(fileinfo, KWMFileInfo): + raise TypeError( + f"second element of the tuple must be KWMFileInfo or None, not {type(fileinfo).__name__}" + ) + if isfilepath(filething): with open(filething, mode='rb') as fileobj: instance = operation(fileobj) diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index 469d8f8..bf2dca6 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -513,13 +513,18 @@ def operation(fd: IO[bytes]) -> cls: "or a tuple of probe() returns" ) filething, fileinfo = filething_or_info - if fileinfo is None: - raise CrypterCreatingError( - f"{repr(filething)} is not a NCM file" - ) else: filething, fileinfo = probe(filething_or_info) + if fileinfo is None: + raise CrypterCreatingError( + f"{repr(filething)} is not a NCM file" + ) + elif not isinstance(fileinfo, NCMFileInfo): + raise TypeError( + f"second element of the tuple must be NCMFileInfo or None, not {type(fileinfo).__name__}" + ) + if isfilepath(filething): with open(filething, mode='rb') as fileobj: instance = operation(fileobj) diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 5361506..51cef3d 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -390,10 +390,14 @@ def operation(fd: IO[bytes]) -> cls: else: filething, fileinfo = probe_qmcv1(filething_or_info) - if not isinstance(fileinfo, QMCv1FileInfo): + if fileinfo is None: raise CrypterCreatingError( f"{repr(filething)} is not a QMCv1 file" ) + elif not isinstance(fileinfo, QMCv1FileInfo): + raise TypeError( + f"second element of the tuple must be QMCv1FileInfo or None, not {type(fileinfo).__name__}" + ) if isfilepath(filething): with open(filething, mode='rb') as fileobj: @@ -933,10 +937,14 @@ def operation(fd: IO[bytes]) -> cls: else: filething, fileinfo = probe_qmcv2(filething_or_info) - if not isinstance(fileinfo, QMCv2FileInfo): + if fileinfo is None: raise CrypterCreatingError( f"{repr(filething)} is not a QMCv2 file" ) + elif not isinstance(fileinfo, QMCv2FileInfo): + raise TypeError( + f"second element of the tuple must be QMCv2FileInfo or None, not {type(fileinfo).__name__}" + ) if isfilepath(filething): with open(filething, mode='rb') as fileobj: From 223aa61c6968ac4d5bc5988e34574fea55abba08 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Wed, 4 Jan 2023 09:08:18 +0800 Subject: [PATCH 43/58] Say Hello to 2023! --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 91775a0..c045274 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 nukemiko +Copyright (c) 2023 nukemiko Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 82718c4fa276d98f3a51887d423b4a8fcd10218e Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sat, 7 Jan 2023 00:06:58 +0800 Subject: [PATCH 44/58] =?UTF-8?q?`KeyStreamBasedStreamCipherProto`?= =?UTF-8?q?=EF=BC=9A=E4=B8=BA=20`keystream()`=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=86=20`operation`=20=E5=8F=82=E6=95=B0=EF=BC=8C=E7=94=A8?= =?UTF-8?q?=E4=BA=8E=E6=A0=B9=E6=8D=AE=E7=89=B9=E5=AE=9A=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=AF=86=E9=92=A5=E6=B5=81=EF=BC=9B=E4=B8=BA?= =?UTF-8?q?=E6=89=80=E6=9C=89=E4=BB=A5=20`prexor`/`postxor`=20=E5=BC=80?= =?UTF-8?q?=E5=A4=B4=E7=9A=84=E5=8F=AF=E9=80=89=E6=96=B9=E6=B3=95=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BA=86=20`offset`=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kwm/kwmdataciphers.py | 8 +- src/libtakiyasha/prototypes.py | 101 +++++++++++++++---------- src/libtakiyasha/qmc/qmcdataciphers.py | 14 +++- src/libtakiyasha/stdciphers.py | 8 +- src/libtakiyasha/typedefs.py | 8 +- 5 files changed, 91 insertions(+), 48 deletions(-) diff --git a/src/libtakiyasha/kwm/kwmdataciphers.py b/src/libtakiyasha/kwm/kwmdataciphers.py index cdc69d4..6d9304c 100644 --- a/src/libtakiyasha/kwm/kwmdataciphers.py +++ b/src/libtakiyasha/kwm/kwmdataciphers.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations -from typing import Generator +from typing import Generator, Literal from ..miscutils import bytestrxor from ..prototypes import KeyStreamBasedStreamCipherSkel @@ -50,7 +50,11 @@ def cls_keystream(cls, mask32: BytesLike, nbytes: IntegerLike, offset: IntegerLi yield from maskblk_data yield from maskblk_data[:target_after_maskblk_area_len] - def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + def keystream(self, + operation: Literal['encrypt', 'decrypt'], + nbytes: IntegerLike, + offset: IntegerLike, / + ) -> Generator[int, None, None]: yield from self.cls_keystream(self._mask32, nbytes, offset) diff --git a/src/libtakiyasha/prototypes.py b/src/libtakiyasha/prototypes.py index 93c6e48..b41c7d2 100644 --- a/src/libtakiyasha/prototypes.py +++ b/src/libtakiyasha/prototypes.py @@ -63,7 +63,7 @@ class KeyStreamBasedStreamCipherSkel(metaclass=ABCMeta): - ``prexor_decrypt()`` - 解密前对密文的预处理,被 ``decrypt()`` 使用 - ``postxor_decrypt()`` - 解密后对明文的后处理,被 ``decrypt()`` 使用 - 以上可选方法的实现必须接受一个类字节对象,并返回一个由整数组成的可迭代对象。 + 以上可选方法的实现必须接受一个类字节对象和一个整数,并返回一个由整数组成的可迭代对象。 """ @abstractmethod @@ -71,11 +71,16 @@ def getkey(self, keyname: str = 'master') -> bytes | None: raise NotImplementedError @abstractmethod - def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + def keystream(self, + operation: Literal['encrypt', 'decrypt'], + nbytes: IntegerLike, + offset: IntegerLike, / + ) -> Generator[int, None, None]: """返回一个生成器对象,对其进行迭代,即可得到从起始点 ``offset`` 开始,持续一定长度 ``nbytes`` 的密钥流。 Args: + operation: 针对特定的操作生成密钥流(加密 encrypt 和解密 decrypt) offset: 密钥流的起始点,不应为负数 nbytes: 密钥流的长度,不应为负数 """ @@ -91,17 +96,17 @@ def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: plaindata = tobytes(plaindata) offset = toint(offset) - prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'prexor_encrypt', None) - postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'postxor_encrypt', None) - keystream = self.keystream(len(plaindata), offset) + prexor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self, 'prexor_encrypt', None) + postxor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self, 'postxor_encrypt', None) + keystream = self.keystream('encrypt', len(plaindata), offset) if prexor: - pd_strm = prexor(plaindata) + pd_strm = prexor(plaindata, offset) else: pd_strm = plaindata cd_noxor_strm = (pd_byte ^ ks_byte for pd_byte, ks_byte in zip(pd_strm, keystream)) if postxor: - cd_strm = postxor(cd_noxor_strm) + cd_strm = postxor(cd_noxor_strm, offset) else: cd_strm = cd_noxor_strm @@ -119,7 +124,7 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'prexor_decrypt', None) postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'postxor_decrypt', None) - keystream = self.keystream(len(cipherdata), offset) + keystream = self.keystream('decrypt', len(cipherdata), offset) if prexor: cd_strm = prexor(cipherdata) @@ -652,16 +657,28 @@ def ITER_WITHOUT_CRYPTLAYER(self, value: bool) -> None: @classmethod def verify_stream_cipher(cls, cipher: KeyStreamBasedStreamCipherProto | StreamCipherProto - ) -> tuple[bool, bool, bool]: - keystream_available = True + ) -> tuple[bool, bool, bool, bool]: + keystream_encrypt_available = True + keystream_decrypt_available = True encrypt_available = True decrypt_available = True # 验证 keystream() if isinstance(cipher, KeyStreamBasedStreamCipherProto): try: - ks = cipher.keystream(randint(0, 255), randint(0, 8191)) + ks = cipher.keystream('encrypt', randint(0, 255), randint(0, 8191)) except NotImplementedError: - keystream_available = False + keystream_encrypt_available = False + except Exception as exc: + raise TypeError(f"{repr(cipher)} is not a valid cipher object") from exc + else: + if not all(map(lambda _: isinstance(_, int), ks)): + raise TypeError( + f"result of {repr(cipher)}.keystream() returns non-int during iterating" + ) + try: + ks = cipher.keystream('decrypt', randint(0, 255), randint(0, 8191)) + except NotImplementedError: + keystream_decrypt_available = False except Exception as exc: raise TypeError(f"{repr(cipher)} is not a valid cipher object") from exc else: @@ -670,7 +687,8 @@ def verify_stream_cipher(cls, f"result of {repr(cipher)}.keystream() returns non-int during iterating" ) elif isinstance(cipher, StreamCipherProto): - keystream_available = False + keystream_encrypt_available = False + keystream_decrypt_available = False else: raise TypeError(f"{repr(cipher)} is not a cipher object") @@ -698,7 +716,7 @@ def verify_stream_cipher(cls, f"{repr(cipher)}.decrypt() returns non-bytes (type {type(decrypt_result).__name__})" ) - return encrypt_available, decrypt_available, keystream_available + return encrypt_available, decrypt_available, keystream_encrypt_available, keystream_decrypt_available @property def acceptable_ciphers(self) -> list[Type[StreamCipherProto] | Type[KeyStreamBasedStreamCipherProto]]: @@ -712,7 +730,7 @@ def __init__(self, initial_bytes: BytesLike = b'' ) -> None: super().__init__(tobytes(initial_bytes)) - self._encrypt_available, self._decrypt_available, self._keystream_available = self.verify_stream_cipher(cipher) + self._encrypt_available, self._decrypt_available, self._keystream_encrypt_available, self._keystream_decrypt_available = self.verify_stream_cipher(cipher) self._cipher = cipher if self.acceptable_ciphers: if not isinstance(self._cipher, tuple(self.acceptable_ciphers)): @@ -735,36 +753,41 @@ def __init__(self, self._ITER_WITHOUT_CRYPTLAYER = False def _iterencrypt(self, plaindata: bytes, offset: int, /) -> Generator[int, None, None]: - encrypt = self._cipher.encrypt - iterprexor_plaindata: Callable[[bytes], Iterator[int]] | None = getattr(self, '_iterprexor_plaindata', None) - - if not self._keystream_available: - yield from encrypt(plaindata, offset) - elif iterprexor_plaindata: - keystream = self._cipher.keystream - for prexor_pb_byte, ks_byte in zip(iterprexor_plaindata(plaindata), keystream(len(plaindata), offset)): - yield prexor_pb_byte ^ ks_byte + if self._keystream_encrypt_available: + prexor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self._cipher, 'prexor_encrypt', None) + postxor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self._cipher, 'postxor_encrypt', None) + keystream = self._cipher.keystream('encrypt', len(plaindata), offset) + + if prexor: + pd_strm = prexor(plaindata, offset) + else: + pd_strm = plaindata + cd_noxor_strm = (pd_byte ^ ks_byte for pd_byte, ks_byte in zip(pd_strm, keystream)) + if postxor: + cd_strm = postxor(cd_noxor_strm, offset) + else: + cd_strm = cd_noxor_strm else: - keystream = self._cipher.keystream - for pd_byte, ks_byte in zip(plaindata, keystream(len(plaindata), offset)): - yield pd_byte ^ ks_byte + cd_strm = self._cipher.encrypt(plaindata, offset) + + yield from cd_strm def _iterdecrypt(self, cipherdata: bytes, offset: int, /) -> Generator[int, None, None]: if not cipherdata: return - if self._keystream_available: - prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self._cipher, 'prexor_decrypt', None) - postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self._cipher, 'postxor_decrypt', None) - keystream = self._cipher.keystream(len(cipherdata), offset) + if self._keystream_decrypt_available: + prexor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self._cipher, 'prexor_decrypt', None) + postxor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self._cipher, 'postxor_decrypt', None) + keystream = self._cipher.keystream('decrypt', len(cipherdata), offset) if prexor: - cd_strm = prexor(cipherdata) + cd_strm = prexor(cipherdata, offset) else: cd_strm = cipherdata pd_noxor_strm = (cd_byte ^ ks_byte for cd_byte, ks_byte in zip(cd_strm, keystream)) if postxor: - pd_strm = postxor(pd_noxor_strm) + pd_strm = postxor(pd_noxor_strm, offset) else: pd_strm = pd_noxor_strm else: @@ -776,18 +799,18 @@ def _iterdecrypt_untilnl(self, cipherdata: bytes, offset: int, /) -> Generator[i if not cipherdata: return - if self._keystream_available: - prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self._cipher, 'prexor_decrypt', None) - postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self._cipher, 'postxor_decrypt', None) - keystream = self._cipher.keystream(len(cipherdata), offset) + if self._keystream_decrypt_available: + prexor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self._cipher, 'prexor_decrypt', None) + postxor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self._cipher, 'postxor_decrypt', None) + keystream = self._cipher.keystream('decrypt', len(cipherdata), offset) if prexor: - cd_strm = prexor(cipherdata) + cd_strm = prexor(cipherdata, offset) else: cd_strm = cipherdata pd_noxor_strm = (cd_byte ^ ks_byte for cd_byte, ks_byte in zip(cd_strm, keystream)) if postxor: - pd_strm = postxor(pd_noxor_strm) + pd_strm = postxor(pd_noxor_strm, offset) else: pd_strm = pd_noxor_strm else: diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index a3711e4..1bd509c 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -2,7 +2,7 @@ from __future__ import annotations from functools import lru_cache -from typing import Generator +from typing import Generator, Literal from .qmcconsts import KEY256_MAPPING from ..prototypes import KeyStreamBasedStreamCipherSkel @@ -73,7 +73,11 @@ def cls_keystream(cls, yield from commonblk_data yield from commonblk_data[:target_after_commonblk_area_len] - def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + def keystream(self, + operation: Literal['encrypt', 'decrypt'], + nbytes: IntegerLike, + offset: IntegerLike, / + ) -> Generator[int, None, None]: yield from self.cls_keystream(self._mask128, nbytes, offset) @classmethod @@ -210,7 +214,11 @@ def _yield_common_segment_keystream(self, if i >= 0: yield box[(box[j] + box[k]) % key_len] - def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + def keystream(self, + operation: Literal['encrypt', 'decrypt'], + nbytes: IntegerLike, + offset: IntegerLike, / + ) -> Generator[int, None, None]: pending = toint(nbytes) done = 0 offset = toint(offset) diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index 63f772b..2ff46c3 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -8,7 +8,7 @@ import io except ImportError: import _pyio as io -from typing import Generator +from typing import Generator, Literal from pyaes import AESModeOfOperationECB from pyaes.util import append_PKCS7_padding, strip_PKCS7_padding @@ -390,7 +390,11 @@ def getkey(self, keyname: str = 'master') -> bytes: if keyname == 'master': return self._key - def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: + def keystream(self, + operation: Literal['encrypt', 'decrypt'], + nbytes: IntegerLike, + offset: IntegerLike, / + ) -> Generator[int, None, None]: offset = toint(offset) nbytes = toint(nbytes) if offset < 0: diff --git a/src/libtakiyasha/typedefs.py b/src/libtakiyasha/typedefs.py index f6302d7..60136a5 100644 --- a/src/libtakiyasha/typedefs.py +++ b/src/libtakiyasha/typedefs.py @@ -4,7 +4,7 @@ import array import mmap from os import PathLike -from typing import ByteString, Iterable, Iterator, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable +from typing import ByteString, Iterable, Iterator, Literal, Protocol, Sequence, SupportsBytes, SupportsIndex, SupportsInt, TypeVar, Union, runtime_checkable __all__ = [ 'T', @@ -69,7 +69,11 @@ class KeyStreamBasedStreamCipherProto(Protocol): def getkey(self, keyname: str = 'master') -> bytes | None: raise NotImplementedError - def keystream(self, nbytes: IntegerLike, offset: IntegerLike = 0, /) -> Iterator[int]: + def keystream(self, + operation: Literal['encrypt', 'decrypt'], + nbytes: IntegerLike, + offset: IntegerLike = 0, / + ) -> Iterator[int]: raise NotImplementedError def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: From 2c46af7b08ae00c261213ae88de47612624da954 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Sat, 7 Jan 2023 11:40:31 +0800 Subject: [PATCH 45/58] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=2082718c4fa276d98f3a51887d423b4a8fcd10218e=20?= =?UTF-8?q?=E9=81=97=E6=BC=8F=E7=9A=84=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kwm/kwmdataciphers.py | 4 ++-- src/libtakiyasha/qmc/qmcdataciphers.py | 8 ++++---- src/libtakiyasha/stdciphers.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/libtakiyasha/kwm/kwmdataciphers.py b/src/libtakiyasha/kwm/kwmdataciphers.py index 6d9304c..f646788 100644 --- a/src/libtakiyasha/kwm/kwmdataciphers.py +++ b/src/libtakiyasha/kwm/kwmdataciphers.py @@ -26,9 +26,9 @@ def cls_keystream(cls, mask32: BytesLike, nbytes: IntegerLike, offset: IntegerLi offset = toint(offset) nbytes = toint(nbytes) if offset < 0: - raise ValueError("second argument 'offset' must be a non-negative integer") + raise ValueError("third argument 'offset' must be a non-negative integer") if nbytes < 0: - raise ValueError("first argument 'nbytes' must be a non-negative integer") + raise ValueError("second argument 'nbytes' must be a non-negative integer") maskblk_data: bytes = tobytes(mask32) maskblk_len = len(maskblk_data) if maskblk_len != 32: diff --git a/src/libtakiyasha/qmc/qmcdataciphers.py b/src/libtakiyasha/qmc/qmcdataciphers.py index 1bd509c..69e644a 100644 --- a/src/libtakiyasha/qmc/qmcdataciphers.py +++ b/src/libtakiyasha/qmc/qmcdataciphers.py @@ -34,9 +34,9 @@ def cls_keystream(cls, nbytes = toint(nbytes) offset = toint(offset) if offset < 0: - raise ValueError("second argument 'offset' must be a non-negative integer") + raise ValueError("third argument 'offset' must be a non-negative integer") if nbytes < 0: - raise ValueError("first argument 'nbytes' must be a non-negative integer") + raise ValueError("second argument 'nbytes' must be a non-negative integer") firstblk_data = mask * 256 # 前 32768 字节 secondblk_data = firstblk_data[1:-1] # 第 32769 至 65535 字节 @@ -223,9 +223,9 @@ def keystream(self, done = 0 offset = toint(offset) if offset < 0: - raise ValueError("second argument 'offset' must be a non-negative integer") + raise ValueError("third argument 'offset' must be a non-negative integer") if pending < 0: - raise ValueError("first argument 'nbytes' must be a non-negative integer") + raise ValueError("second argument 'nbytes' must be a non-negative integer") def mark(p: int) -> None: nonlocal pending, done, offset diff --git a/src/libtakiyasha/stdciphers.py b/src/libtakiyasha/stdciphers.py index 2ff46c3..8422a65 100644 --- a/src/libtakiyasha/stdciphers.py +++ b/src/libtakiyasha/stdciphers.py @@ -398,9 +398,9 @@ def keystream(self, offset = toint(offset) nbytes = toint(nbytes) if offset < 0: - raise ValueError("second argument 'offset' must be a non-negative integer") + raise ValueError("third argument 'offset' must be a non-negative integer") if nbytes < 0: - raise ValueError("first argument 'nbytes' must be a non-negative integer") + raise ValueError("second argument 'nbytes' must be a non-negative integer") for i in range(offset, offset + nbytes): yield self._meta_keystream[i % 256] From 4b7d06da07c462ef7e10089e4f9fd9f2b44513cb Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 12 Jan 2023 16:59:28 +0800 Subject: [PATCH 46/58] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=20`KeyStreamB?= =?UTF-8?q?asedStreamCipherSkel.decrypt()`=20=E8=B0=83=E7=94=A8=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E5=8F=AF=E9=80=89=E6=96=B9=E6=B3=95=E6=97=B6=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=E5=8F=82=E6=95=B0=EF=BC=8C=E5=AF=BC=E8=87=B4=20TypeEr?= =?UTF-8?q?ror=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/prototypes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libtakiyasha/prototypes.py b/src/libtakiyasha/prototypes.py index b41c7d2..1aadb61 100644 --- a/src/libtakiyasha/prototypes.py +++ b/src/libtakiyasha/prototypes.py @@ -122,17 +122,17 @@ def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: cipherdata = tobytes(cipherdata) offset = toint(offset) - prexor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'prexor_decrypt', None) - postxor: Callable[[BytesLike], Iterator[int]] | None = getattr(self, 'postxor_decrypt', None) + prexor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self, 'prexor_decrypt', None) + postxor: Callable[[BytesLike, IntegerLike], Iterator[int]] | None = getattr(self, 'postxor_decrypt', None) keystream = self.keystream('decrypt', len(cipherdata), offset) if prexor: - cd_strm = prexor(cipherdata) + cd_strm = prexor(cipherdata, offset) else: cd_strm = cipherdata pd_noxor_strm = (cd_byte ^ ks_byte for cd_byte, ks_byte in zip(cd_strm, keystream)) if postxor: - pd_strm = postxor(pd_noxor_strm) + pd_strm = postxor(pd_noxor_strm, offset) else: pd_strm = pd_noxor_strm From c18d79f647b809175e39409ea279589b82b3fc8d Mon Sep 17 00:00:00 2001 From: nukemiko Date: Thu, 12 Jan 2023 17:40:49 +0800 Subject: [PATCH 47/58] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E5=AF=B9=20KG?= =?UTF-8?q?M/VPR=20=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=E7=9A=84=E6=94=B9?= =?UTF-8?q?=E9=80=A0=EF=BC=9B=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E6=80=A7=E7=9A=84=20KGM/VPR=20=E6=96=87=E4=BB=B6=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kgmvpr/__init__.py | 346 ++++++++++--------- src/libtakiyasha/kgmvpr/kgmvprdataciphers.py | 179 ++++++---- src/libtakiyasha/kgmvpr/kgmvprmaskutils.py | 43 --- 3 files changed, 287 insertions(+), 281 deletions(-) delete mode 100644 src/libtakiyasha/kgmvpr/kgmvprmaskutils.py diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index ace5a49..78a761f 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -1,110 +1,84 @@ # -*- coding: utf-8 -*- from __future__ import annotations -from typing import IO, Literal +from pathlib import Path +from typing import IO, NamedTuple -from .kgmvprdataciphers import KGMorVPREncryptAlgorithm -from ..exceptions import CrypterCreatingError -from ..keyutils import make_salt -from ..prototypes import CryptLayerWrappedIOSkel -from ..typedefs import BytesLike, FilePath +from .kgmvprdataciphers import KGMCryptoLegacy +from ..exceptions import CrypterCreatingError, CrypterSavingError +from ..prototypes import EncryptedBytesIOSkel +from ..typedefs import BytesLike, FilePath, KeyStreamBasedStreamCipherProto, StreamCipherProto from ..typeutils import isfilepath, tobytes, verify_fileobj -__all__ = ['KGMorVPR'] - - -class KGMorVPR(CryptLayerWrappedIOSkel): - """基于 BytesIO 的 KGM/VPR 透明加密二进制流。 - - 所有读写相关方法都会经过透明加密层处理: - 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 - - 调用读写相关方法时,附加参数 ``nocryptlayer=True`` - 可绕过透明加密层,访问缓冲区内的原始加密数据。 - - 如果你要新建一个 KGMorVPR 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``KGMorVPR.new()`` 和 ``KGMorVPR.from_file()`` 新建或打开已有 KGM 或 VPR 文件。 - - 已有 KGMorVPR 对象的 ``self.to_file()`` 方法可用于将对象内数据保存到文件,但目前尚未实现。 - 尝试调用此方法会触发 ``NotImplementedError``。 - """ - - @property - def cipher(self) -> KGMorVPREncryptAlgorithm: - return self._cipher - - @property - def master_key(self) -> bytes: - return self.cipher.master_key - - @property - def vpr_key(self) -> bytes | None: - return self._cipher.vpr_key +class KGMorVPRFileInfo(NamedTuple): + cipher_data_offset: int + cipher_data_len: int + encryption_version: int + core_key_slot: int + core_key_test_data: bytes + master_key: bytes | None + is_vpr: bool + + +def probe(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], KGMorVPRFileInfo | None]: + def operation(fd: IO[bytes]) -> KGMorVPRFileInfo | None: + total_size = fd.seek(0, 2) + if total_size < 60: + return + fd.seek(0, 0) + + header = fd.read(16) + if header == b'\x05\x28\xbc\x96\xe9\xe4\x5a\x43\x91\xaa\xbd\xd0\x7a\xf5\x36\x31': + is_vpr = True + elif header == b'\x7c\xd5\x32\xeb\x86\x02\x7f\x4b\xa8\xaf\xa6\x8e\x0f\xff\x99\x14': + is_vpr = False + else: + return + + cipher_data_offset = int.from_bytes(fd.read(4), 'little') + encryption_version = int.from_bytes(fd.read(4), 'little') + core_key_slot = int.from_bytes(fd.read(4), 'little') + core_key_test_data = fd.read(16) + master_key = fd.read(16) + + return KGMorVPRFileInfo( + cipher_data_offset=cipher_data_offset, + cipher_data_len=total_size - cipher_data_offset, + encryption_version=encryption_version, + core_key_slot=core_key_slot, + core_key_test_data=core_key_test_data, + master_key=master_key, + is_vpr=is_vpr + ) + + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + return Path(filething), operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_origpos = fileobj.tell() + prs = operation(fileobj) + fileobj.seek(fileobj_origpos, 0) + + return fileobj, prs + + +class KGMorVPR(EncryptedBytesIOSkel): @property - def subtype(self): - return 'KGM' if self.vpr_key is None else 'VPR' - - def __init__(self, cipher: KGMorVPREncryptAlgorithm, /, initial_bytes: BytesLike = b'') -> None: - """基于 BytesIO 的 KGM/VPR 透明加密二进制流。 - - 所有读写相关方法都会经过透明加密层处理: - 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + def acceptable_ciphers(self): + return [KGMCryptoLegacy] - 调用读写相关方法时,附加参数 ``nocryptlayer=True`` - 可绕过透明加密层,访问缓冲区内的原始加密数据。 - - 如果你要新建一个 KGMorVPR 对象,不要直接调用 ``__init__()``,而是使用构造器方法 - ``KGMorVPR.new()`` 和 ``KGMorVPR.from_file()`` 新建或打开已有 KGM 或 VPR 文件。 - - 已有 KGMorVPR 对象的 ``self.to_file()`` 方法可用于将对象内数据保存到文件,但目前尚未实现。 - 尝试调用此方法会触发 ``NotImplementedError``。 - """ + def __init__(self, + cipher: StreamCipherProto | KeyStreamBasedStreamCipherProto, /, + initial_bytes: BytesLike = b'' + ): super().__init__(cipher, initial_bytes) - if not isinstance(cipher, KGMorVPREncryptAlgorithm): - raise TypeError('unsupported Cipher: ' - f'supports {KGMorVPREncryptAlgorithm.__module__}.{KGMorVPREncryptAlgorithm.__name__}, ' - f'not {type(cipher).__name__}' - ) - - @classmethod - def new(cls, subtype: Literal['kgm', 'vpr'], /, - table1: BytesLike, - table2: BytesLike, - tablev2: BytesLike, - vpr_key: BytesLike = None - ) -> KGMorVPR: - """创建并返回一个全新的空 KGMorVPR 对象。 - - 第一个位置参数 ``subtype`` 决定此 KGMorVPR 对象的透明加密层使用哪种加密算法, - 仅支持 ``'kgm'`` 和 ``'vpr'``。 - - 参数 ``table1``、``table2``、``tablev2`` 都是必选参数, - 因为它们会参与到内置透明加密层的创建过程中,并且在加密/解密过程中发挥关键作用。 - 这三个参数的都必须是类字节对象,且转换为 ``bytes`` 后,长度为 272 字节。 - - 如果你选择 ``subtype='vpr'``,那么参数 ``vpr_key`` 是必选的:必须是类字节对象,且转换为 ``bytes`` - 后的长度为 17 字节。 - """ - table1 = tobytes(table1) - table2 = tobytes(table2) - tablev2 = tobytes(tablev2) - if vpr_key is not None: - vpr_key = tobytes(vpr_key) - if subtype == 'vpr': - if vpr_key is None: - raise ValueError("argument 'vpr_key' is required for VPR subtype") - else: - vpr_key = tobytes(vpr_key) - elif subtype != 'kgm': - if isinstance(subtype, str): - raise ValueError(f"argument 'subtype' must be 'kgm' or 'vpr', not {subtype}") - else: - raise TypeError(f"argument 'subtype' must be str, not {type(subtype).__name__}") - - master_key = make_salt(16) + b'\x00' - return cls(KGMorVPREncryptAlgorithm(table1, table2, tablev2, master_key, vpr_key)) + self._source_file_header_data: bytes | None = None @classmethod def from_file(cls, @@ -113,84 +87,128 @@ def from_file(cls, table2: BytesLike, tablev2: BytesLike, vpr_key: BytesLike = None - ) -> KGMorVPR: - """打开一个 KGMorVPR 文件或文件对象 ``kgm_vpr_filething``。 - - 第一个位置参数 ``kgm_vpr_filething`` 可以是文件路径(``str``、``bytes`` - 或任何拥有方法 ``__fspath__()`` 的对象)。``kgm_vpr_filething`` - 也可以是一个文件对象,但必须可读、可跳转(``kgm_vpr_filething.seekable() == True``)。 - - 参数 ``table1``、``table2``、``tablev2`` 都是必选参数, - 因为它们会参与到内置透明加密层的创建过程中,并且在加密/解密过程中发挥关键作用。 - 这三个参数的都必须是类字节对象,且转换为 ``bytes`` 后,长度为 272 字节。 - - 本方法会寻找文件内嵌主密钥的位置和加密方式,进而判断所用加密算法的类型。 - - 如果探测到 ``VPR`` 文件,那么参数 ``vpr_key`` 是必选的:必须是类字节对象,且转换为 ``bytes`` - 后的长度为 17 字节。 - """ - - def operation(fileobj: IO[bytes]) -> KGMorVPR: - fileobj_endpos = fileobj.seek(0, 2) - fileobj.seek(0, 0) - magicheader = fileobj.read(16) - if magicheader == b'\x05\x28\xbc\x96\xe9\xe4\x5a\x43\x91\xaa\xbd\xd0\x7a\xf5\x36\x31': - subtype: Literal['kgm', 'vpr'] = 'vpr' - if vpr_key is None: - raise ValueError( - f"{repr(kgm_vpr_filething)} is a VPR file, but argument 'vpr_key' is missing" - ) - elif magicheader == b'\x7c\xd5\x32\xeb\x86\x02\x7f\x4b\xa8\xaf\xa6\x8e\x0f\xff\x99\x14': - subtype: Literal['kgm', 'vpr'] = 'kgm' - else: - raise ValueError(f"{repr(kgm_vpr_filething)} is not a KGM or VPR file") - header_len = int.from_bytes(fileobj.read(4), 'little') - if header_len > fileobj_endpos: - raise CrypterCreatingError( - f"{repr(kgm_vpr_filething)} is not a valid {subtype.upper()} file: " - f"header length ({header_len}) is greater than file size ({fileobj_endpos})" - ) - fileobj.seek(28, 0) - master_key = fileobj.read(16) + b'\x00' - fileobj.seek(header_len, 0) - - initial_bytes = fileobj.read() - - cipher = KGMorVPREncryptAlgorithm(table1, table2, tablev2, master_key, vpr_key) - return cls(cipher, initial_bytes) + ): + return cls.open(kgm_vpr_filething, + table1=table1, + table2=table2, + tablev2=tablev2, + vpr_key=vpr_key + ) + @classmethod + def open(cls, + filething_or_info: tuple[Path | IO[bytes]] | FilePath | IO[bytes], /, + table1: BytesLike, + table2: BytesLike, + tablev2: BytesLike, + vpr_key: BytesLike = None + ): + # if table1 is not None: + # table1 = tobytes(table1) + # if table2 is not None: + # table2 = tobytes(table2) + # if tablev2 is not None: + # tablev2 = tobytes(tablev2) + # if vpr_key is not None: + # vpr_key = tobytes(vpr_key) table1 = tobytes(table1) table2 = tobytes(table2) tablev2 = tobytes(tablev2) if vpr_key is not None: vpr_key = tobytes(vpr_key) - if isfilepath(kgm_vpr_filething): - with open(kgm_vpr_filething, mode='rb') as kgm_vpr_fileobj: - instance = operation(kgm_vpr_fileobj) + def operation(fd: IO[bytes]) -> cls: + if fileinfo.encryption_version != 3: + raise CrypterCreatingError( + f'unsupported KGM encryption version {fileinfo.encryption_version} ' + f'(only version 3 is supported)' + ) + if fileinfo.is_vpr and vpr_key is None: + raise TypeError( + "argument 'vpr_key' is required for encrypt and decrypt VPR file" + ) + cipher = KGMCryptoLegacy(table1, + table2, + tablev2, + fileinfo.core_key_test_data + b'\x00', + vpr_key + ) + + fd.seek(fileinfo.cipher_data_offset, 0) + + inst = cls(cipher, fd.read(fileinfo.cipher_data_len)) + fd.seek(0, 0) + inst._source_file_header_data = fd.read(fileinfo.cipher_data_offset) + return inst + + if isinstance(filething_or_info, tuple): + filething_or_info: tuple[Path | IO[bytes], KGMorVPRFileInfo | None] + if len(filething_or_info) != 2: + raise TypeError( + "first argument 'filething_or_info' must be a file path, a file object, " + "or a tuple of probe() returns" + ) + filething, fileinfo = filething_or_info + else: + filething, fileinfo = probe(filething_or_info) + + if fileinfo is None: + raise CrypterCreatingError( + f"{repr(filething)} is not a KGM or VPR file" + ) + elif not isinstance(fileinfo, KGMorVPRFileInfo): + raise TypeError( + f"second element of the tuple must be KGMorVPRFileInfo or None, not {type(fileinfo).__name__}" + ) + + if isfilepath(filething): + with open(filething, mode='rb') as fileobj: + instance = operation(fileobj) + instance._name = Path(filething) else: - kgm_vpr_fileobj = verify_fileobj(kgm_vpr_filething, 'binary', - verify_readable=True, - verify_seekable=True - ) - instance = operation(kgm_vpr_fileobj) + fileobj = verify_fileobj(filething, 'binary', + verify_readable=True, + verify_seekable=True + ) + fileobj_sourcefile = getattr(fileobj, 'name', None) + instance = operation(fileobj) - instance._name = getattr(kgm_vpr_fileobj, 'name', None) + if fileobj_sourcefile is not None: + instance._name = Path(fileobj_sourcefile) return instance - def to_file(self, kgm_vpr_filething: FilePath | IO[bytes], /, **kwargs) -> None: - """警告:尚未完全探明 KGM/VPR 文件的结构,因此本方法尚未实现,尝试调用会触发 - ``NotImplementedError``。预计的参数和行为如下: - - 将当前 KGMorVPR 对象的内容保存到文件 ``kgm_vpr_filething``。 - - 第一个位置参数 ``kgm_vpr_filething`` 可以是文件路径(``str``、``bytes`` - 或任何拥有方法 ``__fspath__()`` 的对象)。``kgm_vpr_filething`` - 也可以是一个文件对象,但必须可写。 + def to_file(self, kgm_vpr_filething: FilePath | IO[bytes] = None) -> None: + return self.save(kgm_vpr_filething) + + def save(self, + filething: FilePath | IO[bytes] = None + ) -> None: + def operation(fd: IO[bytes]) -> None: + if self._source_file_header_data is None: + raise CrypterSavingError( + f"cannot save current {type(self).__name__} object to file '{str(filething)}', " + f"because it's not open from KGM or VPR file" + ) + fd.seek(0, 0) + fd.write(self._source_file_header_data) + while blk := self.read(self.DEFAULT_BUFFER_SIZE, nocryptlayer=True): + fd.write(blk) + + if filething is None: + if self.source is None: + raise TypeError( + "attribute 'self.source' and argument 'filething' are empty, " + "don't know which file to save to" + ) + filething = self.source - 本方法会首先尝试写入 ``kgm_vpr_filething`` 指向的文件。 - 如果未提供 ``kgm_vpr_filething``,则会尝试写入 ``self.name`` - 指向的文件。如果两者都为空或未提供,则会触发 ``CrypterSavingError``。 - """ - raise NotImplementedError('coming soon') + if isfilepath(filething): + with open(filething, mode='wb') as fileobj: + return operation(fileobj) + else: + fileobj = verify_fileobj(filething, 'binary', + verify_seekable=True, + verify_writable=True + ) + return operation(fileobj) diff --git a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py index a2f5584..c3c5550 100644 --- a/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py +++ b/src/libtakiyasha/kgmvpr/kgmvprdataciphers.py @@ -1,23 +1,28 @@ # -*- coding: utf-8 -*- from __future__ import annotations -from typing import Generator, TypedDict +from hashlib import md5 +from typing import Generator, Literal -from .kgmvprmaskutils import make_maskstream, xor_half_lower_byte from ..prototypes import KeyStreamBasedStreamCipherSkel from ..typedefs import BytesLike, IntegerLike from ..typeutils import CachedClassInstanceProperty, tobytes, toint -__all__ = ['KGMorVPRTables', 'KGMorVPREncryptAlgorithm'] +__all__ = ['KGMCryptoLegacy'] -class KGMorVPRTables(TypedDict): - table1: bytes - table2: bytes - tablev2: bytes +def kugou_md5sum(data: bytes, /) -> bytes: + md5sum = md5(data) + md5digest = md5sum.digest() + ret = bytearray(md5sum.digest_size) + for i in range(0, md5sum.digest_size, 2): + ret[i] = md5digest[14 - i] + ret[i + 1] = md5digest[14 + 1 - i] + return bytes(ret) -class KGMorVPREncryptAlgorithm(KeyStreamBasedStreamCipherSkel): + +class KGMCryptoLegacy(KeyStreamBasedStreamCipherSkel): @CachedClassInstanceProperty def keysize(self) -> int: return 17 @@ -26,46 +31,34 @@ def keysize(self) -> int: def tablesize(self) -> int: return 17 * 16 - @property - def master_key(self) -> bytes: - return self._master_key - - @property - def vpr_key(self) -> bytes | None: - return self._vpr_key - - @property - def tables(self) -> KGMorVPRTables: - return { - 'table1' : self._table1, - 'table2' : self._table2, - 'tablev2': self._tablev2 - } - def __init__(self, table1: BytesLike, table2: BytesLike, tablev2: BytesLike, - master_key: BytesLike, /, + core_key_test_data: BytesLike, /, vpr_key: BytesLike = None, ) -> None: self._table1 = tobytes(table1) self._table2 = tobytes(table2) self._tablev2 = tobytes(tablev2) - for valname, val in [('table1', self._table1), - ('table2', self._table2), - ('tablev2', self._tablev2)]: + for idx, valname_val in enumerate([('table1', self._table1), + ('table2', self._table2), + ('tablev2', self._tablev2)], + start=1 + ): + valname, val = valname_val if len(val) != self.tablesize: raise ValueError( - f"invalid length of argument '{valname}': should be {self.tablesize}, not {len(val)}" + f"invalid length of position {idx} argument '{valname}': " + f"should be {self.tablesize}, not {len(val)}" ) - self._master_key = tobytes(master_key) - if len(self._master_key) != self.keysize: + self._core_key_test_data = tobytes(core_key_test_data) + if len(self._core_key_test_data) != self.keysize: raise ValueError( - f"invalid length of argument 'master_key': " - f"should be {self.keysize}, not {len(self._master_key)}" + f"invalid length of fourth argument 'core_key_test_data': " + f"should be {self.keysize}, not {len(self._core_key_test_data)}" ) if vpr_key is None: self._vpr_key = None @@ -77,51 +70,89 @@ def __init__(self, f"should be {self.keysize}, not {len(self._vpr_key)}" ) - def keystream(self, nbytes: IntegerLike, offset: IntegerLike, /) -> Generator[int, None, None]: - raise NotImplementedError - - def encrypt(self, plaindata: BytesLike, offset: IntegerLike = 0, /) -> bytes: - master_key = self._master_key - vpr_key: bytes | None = self._vpr_key - keysize = self.keysize - + def getkey(self, keyname: str = 'master') -> bytes | None: + if keyname == 'master': + return self._core_key_test_data + elif keyname == 'table1': + return self._table1 + elif keyname == 'table2': + return self._table2 + elif keyname == 'tablev2': + return self._tablev2 + elif keyname == 'vprkey': + return self._vpr_key + + def prexor_encrypt(self, data: BytesLike, offset: IntegerLike, /) -> Generator[int, None, None]: offset = toint(offset) - if offset < 0: - ValueError("second argument 'offset' must be a non-negative integer") - plaindata = tobytes(plaindata) - cipherdata_buf = bytearray(len(plaindata)) - - maskstream_iterator = make_maskstream( - offset, len(plaindata), self._table1, self._table2, self._tablev2 - ) - for idx, peered_byte in enumerate(zip(plaindata, maskstream_iterator)): - pdb, msb = peered_byte + vpr_key = self._vpr_key + keysize = self.keysize + for idx, byte in enumerate(data, start=offset): if vpr_key is not None: - pdb ^= vpr_key[(idx + offset) % keysize] - cdb = xor_half_lower_byte(pdb) ^ msb ^ master_key[(idx + offset) % keysize] - cipherdata_buf[idx] = cdb + byte ^= vpr_key[idx % keysize] + yield byte ^ ((byte % 16) << 4) - return tobytes(cipherdata_buf) + @staticmethod + def prexor_decrypt(data: BytesLike, offset: IntegerLike, /) -> Generator[int, None, None]: + offset = toint(offset) + for idx, byte in enumerate(data, start=offset): + yield byte ^ ((byte % 16) << 4) - def decrypt(self, cipherdata: BytesLike, offset: IntegerLike = 0, /) -> bytes: - master_key = self._master_key - vpr_key: bytes | None = self._vpr_key + def postxor_decrypt(self, data: BytesLike, offset: IntegerLike, /) -> Generator[int, None, None]: + offset = toint(offset) + vpr_key = self._vpr_key keysize = self.keysize - + for idx, byte in enumerate(data, start=offset): + if vpr_key is None: + yield byte + else: + yield byte ^ vpr_key[idx % keysize] + + def genmask(self, + nbytes: IntegerLike, + offset: IntegerLike, / + ) -> Generator[int, None, None]: + nbytes = toint(nbytes) offset = toint(offset) if offset < 0: - ValueError("second argument 'offset' must be a non-negative integer") - cipherdata = tobytes(cipherdata) - plaindata_buf = bytearray(len(cipherdata)) - - maskstream_iterator = make_maskstream( - offset, len(cipherdata), self._table1, self._table2, self._tablev2 - ) - for idx, peered_byte in enumerate(zip(cipherdata, maskstream_iterator)): - cdb, msb = peered_byte - pdb = xor_half_lower_byte(cdb ^ msb ^ master_key[(idx + offset) % keysize]) - if vpr_key is not None: - pdb ^= vpr_key[(idx + offset) % keysize] - plaindata_buf[idx] = pdb - - return tobytes(plaindata_buf) + raise ValueError("second argument 'offset' must be a non-negative integer") + if nbytes < 0: + raise ValueError("first argument 'nbytes' must be a non-negative integer") + + tablesize: int = self.tablesize + table1 = self._table1 + table2 = self._table2 + tablev2 = self._tablev2 + for idx in range(offset, offset + nbytes): + idx_urs4 = idx >> 4 + value = 0 + while idx_urs4 >= 17: + value ^= table1[idx_urs4 % tablesize] + idx_urs4 >>= 4 + value ^= table2[idx_urs4 % tablesize] + idx_urs4 >>= 4 + yield value ^ tablev2[idx % tablesize] + + def keystream(self, + operation: Literal['encrypt', 'decrypt'], + nbytes: IntegerLike, + offset: IntegerLike, / + ) -> Generator[int, None, None]: + ck_test_data = self._core_key_test_data + keysize: int = self.keysize + + mask_strm = self.genmask(nbytes, offset) + if operation == 'encrypt': + for idx, msb in enumerate(mask_strm, start=offset): + yield msb ^ ck_test_data[idx % keysize] + elif operation == 'decrypt': + for idx, msb in enumerate(mask_strm, start=offset): + msb ^= ck_test_data[idx % keysize] + yield msb ^ ((msb % 16) << 4) + elif isinstance(operation, str): + raise ValueError( + f"first argument 'operation' must be 'encrypt' or 'decrypt', not {operation}" + ) + else: + raise TypeError( + f"first argument 'operation' must be str, not {type(operation).__name__}" + ) diff --git a/src/libtakiyasha/kgmvpr/kgmvprmaskutils.py b/src/libtakiyasha/kgmvpr/kgmvprmaskutils.py deleted file mode 100644 index 7727b02..0000000 --- a/src/libtakiyasha/kgmvpr/kgmvprmaskutils.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations - -from typing import Generator - -from ..typedefs import BytesLike, IntegerLike -from ..typeutils import tobytes, toint - -__all__ = ['make_maskstream', 'xor_half_lower_byte'] - - -def xor_half_lower_byte(byte: int) -> int: - return byte ^ ((byte % 16) << 4) - - -def make_maskstream(offset: IntegerLike, - length: IntegerLike, /, - table1: BytesLike, - table2: BytesLike, - tablev2: BytesLike - ) -> Generator[int, None, None]: - offset = toint(offset) - length = toint(length) - if offset < 0: - raise ValueError("first argument 'offset' must be a non-negative integer") - if length < 0: - raise ValueError("second argument 'length' must be a non-negative integer") - table1 = tobytes(table1) - table2 = tobytes(table2) - tablev2 = tobytes(tablev2) - if not (len(table1) == len(table2) == len(tablev2)): - raise ValueError("argument 'table1', 'table2', 'tablev2' must have the same length") - tablesize = len(tablev2) - - for idx in range(offset, offset + length): - idx_urs4 = idx >> 4 - value = 0 - while idx_urs4 >= 17: - value ^= table1[idx_urs4 % tablesize] - idx_urs4 >>= 4 - value ^= table2[idx_urs4 % tablesize] - idx_urs4 >>= 4 - yield value ^ tablev2[idx % tablesize] From dafc6e832f789e84430289f0adbab4bcc55b1c20 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 13 Jan 2023 14:07:08 +0800 Subject: [PATCH 48/58] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BA=86=20`KWM`=20?= =?UTF-8?q?=E5=92=8C=20`QMCv2`=20=E7=9A=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kwm/__init__.py | 31 ++++++++++++++++++++++++++++++- src/libtakiyasha/qmc/__init__.py | 4 ++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index c31ff41..1c1b912 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -94,6 +94,19 @@ def operation(fd: IO[bytes]) -> KWMFileInfo | None: class KWM(EncryptedBytesIOSkel): + """基于 BytesIO 的 KWM 透明加密二进制流。 + + 所有读写相关方法都会经过透明加密层处理: + 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + + 调用读写相关方法时,附加参数 ``nocryptlayer=True`` + 可绕过透明加密层,访问缓冲区内的原始加密数据。 + + 如果你要新建一个 KWM 对象,不要直接调用 ``__init__()``,而是使用构造器方法 + ``KWM.new()`` 和 ``KWM.open()`` 新建或打开已有 KWM 文件, + 使用已有 KWM 对象的 ``save()`` 方法将其保存到文件。 + """ + @property def acceptable_ciphers(self): return [Mask32FromRecipe] @@ -101,7 +114,23 @@ def acceptable_ciphers(self): def __init__(self, cipher: StreamCipherProto | KeyStreamBasedStreamCipherProto, /, initial_bytes: BytesLike = b'' - ): + ) -> None: + """基于 BytesIO 的 KWM 透明加密二进制流。 + + 所有读写相关方法都会经过透明加密层处理: + 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + + 调用读写相关方法时,附加参数 ``nocryptlayer=True`` + 可绕过透明加密层,访问缓冲区内的原始加密数据。 + + 如果你要新建一个 KWM 对象,不要直接调用 ``__init__()``,而是使用构造器方法 + ``KWM.new()`` 和 ``KWM.open()`` 新建或打开已有 KWM 文件, + 使用已有 KWM 对象的 ``save()`` 方法将其保存到文件。 + + Args: + cipher: 要使用的 cipher,必须是一个 libtakiyasha.kwm.kwmdataciphers.Mask32/Mask32FromRecipe 对象 + initial_bytes: 内置缓冲区的初始数据 + """ super().__init__(cipher, initial_bytes=initial_bytes) self._bitrate: int | None = None diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 51cef3d..e0a5292 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -516,6 +516,10 @@ def __init__(self, cipher: HardenedRC4 | Mask128, /, initial_bytes: BytesLike = 如果你要新建一个 QMCv2 对象,不要直接调用 ``__init__()``,而是使用构造器方法 ``QMCv2.new()`` 和 ``QMCv2.open()`` 新建或打开已有 QMCv2 文件, 使用已有 QMCv2 对象的 ``save()`` 方法将其保存到文件。 + + Args: + cipher: 要使用的 cipher,必须是一个 libtakiyasha.qmc.qmcdataciphers.Mask128/HardenedRC4 对象 + initial_bytes: 内置缓冲区的初始数据 """ super().__init__(cipher, initial_bytes) From 93f8c0c9b26e6a53d7891ff81040f993f294a891 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 13 Jan 2023 14:27:46 +0800 Subject: [PATCH 49/58] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BA=86=20`KGMorVPR`?= =?UTF-8?q?=20=E7=9A=84=E6=96=87=E6=A1=A3=EF=BC=8C=E5=B9=B6=E4=BD=BF?= =?UTF-8?q?=E5=85=B6=E5=90=91=E5=90=8E=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kgmvpr/__init__.py | 123 +++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 4 deletions(-) diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index 78a761f..29adb73 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -5,7 +5,7 @@ from typing import IO, NamedTuple from .kgmvprdataciphers import KGMCryptoLegacy -from ..exceptions import CrypterCreatingError, CrypterSavingError +from ..exceptions import CrypterCreatingError from ..prototypes import EncryptedBytesIOSkel from ..typedefs import BytesLike, FilePath, KeyStreamBasedStreamCipherProto, StreamCipherProto from ..typeutils import isfilepath, tobytes, verify_fileobj @@ -22,6 +22,23 @@ class KGMorVPRFileInfo(NamedTuple): def probe(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], KGMorVPRFileInfo | None]: + """探测源文件 ``filething`` 是否为一个 KGM 或 VPR 文件。 + + 返回一个 2 个元素长度的元组:第一个元素为 ``filething``;如果 + ``filething`` 是 KGM 或 VPR 文件,那么第二个元素为一个 ``KGMorVPRFileInfo`` 对象;否则为 ``None``。 + + 如果 ``filething`` 是 VPR 文件,那么 ``KGMorVPRFileInfo`` + 对象的 ``is_vpr`` 属性为 ``True``;否则为 ``False``。 + + 本方法的返回值可以用于 ``KGMorVPR.open()`` 的第一个位置参数。 + + Args: + filething: 源文件的路径或文件对象 + Returns: + 一个 2 个元素长度的元组:第一个元素为 filething;如果 + filething 是 KGM 或 VPR 文件,那么第二个元素为一个 KGMorVPRFileInfo 对象;否则为 None。 + """ + def operation(fd: IO[bytes]) -> KGMorVPRFileInfo | None: total_size = fd.seek(0, 2) if total_size < 60: @@ -68,6 +85,19 @@ def operation(fd: IO[bytes]) -> KGMorVPRFileInfo | None: class KGMorVPR(EncryptedBytesIOSkel): + """基于 BytesIO 的 KGM/VPR 透明加密二进制流。 + + 所有读写相关方法都会经过透明加密层处理: + 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + + 调用读写相关方法时,附加参数 ``nocryptlayer=True`` + 可绕过透明加密层,访问缓冲区内的原始加密数据。 + + 如果你要新建一个 KGMorVPR 对象,不要直接调用 ``__init__()``,而是使用构造器方法 + ``KGMorVPR.new()`` 和 ``KGMorVPR.open()`` 新建或打开已有 KGM/VPR 文件, + 使用已有 KGMorVPR 对象的 ``save()`` 方法将其保存到文件。 + """ + @property def acceptable_ciphers(self): return [KGMCryptoLegacy] @@ -75,7 +105,23 @@ def acceptable_ciphers(self): def __init__(self, cipher: StreamCipherProto | KeyStreamBasedStreamCipherProto, /, initial_bytes: BytesLike = b'' - ): + ) -> None: + """基于 BytesIO 的 KGM/VPR 透明加密二进制流。 + + 所有读写相关方法都会经过透明加密层处理: + 读取时,返回解密后的数据;写入时,向缓冲区写入加密后的数据。 + + 调用读写相关方法时,附加参数 ``nocryptlayer=True`` + 可绕过透明加密层,访问缓冲区内的原始加密数据。 + + 如果你要新建一个 KGMorVPR 对象,不要直接调用 ``__init__()``,而是使用构造器方法 + ``KGMorVPR.new()`` 和 ``KGMorVPR.open()`` 新建或打开已有 KGM/VPR 文件, + 使用已有 KGMorVPR 对象的 ``save()`` 方法将其保存到文件。 + + Args: + cipher: 要使用的 cipher,必须是一个 libtakiyasha.kgmvpr.kgmvprdataciphers.KGMCryptoLegacy 对象 + initial_bytes: 内置缓冲区的初始数据 + """ super().__init__(cipher, initial_bytes) self._source_file_header_data: bytes | None = None @@ -88,6 +134,23 @@ def from_file(cls, tablev2: BytesLike, vpr_key: BytesLike = None ): + """(已弃用,且将会在后续版本中删除。请尽快使用 ``KGMorVPR.open()`` 代替。) + + 打开一个 KGMorVPR 文件或文件对象 ``kgm_vpr_filething``。 + + 第一个位置参数 ``kgm_vpr_filething`` 可以是文件路径(``str``、``bytes`` + 或任何拥有方法 ``__fspath__()`` 的对象)。``kgm_vpr_filething`` + 也可以是一个文件对象,但必须可读、可跳转(``kgm_vpr_filething.seekable() == True``)。 + + 参数 ``table1``、``table2``、``tablev2`` 都是必选参数, + 因为它们会参与到内置透明加密层的创建过程中,并且在加密/解密过程中发挥关键作用。 + 这三个参数的都必须是类字节对象,且转换为 ``bytes`` 后,长度为 272 字节。 + + 本方法会寻找文件内嵌主密钥的位置和加密方式,进而判断所用加密算法的类型。 + + 如果探测到 ``VPR`` 文件,那么参数 ``vpr_key`` 是必选的:必须是类字节对象,且转换为 ``bytes`` + 后的长度为 17 字节。 + """ return cls.open(kgm_vpr_filething, table1=table1, table2=table2, @@ -103,6 +166,28 @@ def open(cls, tablev2: BytesLike, vpr_key: BytesLike = None ): + """打开一个 KGMorVPR 文件,并返回一个 ``KGMorVPR`` 对象。 + + 第一个位置参数 ``filething_or_info`` 需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + + ``filething_or_info`` 也可以接受 ``probe()`` 函数的返回值: + 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 + + 第二、三、四个参数 ``table1``、``table2`` 和 ``tablev2`` + 是必需的,都必须是 272 字节长度的字节串。 + + 如果探测到 VPR 文件,那么第五个参数 ``vpr_key`` + 是必需的。如果提供,则必须是 17 字节长度的字节串。其他情况下,此参数会被忽略。 + + Args: + filething_or_info: 源文件的路径或文件对象,或者 probe() 的返回值 + table1: 解码表 1 + table2: 解码表 2 + tablev2: 解码表 3 + vpr_key: 针对 VPR 文件额外所需的密钥 + """ # if table1 is not None: # table1 = tobytes(table1) # if table2 is not None: @@ -179,16 +264,46 @@ def operation(fd: IO[bytes]) -> cls: return instance def to_file(self, kgm_vpr_filething: FilePath | IO[bytes] = None) -> None: + """(已弃用,且将会在后续版本中删除。请尽快使用 ``KGMorVPR.save()`` 代替。) + + 将当前 KGMorVPR 对象的内容保存到文件 ``kgm_vpr_filething``。 + + 第一个位置参数 ``kgm_vpr_filething`` 可以是文件路径(``str``、``bytes`` + 或任何拥有方法 ``__fspath__()`` 的对象)。``kgm_vpr_filething`` + 也可以是一个文件对象,但必须可写。 + + 本方法会首先尝试写入 ``kgm_vpr_filething`` 指向的文件。 + 如果未提供 ``kgm_vpr_filething``,则会尝试写入 ``self.name`` + 指向的文件。如果两者都为空或未提供,则会触发 ``CrypterSavingError``。 + + 目前无法生成 KGM/VPR 文件的标头数据,因此本方法不能用于保存通过 ``KGMorVPR.new()`` + 创建的 ``KGMorVPR`` 对象。尝试这样做会触发 ``NotImplementedError``。 + """ return self.save(kgm_vpr_filething) def save(self, filething: FilePath | IO[bytes] = None ) -> None: + """(实验性功能)将当前对象保存为一个新 KGM 或 VPR 文件。 + + 第一个参数 ``filething`` 是可选的,如果提供此参数,需要是一个文件路径或文件对象。 + 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 + 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 + 如果未提供此参数,那么将会尝试使用当前对象的 ``source`` 属性;如果后者也不可用,则引发 + ``TypeError``。 + + 目前无法生成 KGM/VPR 文件的标头数据,因此本方法不能用于保存通过 ``KGMorVPR.new()`` + 创建的 ``KGMorVPR`` 对象。尝试这样做会触发 ``NotImplementedError``。 + + Args: + filething: 目标文件的路径或文件对象 + """ + def operation(fd: IO[bytes]) -> None: if self._source_file_header_data is None: - raise CrypterSavingError( + raise NotImplementedError( f"cannot save current {type(self).__name__} object to file '{str(filething)}', " - f"because it's not open from KGM or VPR file" + f"generate KGM/VPR file header is not supported" ) fd.seek(0, 0) fd.write(self._source_file_header_data) From 218fbe96ebb4c594024add301ec39dbd711a563b Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 13 Jan 2023 14:33:10 +0800 Subject: [PATCH 50/58] =?UTF-8?q?=E6=9A=82=E6=97=B6=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=BA=86=E7=A4=BA=E4=BE=8B=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/dump-ncm-file-with-key.py | 286 ----------------------------- 1 file changed, 286 deletions(-) delete mode 100755 examples/dump-ncm-file-with-key.py diff --git a/examples/dump-ncm-file-with-key.py b/examples/dump-ncm-file-with-key.py deleted file mode 100755 index e8423ab..0000000 --- a/examples/dump-ncm-file-with-key.py +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -from __future__ import annotations - -import argparse -import os -import re -import shutil -import sys -from datetime import datetime -from pathlib import Path - -from libtakiyasha.exceptions import LibTakiyashaException -from libtakiyasha.ncm import NCM - -try: - from mutagen import flac, mp3, id3 -except ImportError: - mutagen_available = False -else: - mutagen_available = True - -try: - from tqdm import tqdm -except ImportError: - tqdm_available = False -else: - tqdm_available = True - -progname = Path(sys.argv[0]).name - -template_string_pattern = re.compile('{title}|{artist}|{album}|{time}') - -hexnumstr_array_sep_pattern = re.compile(', ?|,? ') -hexnumstr_pattern = re.compile('^0x[0-9a-z]{,2}$', flags=re.IGNORECASE) - -if sys.platform.startswith('win'): - illegal_filename_chars_pattern = re.compile(r'[\x00-\x31~"#%&*:<>?/\\|]+') -else: - illegal_filename_chars_pattern = re.compile(r'[\x00/]') - - -def hexstring2bytes(hexstring: str, paramname: str) -> bytes: - try: - return bytes.fromhex(hexstring) - except ValueError: - hexnum_strings = hexnumstr_array_sep_pattern.split(hexstring) - hexnums: list[int] = [] - for item in hexnum_strings: - try: - hexnums.append(int(item, base=16)) - except ValueError: - print(f"错误:在参数 '{paramname}' 中发现无效的十六进制数字 '{item}'") - sys.exit(1) - return bytes(hexnums) - - -def str_shorten(s, maxlen: int = 30, lr_maxkeeplen: int = 10) -> str: - string = str(s) - maxlen = int(maxlen) - lr_maxkeeplen = int(lr_maxkeeplen) - - if len(string) > maxlen: - return string[:lr_maxkeeplen] + '...' + string[-lr_maxkeeplen:] - return string - - -ap = argparse.ArgumentParser(prog=progname, - add_help=False, - formatter_class=argparse.RawTextHelpFormatter, - usage='%(prog)s [-h] [-t 模板| --template 模板] ' - '(-k 核心密钥 | --core-key 核心密钥) ' - 'NCM 文件... ' - '[输出目录]' - ) -required_optargs = ap.add_argument_group('必需选项和参数') -required_optargs.add_argument('-k', '--core-key', - dest='core_key_str', - metavar='核心密钥', - required=True, - help="解密文件所需的密钥,使用十六进制表示法。\n" - "可以接受以下形式:\n" - " -k a1b1c4d5e1f4\n" - " -k 0x11,0x45,0x14,0Xc1,0x9F,0x19,0xab\n" - "不区分大小写,包含的空格会被去除。" - ) -required_optargs.add_argument('sources_or_target', - metavar='NCM 文件... [输出目录]', - nargs='+', - type=Path, - help='所有输入文件的路径。如果最后一个路径指向一个目录,那么它会被用作\n' - '所有输入文件的输出目录;否则,输出目录为当前目录。\n' - '除最后一个参数外,所有路径必须指向一个文件。' - ) - -optional_optargs = ap.add_argument_group('可选选项和参数') -optional_optargs.add_argument('-h', '--help', - action='help', - help='显示帮助信息并退出' - ) -optional_optargs.add_argument('-t', '--template', - dest='target_filename_template', - metavar='模板', - default='', - help='以 <模板> 规定的格式设定输出文件的名称。\n' - '模板字符串中的可用字段:\n' - ' {title} - 标题\n' - ' {artist} - 歌手(艺术家)\n' - ' {album} - 专辑\n' - ' {time} - 文件生成的时间,使用 ISO 8601 表示法\n' - "例如,将歌曲标题作为输出文件名:-t '{title}'\n" - '如果未指定此选项,那么将会根据源文件名决定输出文件名。' - ) -optional_optargs.add_argument('-n', '--no-tag', - action='store_false', - dest='with_tag', - help='不要向输出文件中写入标签信息' - ) - - -def main(): - optargs = ap.parse_intermixed_args() - - core_key_str: str = optargs.core_key_str - core_key = hexstring2bytes(core_key_str, '-k/--core-key') - - sources_or_target: list[Path] = optargs.sources_or_target - targetdir = sources_or_target.pop(-1) - if not targetdir.is_dir(): - sources_or_target.append(targetdir) - targetdir = Path.cwd() - - target_filename_template = optargs.target_filename_template - if target_filename_template != '' and not template_string_pattern.search(target_filename_template): - print(f"错误:文件名模板字符串 '{target_filename_template}' 不包含任何字段") - sys.exit(1) - target_filename_template = illegal_filename_chars_pattern.sub( - '%%', target_filename_template - ) - - with_tag: bool = optargs.with_tag - - total = len(sources_or_target) - succeeds: list[tuple[Path, Path]] = [] - fails: list[tuple[Path, str]] = [] - - for current, sourcepath in enumerate(sources_or_target, start=1): - sourcepath_dirname = sourcepath.parent - sourcepath_filename = sourcepath.name - sourcepath_display = os.path.join(str_shorten(sourcepath_dirname), str_shorten(sourcepath_filename)) - - termcols, termls = shutil.get_terminal_size() - print('=' * termcols) - print(f"[{current}/{total}]输入文件:'{sourcepath_display}'") - - errmsg = '' - if not sourcepath.exists(): - errmsg = '路径不存在' - elif not sourcepath.is_file(): - errmsg = '路径不是一个文件' - if errmsg: - print(f"跳过 '{sourcepath_display}':{errmsg}") - fails.append((sourcepath, errmsg)) - continue - - try: - ncmfile = NCM.from_file(sourcepath, core_key=core_key) - ncmfile_size = ncmfile.seek(0, 2) - ncmfile.seek(0, 0) - except LibTakiyashaException as exc: - errmsg = f'{type(exc).__name__}: {exc}' - print(f"跳过 '{sourcepath}':{errmsg}") - fails.append((sourcepath, errmsg)) - continue - - ncm_tag = ncmfile.ncm_tag - targetfile_format = ncm_tag.format.upper() - if not targetfile_format: - header_4bytes = ncmfile.read(4) - if header_4bytes.startswith(b'fLaC'): - targetfile_format = 'FLAC' - elif header_4bytes.startswith((b'ID3', b'\xff\xfb', b'\xff\xf3', b'\xff\xf2')): - targetfile_format = 'MP3' - print(f"输出文件格式:{targetfile_format.strip() if targetfile_format.strip() else '未知'}") - - title = str(ncm_tag.musicName) if ncm_tag.musicName else None - if ncm_tag.artist: - artist_names_ids = list(ncm_tag.artist) - artist_names: list[str] = [] - for item in artist_names_ids: - if len(item) < 1: - continue - artist_names.append(str(item[0])) - artist = '、'.join(artist_names) - else: - artist = None - album = str(ncm_tag.album) if ncm_tag.album else None - - print(f"标题:{title if title else '无'}") - print(f"歌手:{artist if artist else '无'}") - print(f"专辑:{album if album else '无'}") - - targetpath_filename = sourcepath.stem + f'.{targetfile_format.lower() if targetfile_format.strip() else "unknown"}' - if target_filename_template: - targetpath_filename = target_filename_template.format( - title=title, - artist=artist, - album=album, - time=datetime.now().isoformat(timespec='seconds') - ) + f'.{targetfile_format.lower() if targetfile_format.strip() else "unknown"}' - targetpath_filename_display = str_shorten(targetpath_filename) - targetpath = targetdir / targetpath_filename - print(f"输出文件名:{targetpath_filename}") - print(f"输出文件所在目录:{targetdir}") - - if tqdm_available: - with tqdm(total=ncmfile_size, - unit='B', - unit_scale=True, - desc=targetpath_filename_display - ) as pbar: - with open(targetpath, 'wb') as targetfile: - for blk in ncmfile: - targetfile.write(blk) - pbar.update(len(blk)) - else: - with open(targetpath, 'wb') as targetfile: - for blk in ncmfile: - targetfile.write(blk) - print('音频数据已取出') - - if with_tag: - if mutagen_available: - try: - cover_data = ncmfile.cover_data - metadata = ncm_tag.to_mutagen_style_dict() - if targetfile_format.upper() == 'FLAC': - tag = flac.FLAC(targetpath) - for key, value in metadata.items(): - tag[key] = value - picture = flac.Picture() - picture.data = cover_data - picture.type = 3 - if cover_data.startswith(b'\x89PNG'): - picture.mime = 'image/png' - elif cover_data.startswith(b'\xff\xd8\xff'): - picture.mime = 'image/jpeg' - tag.add_picture(picture) - tag.save(targetpath) - elif targetfile_format.upper() == 'MP3': - tag = mp3.MP3(targetpath) - for key, value in metadata.items(): - id3frame_cls = getattr(id3, key[:4]) - id3frame = tag.get(key) - if id3frame is None: - tag[key] = id3frame_cls(text=value, desc='comment') - elif id3frame.text: - id3frame.text = value - tag[key] = id3frame - picture = id3.APIC() - picture.data = cover_data - picture.type = 3 - if cover_data.startswith(b'\x89PNG'): - picture.mime = 'image/png' - elif cover_data.startswith(b'\xff\xd8\xff'): - picture.mime = 'image/jpeg' - tag['APIC:'] = picture - tag.save(targetpath) - else: - print('未嵌入标签信息,因为输出文件格式未知') - except Exception as exc: - print(f'未能嵌入标签信息:{type(exc).__name__}: {exc}') - else: - print('已嵌入标签信息') - else: - print("未嵌入标签信息,因为缺少依赖关系“mutagen”") - - succeeds.append((sourcepath, targetpath)) - - if current == total: - termcols, termls = shutil.get_terminal_size() - print('=' * termcols) - - -if __name__ == '__main__': - main() From 56fefd2b0faaf2b6bf5e1e0db00c0203c12af183 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 13 Jan 2023 23:08:25 +0800 Subject: [PATCH 51/58] =?UTF-8?q?=E4=B8=BA=20`KGMorVPR`=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BA=86=20`new()`=20=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kgmvpr/__init__.py | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index 29adb73..1500b7c 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -6,6 +6,7 @@ from .kgmvprdataciphers import KGMCryptoLegacy from ..exceptions import CrypterCreatingError +from ..keyutils import make_salt from ..prototypes import EncryptedBytesIOSkel from ..typedefs import BytesLike, FilePath, KeyStreamBasedStreamCipherProto, StreamCipherProto from ..typeutils import isfilepath, tobytes, verify_fileobj @@ -327,3 +328,33 @@ def operation(fd: IO[bytes]) -> None: verify_writable=True ) return operation(fileobj) + + @classmethod + def new(cls, + table1: BytesLike, + table2: BytesLike, + tablev2: BytesLike, + vpr_key: BytesLike = None + ): + """返回一个空 KGMorVPR 对象。 + + 第一、二、三个参数 ``table1``、``table2`` 和 ``tablev2`` + 是必需的,都必须是 272 字节长度的字节串。 + + 如果提供了第五个参数 ``vpr_key``,那么将会使用针对 VPR 的加密方法;这种情况下 + ``vpr_key`` 必须是 17 字节长度的字节串。 + + 注意:通过本方法创建的 ``KGMorVPR`` 对象不可通过 ``save()`` + 方法保存到文件。尝试这样做会触发 ``NotImplementedError``。 + """ + table1 = tobytes(table1) + table2 = tobytes(table2) + tablev2 = tobytes(tablev2) + if vpr_key is not None: + vpr_key = tobytes(vpr_key) + + core_key_test_data = make_salt(16) + b'\x00' + + cipher = KGMCryptoLegacy(table1, table2, tablev2, core_key_test_data, vpr_key) + + return cls(cipher) From 7372e93f8b3d4d07dc5071561e4ebd8a03e238bd Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 13 Jan 2023 23:18:37 +0800 Subject: [PATCH 52/58] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README=20=E5=92=8C?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 67 +++++----------------------------------- src/libtakiyasha/VERSION | 2 +- 2 files changed, 9 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 6ed1da7..cbf12dc 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,21 @@ -# libtakiyasha ![](https://img.shields.io/badge/Version-2.1.0a1-yellow) ![](https://img.shields.io/badge/Python-3.8%2B-blue) +# libtakiyasha ![](https://img.shields.io/badge/Version-2.1.0rc1-yellow) ![](https://img.shields.io/badge/Python-3.8%2B-blue) -`libtakiyasha` 是一个 Python 音频加密/解密工具库(当然也可用于加密非音频数据),支持多种加密文件格式。 +`libtakiyasha` 是一个 Python 音频加密/解密工具库(当然也可用于加密非音频数据),支持多种加密文件格式。**不提供任何命令行或图形界面支持。** -`libtakiyasha` 只是一个工具库,不提供任何命令行或图形界面支持。 - ---- +## 使用前必读 **本项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循 [License](LICENSE)。** 本项目的设计灵感,以及部分解密方案,来源于: -- [Unlock Music Project - CLI Edition](https://git.unlock-music.dev/um/cli) -- [jixunmoe/qmc2](https://github.com/jixunmoe/qmc2) +- [Unlock Music Project - CLI Edition](https://git.unlock-music.dev/um/cli) +- [jixunmoe/kugou-crypto](https://github.com/jixunmoe/kugou-crypto) +- [jixunmoe/qmc2](https://github.com/jixunmoe/qmc2) -**本项目没有所谓的“内置密钥”,打开任何类型的加密文件都需要你提供对应的密钥。你需要自行寻找解密所需密钥或加密参数,在调用时作为参数传入。** +**本项目没有所谓的“默认密钥”或“内置密钥”,打开/保存任何类型的加密文件都需要你提供对应的密钥。你需要自行寻找解密所需密钥或加密参数,在调用时作为参数传入。** -你可以在内容提供商的应用程序中查找这些必需参数,或寻求同类项目以及他人的帮助。**但请不要在 Issues/讨论区向作者索要所谓“缺失”的“内置密钥”,你的此类想法不会被满足。** +你可以在内容提供商的应用程序中查找这些必需参数,或寻求同类项目以及他人的帮助,**但请不要在 Issues/讨论区直接向作者索要所谓“缺失”的“内置密钥”。** **`libtakiyasha` 对输出数据的可用性(是否可以识别、播放等)不做任何保证。** --- - -## 特性 - -- 纯 Python 实现(包括所有依赖关系),无 C/C++ 扩展模块,跨平台可用 -- 支持多种加密文件格式的加密和解密 - -## 当前版本:[2.1.0a1](https://github.com/nukemiko/libtakiyasha/releases/tag/2.1.0a1) - -此版本为测试版。如果发现任何 `libtakiyasha` 自身的问题,欢迎[提交 Issue](https://github.com/nukemiko/libtakiyasha/issues)。 - -**`libtakiyasha` 2.x 版本和 1.x 版本之间的接口并不兼容,使用 1.x 版本的应用程序需要进行大量改造,才能使用 2.x 版本。** - -### 变更日志 - -详见[版本发布页](https://github.com/nukemiko/libtakiyasha/releases/tag/2.1.0a1)。 - -### 支持的格式 - -~~请在[此处](https://github.com/nukemiko/libtakiyasha/wiki/%E6%94%AF%E6%8C%81%E7%9A%84%E6%A0%BC%E5%BC%8F%E5%92%8C%E6%89%80%E9%9C%80%E5%AF%86%E9%92%A5%E5%8F%82%E6%95%B0)查看。~~ - -鉴于以上信息无法正确反映当前版本,请以当前版本的文档为准(可在 Python 交互式终端中使用 `help(<函数/方法/对象>)` 查看)。 - -### 兼容性 - -到目前为止(版本 2.1.0a1),`libtakiyasha` 已在以下 Python 实现中通过了测试: - -- [CPython(官方实现)](https://www.python.org)3.8 至 3.10,可能支持 3.11 -- [Pyston](https://github.com/pyston/pyston) [2.3.5](https://github.com/pyston/pyston/releases/tag/pyston_2.3.5)(基于 CPython 3.8.12),其他版本或许也可用 -- [PyPy](https://www.pypy.org/) 7.3.9([CPython 3.8 兼容版本、CPython 3.9 兼容版本](https://downloads.python.org/pypy/)),其他版本或许也可用 - -**注意:`libtakiyasha` 所需的最低 Python 版本为 3.8,因为它使用的很多 Python 特性从 Python 3.8 开始才出现,这意味着使用更低的 Python 版本会出现大量不可预知的错误。** - -提示:在作者运行的测试中(仅测试了 NCM),CPython 实现是速度最慢的;PyPy 比 Pyston 快了大约两倍(两者都内置了不同形式的 JIT),比 CPython 快了接近五倍。 - -### 安装 - -- 运行命令:`pip install -U libtakiyasha==2.1.0a1` -- 或者前往 [GitHub 发布页](https://github.com/nukemiko/libtakiyasha/releases/tag/2.1.0a1) 下载安装 - -#### 所需依赖关系 - -- `setuptools` - 安装依赖 -- `pyaes` - AES 加解密支持 -- `mutagen` - 导出网易云音乐使用的 163key 数据 - -如果你是通过[上文提到的方式](#安装)安装的 `libtakiyasha`,这些依赖会被自动安装。 - -### 基本使用方法 - -_未完待续_ diff --git a/src/libtakiyasha/VERSION b/src/libtakiyasha/VERSION index 0e4065c..0c271bc 100644 --- a/src/libtakiyasha/VERSION +++ b/src/libtakiyasha/VERSION @@ -1 +1 @@ -2.1.0a1 +2.1.0rc1 From 61ad4005975c341eb6a538725f010f142235c42d Mon Sep 17 00:00:00 2001 From: nukemiko Date: Fri, 13 Jan 2023 23:42:43 +0800 Subject: [PATCH 53/58] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=20=5F=5Fall?= =?UTF-8?q?=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kgmvpr/__init__.py | 2 ++ src/libtakiyasha/kwm/__init__.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index 1500b7c..9f09702 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -11,6 +11,8 @@ from ..typedefs import BytesLike, FilePath, KeyStreamBasedStreamCipherProto, StreamCipherProto from ..typeutils import isfilepath, tobytes, verify_fileobj +__all__ = ['KGMorVPR', 'probe'] + class KGMorVPRFileInfo(NamedTuple): cipher_data_offset: int diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index 1c1b912..1322e02 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -16,6 +16,8 @@ DIGIT_CHARS = b'0123456789' ASCII_LETTER_CHARS = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' +__all__ = ['KWM', 'probe'] + class KWMFileInfo(NamedTuple): mask_recipe: bytes From fc14a8eb28af45c33c9a1fca49bcc08ab5c70d3a Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 16 Jan 2023 00:12:58 +0800 Subject: [PATCH 54/58] =?UTF-8?q?=E5=B0=86=20`*FileInfo`=20=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E4=BA=86=20`=5F=5Fall=5F=5F`=EF=BC=9B=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=86=20`qmc.QMCv1.open()`=20=E4=B8=8D=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E7=9A=84=E7=B1=BB=E5=9E=8B=E6=A0=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kgmvpr/__init__.py | 2 +- src/libtakiyasha/kwm/__init__.py | 2 +- src/libtakiyasha/ncm.py | 2 +- src/libtakiyasha/qmc/__init__.py | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index 9f09702..831c3fb 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -11,7 +11,7 @@ from ..typedefs import BytesLike, FilePath, KeyStreamBasedStreamCipherProto, StreamCipherProto from ..typeutils import isfilepath, tobytes, verify_fileobj -__all__ = ['KGMorVPR', 'probe'] +__all__ = ['KGMorVPR', 'probe', 'KGMorVPRFileInfo'] class KGMorVPRFileInfo(NamedTuple): diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index 1322e02..9b5cd3a 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -16,7 +16,7 @@ DIGIT_CHARS = b'0123456789' ASCII_LETTER_CHARS = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' -__all__ = ['KWM', 'probe'] +__all__ = ['KWM', 'probe', 'KWMFileInfo'] class KWMFileInfo(NamedTuple): diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index bf2dca6..5b418de 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -22,7 +22,7 @@ warnings.filterwarnings(action='default', category=DeprecationWarning, module=__name__) -__all__ = ['CloudMusicIdentifier', 'NCM', 'probe'] +__all__ = ['CloudMusicIdentifier', 'NCM', 'probe', 'NCMFileInfo'] MODULE_BINARIES_ROOTDIR = BINARIES_ROOTDIR / Path(__file__).stem diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index e0a5292..51fe208 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -27,7 +27,9 @@ 'QMCv1', 'QMCv2', 'QMCv2QTag', - 'QMCv2STag' + 'QMCv2STag', + 'QMCv1FileInfo', + 'QMCv2FileInfo' ] QMCV1_SUFFIX_PATTERN = re.compile('\\.qmc[a-zA-Z0-9]{1,4}$', flags=re.IGNORECASE) @@ -345,7 +347,7 @@ def from_file(cls, @classmethod def open(cls, - filething_or_info: FilePath | IO[bytes], /, + filething_or_info: tuple[Path | IO[bytes], QMCv1FileInfo | None] | FilePath | IO[bytes], /, mask: BytesLike ): """打开一个 QMCv1 文件,并返回一个 ``QMCv1`` 对象。 From 08034d33e791433ed2e2e285f7d6cee017ba0d7d Mon Sep 17 00:00:00 2001 From: nukemiko Date: Mon, 16 Jan 2023 00:17:30 +0800 Subject: [PATCH 55/58] =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E4=BA=86?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=9A=84=20`probe()`=20=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E4=BB=A5=E4=BD=BF=E5=AE=83=E4=BB=AC=E6=9B=B4=E6=98=93=E4=BA=8E?= =?UTF-8?q?=E8=BE=A8=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kgmvpr/__init__.py | 12 ++++++------ src/libtakiyasha/kwm/__init__.py | 12 ++++++------ src/libtakiyasha/ncm.py | 12 ++++++------ src/libtakiyasha/qmc/__init__.py | 14 +++++++------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index 831c3fb..39ecf4e 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -11,7 +11,7 @@ from ..typedefs import BytesLike, FilePath, KeyStreamBasedStreamCipherProto, StreamCipherProto from ..typeutils import isfilepath, tobytes, verify_fileobj -__all__ = ['KGMorVPR', 'probe', 'KGMorVPRFileInfo'] +__all__ = ['KGMorVPR', 'probe_kgmvpr', 'KGMorVPRFileInfo'] class KGMorVPRFileInfo(NamedTuple): @@ -24,7 +24,7 @@ class KGMorVPRFileInfo(NamedTuple): is_vpr: bool -def probe(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], KGMorVPRFileInfo | None]: +def probe_kgmvpr(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], KGMorVPRFileInfo | None]: """探测源文件 ``filething`` 是否为一个 KGM 或 VPR 文件。 返回一个 2 个元素长度的元组:第一个元素为 ``filething``;如果 @@ -175,7 +175,7 @@ def open(cls, 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 - ``filething_or_info`` 也可以接受 ``probe()`` 函数的返回值: + ``filething_or_info`` 也可以接受 ``probe_kgmvpr()`` 函数的返回值: 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 第二、三、四个参数 ``table1``、``table2`` 和 ``tablev2`` @@ -185,7 +185,7 @@ def open(cls, 是必需的。如果提供,则必须是 17 字节长度的字节串。其他情况下,此参数会被忽略。 Args: - filething_or_info: 源文件的路径或文件对象,或者 probe() 的返回值 + filething_or_info: 源文件的路径或文件对象,或者 probe_kgmvpr() 的返回值 table1: 解码表 1 table2: 解码表 2 tablev2: 解码表 3 @@ -234,11 +234,11 @@ def operation(fd: IO[bytes]) -> cls: if len(filething_or_info) != 2: raise TypeError( "first argument 'filething_or_info' must be a file path, a file object, " - "or a tuple of probe() returns" + "or a tuple of probe_kgmvpr() returns" ) filething, fileinfo = filething_or_info else: - filething, fileinfo = probe(filething_or_info) + filething, fileinfo = probe_kgmvpr(filething_or_info) if fileinfo is None: raise CrypterCreatingError( diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index 9b5cd3a..4eacaf7 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -16,7 +16,7 @@ DIGIT_CHARS = b'0123456789' ASCII_LETTER_CHARS = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' -__all__ = ['KWM', 'probe', 'KWMFileInfo'] +__all__ = ['KWM', 'probe_kwm', 'KWMFileInfo'] class KWMFileInfo(NamedTuple): @@ -27,7 +27,7 @@ class KWMFileInfo(NamedTuple): suffix: str -def probe(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], KWMFileInfo | None]: +def probe_kwm(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], KWMFileInfo | None]: """探测源文件 ``filething`` 是否为一个 KWM 文件。 返回一个 2 个元素长度的元组:第一个元素为 ``filething``;如果 @@ -261,7 +261,7 @@ def open(cls, 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 - ``filething_or_info`` 也可以接受 ``probe()`` 函数的返回值: + ``filething_or_info`` 也可以接受 ``probe_kwm()`` 函数的返回值: 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 第二个参数 ``core_key`` 一般情况下是必需的,用于解密文件内嵌的主密钥。 @@ -271,7 +271,7 @@ def open(cls, 而文件内置的主密钥会被忽略,``core_key`` 也不再是必需参数。 Args: - filething_or_info: 源文件的路径或文件对象,或者 probe() 的返回值 + filething_or_info: 源文件的路径或文件对象,或者 probe_kwm() 的返回值 core_key: 核心密钥,用于生成文件内加密数据的主密钥 master_key: 如果提供,将会被作为主密钥使用,而文件内置的主密钥会被忽略 """ @@ -305,11 +305,11 @@ def operation(fd: IO[bytes]) -> cls: if len(filething_or_info) != 2: raise TypeError( "first argument 'filething_or_info' must be a file path, a file object, " - "or a tuple of probe() returns" + "or a tuple of probe_kwm() returns" ) filething, fileinfo = filething_or_info else: - filething, fileinfo = probe(filething_or_info) + filething, fileinfo = probe_kwm(filething_or_info) if fileinfo is None: raise CrypterCreatingError( diff --git a/src/libtakiyasha/ncm.py b/src/libtakiyasha/ncm.py index 5b418de..53b1afb 100644 --- a/src/libtakiyasha/ncm.py +++ b/src/libtakiyasha/ncm.py @@ -22,7 +22,7 @@ warnings.filterwarnings(action='default', category=DeprecationWarning, module=__name__) -__all__ = ['CloudMusicIdentifier', 'NCM', 'probe', 'NCMFileInfo'] +__all__ = ['CloudMusicIdentifier', 'NCM', 'probe_ncm', 'NCMFileInfo'] MODULE_BINARIES_ROOTDIR = BINARIES_ROOTDIR / Path(__file__).stem @@ -314,7 +314,7 @@ class NCMFileInfo(NamedTuple): cover_data_len: int -def probe(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], NCMFileInfo | None]: +def probe_ncm(filething: FilePath | IO[bytes], /) -> tuple[Path | IO[bytes], NCMFileInfo | None]: """探测源文件 ``filething`` 是否为一个 NCM 文件。 返回一个 2 个元素长度的元组:第一个元素为 ``filething``;如果 @@ -436,7 +436,7 @@ def open(cls, 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 - ``filething_or_info`` 也可以接受 ``probe()`` 函数的返回值: + ``filething_or_info`` 也可以接受 ``probe_ncm()`` 函数的返回值: 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 第二个参数 ``core_key`` 一般情况下是必需的,用于解密文件内嵌的主密钥。 @@ -450,7 +450,7 @@ def open(cls, 一般不需要填写此参数,因为 NCM 文件总是内嵌加密的主密钥,从而可以轻松地获得。 Args: - filething_or_info: 源文件的路径或文件对象,或者 probe() 的返回值 + filething_or_info: 源文件的路径或文件对象,或者 probe_ncm() 的返回值 core_key: 核心密钥,用于解密文件内嵌的主密钥 tag_key: 歌曲信息密钥,用于解密文件内嵌的歌曲信息 master_key: 如果提供,将会被作为主密钥使用,而文件内置的主密钥会被忽略 @@ -510,11 +510,11 @@ def operation(fd: IO[bytes]) -> cls: if len(filething_or_info) != 2: raise TypeError( "first argument 'filething_or_info' must be a file path, a file object, " - "or a tuple of probe() returns" + "or a tuple of probe_ncm() returns" ) filething, fileinfo = filething_or_info else: - filething, fileinfo = probe(filething_or_info) + filething, fileinfo = probe_ncm(filething_or_info) if fileinfo is None: raise CrypterCreatingError( diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 51fe208..7d7b7bb 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -21,7 +21,7 @@ warnings.filterwarnings(action='default', category=DeprecationWarning, module=__name__) __all__ = [ - 'probe', + 'probe_qmc', 'probe_qmcv1', 'probe_qmcv2', 'QMCv1', @@ -275,7 +275,7 @@ def operation(fd: IO[bytes]) -> QMCv2FileInfo | None: return fileobj, prs -def probe( +def probe_qmc( filething: FilePath | IO[bytes], / ) -> tuple[Path | IO[bytes], QMCv1FileInfo | None] | tuple[Path | IO[bytes], QMCv2FileInfo | None]: """探测源文件 ``filething`` 是否为一个 QMCv1 或 QMCv2 文件。 @@ -386,7 +386,7 @@ def operation(fd: IO[bytes]) -> cls: if len(filething_or_info) != 2: raise TypeError( "first argument 'filething_or_info' must be a file path, a file object, " - "or a tuple of probe(), probe_qmcv1() returns" + "or a tuple of probe_qmc(), probe_qmcv1() returns" ) filething, fileinfo = filething_or_info else: @@ -809,7 +809,7 @@ def open(cls, 可接受的文件路径类型包括:字符串、字节串、任何定义了 ``__fspath__()`` 方法的对象。 如果是文件对象,那么必须可读且可寻址(其 ``seekable()`` 方法返回 ``True``)。 - ``filething_or_info`` 也可以接受 ``probe()`` 和 ``probe_qmcv2()`` 函数的返回值: + ``filething_or_info`` 也可以接受 ``probe_qmc()`` 和 ``probe_qmcv2()`` 函数的返回值: 一个包含两个元素的元组,第一个元素是源文件的路径或文件对象,第二个元素是源文件的信息。 第二个参数 ``core_key`` 一般情况下是必需的,用于解密文件内嵌的主密钥。 @@ -829,10 +829,10 @@ def open(cls, - ``'rc4'`` - 强化版 RC4(HardenedRC4) - ``None`` - 不指定,由 ``probe_qmcv2()`` 自行探测 - 此参数的设置会覆盖 ``probe()`` 或 ``probe_qmcv2()`` 的探测结果。 + 此参数的设置会覆盖 ``probe_qmc()`` 或 ``probe_qmcv2()`` 的探测结果。 Args: - filething_or_info: 源文件的路径或文件对象,或者 probe() 和 probe_qmcv2() 的返回值 + filething_or_info: 源文件的路径或文件对象,或者 probe_qmc() 和 probe_qmcv2() 的返回值 core_key: 核心密钥,用于解密文件内嵌的主密钥 garble_key1: 混淆密钥 1,用于解密使用 V2 加密的主密钥 garble_key2: 混淆密钥 2,用于解密使用 V2 加密的主密钥 @@ -937,7 +937,7 @@ def operation(fd: IO[bytes]) -> cls: if len(filething_or_info) != 2: raise TypeError( "first argument 'filething_or_info' must be a file path, a file object, " - "or a tuple of probe(), probe_qmcv2() returns" + "or a tuple of probe_qmc(), probe_qmcv2() returns" ) filething, fileinfo = filething_or_info else: From 784a11c00be6085c5e54b3c1adeea7ddf6b37730 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Wed, 18 Jan 2023 09:51:46 +0800 Subject: [PATCH 56/58] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BA=86=E9=81=97?= =?UTF-8?q?=E6=BC=8F=E7=9A=84=E5=BC=83=E7=94=A8=E8=AD=A6=E5=91=8A=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E8=AD=A6=E5=91=8A=E8=BF=87=E6=BB=A4=E5=99=A8?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=EF=BC=9B=E7=8E=B0=E5=9C=A8=20`QMCv1.to=5Ffil?= =?UTF-8?q?e()`=20=E5=92=8C=20`QMCv1.save()`=20=E7=9A=84=20`filething`=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E4=B8=8D=E5=86=8D=E6=98=AF=E7=BA=AF=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/kgmvpr/__init__.py | 18 ++++++++++++++++++ src/libtakiyasha/kwm/__init__.py | 2 ++ src/libtakiyasha/qmc/__init__.py | 4 ++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/libtakiyasha/kgmvpr/__init__.py b/src/libtakiyasha/kgmvpr/__init__.py index 39ecf4e..a8fc589 100644 --- a/src/libtakiyasha/kgmvpr/__init__.py +++ b/src/libtakiyasha/kgmvpr/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import warnings from pathlib import Path from typing import IO, NamedTuple @@ -11,6 +12,8 @@ from ..typedefs import BytesLike, FilePath, KeyStreamBasedStreamCipherProto, StreamCipherProto from ..typeutils import isfilepath, tobytes, verify_fileobj +warnings.filterwarnings(action='default', category=DeprecationWarning, module=__name__) + __all__ = ['KGMorVPR', 'probe_kgmvpr', 'KGMorVPRFileInfo'] @@ -154,6 +157,13 @@ def from_file(cls, 如果探测到 ``VPR`` 文件,那么参数 ``vpr_key`` 是必选的:必须是类字节对象,且转换为 ``bytes`` 后的长度为 17 字节。 """ + warnings.warn( + DeprecationWarning( + f'{cls.__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'Use {cls.__name__}.open() instead.' + ) + ) return cls.open(kgm_vpr_filething, table1=table1, table2=table2, @@ -282,6 +292,14 @@ def to_file(self, kgm_vpr_filething: FilePath | IO[bytes] = None) -> None: 目前无法生成 KGM/VPR 文件的标头数据,因此本方法不能用于保存通过 ``KGMorVPR.new()`` 创建的 ``KGMorVPR`` 对象。尝试这样做会触发 ``NotImplementedError``。 """ + warnings.warn( + DeprecationWarning( + f'{type(self).__name__}.from_file() is deprecated, no longer used, ' + f'and may be removed in subsequent versions. ' + f'Use {type(self).__name__}.save() instead.' + ) + ) + return self.save(kgm_vpr_filething) def save(self, diff --git a/src/libtakiyasha/kwm/__init__.py b/src/libtakiyasha/kwm/__init__.py index 4eacaf7..e6a2b70 100644 --- a/src/libtakiyasha/kwm/__init__.py +++ b/src/libtakiyasha/kwm/__init__.py @@ -13,6 +13,8 @@ from ..typedefs import BytesLike, FilePath, IntegerLike, KeyStreamBasedStreamCipherProto, StreamCipherProto from ..typeutils import isfilepath, tobytes, toint, verify_fileobj +warnings.filterwarnings(action='default', category=DeprecationWarning, module=__name__) + DIGIT_CHARS = b'0123456789' ASCII_LETTER_CHARS = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' diff --git a/src/libtakiyasha/qmc/__init__.py b/src/libtakiyasha/qmc/__init__.py index 7d7b7bb..41d0622 100644 --- a/src/libtakiyasha/qmc/__init__.py +++ b/src/libtakiyasha/qmc/__init__.py @@ -418,7 +418,7 @@ def operation(fd: IO[bytes]) -> cls: return instance - def to_file(self, qmcv1_filething: FilePath | IO[bytes] = None, /) -> None: + def to_file(self, qmcv1_filething: FilePath | IO[bytes] = None) -> None: """(已弃用,且将会在后续版本中删除。请尽快使用 ``QMCv2.save()`` 代替。) 将当前 QMCv1 对象的内容保存到文件 ``qmcv1_filething``。 @@ -440,7 +440,7 @@ def to_file(self, qmcv1_filething: FilePath | IO[bytes] = None, /) -> None: ) return self.save(qmcv1_filething) - def save(self, filething: FilePath | IO[bytes] = None, /) -> None: + def save(self, filething: FilePath | IO[bytes] = None) -> None: """将当前对象保存为一个新 QMCv1 文件。 第一个参数 ``filething`` 是可选的,如果提供此参数,需要是一个文件路径或文件对象。 From b44f4e50065683b842eb5dd15a46288a96924c71 Mon Sep 17 00:00:00 2001 From: nukemiko Date: Wed, 18 Jan 2023 10:11:38 +0800 Subject: [PATCH 57/58] =?UTF-8?q?=E5=9C=A8=E5=8C=85=E9=A1=B6=E7=BA=A7?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=9A=84=20=5F=5Finit=5F=5F.py=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BA=86=E5=B8=B8=E7=94=A8=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libtakiyasha/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libtakiyasha/__init__.py b/src/libtakiyasha/__init__.py index 8eb391c..0577d97 100644 --- a/src/libtakiyasha/__init__.py +++ b/src/libtakiyasha/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- from __future__ import annotations +from . import kgmvpr, kwm, ncm, qmc from .pkgver import progname, version, version_info From 6e99e731ae91a10ed174e4914e2b788ff2442bba Mon Sep 17 00:00:00 2001 From: nukemiko Date: Wed, 18 Jan 2023 10:38:59 +0800 Subject: [PATCH 58/58] =?UTF-8?q?=E5=A4=A7=E5=B9=85=E6=9B=B4=E6=94=B9=20RE?= =?UTF-8?q?ADME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cbf12dc..be2ed11 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# libtakiyasha ![](https://img.shields.io/badge/Version-2.1.0rc1-yellow) ![](https://img.shields.io/badge/Python-3.8%2B-blue) +# LibTakiyasha ![](https://img.shields.io/badge/Python-3.8%2B-blue) -`libtakiyasha` 是一个 Python 音频加密/解密工具库(当然也可用于加密非音频数据),支持多种加密文件格式。**不提供任何命令行或图形界面支持。** +LibTakiyasha 是一个 Python 音频加密/解密工具库(当然也可用于加密非音频数据),支持多种加密文件格式。**不提供任何命令行或图形界面支持。** ## 使用前必读 @@ -8,14 +8,72 @@ 本项目的设计灵感,以及部分解密方案,来源于: -- [Unlock Music Project - CLI Edition](https://git.unlock-music.dev/um/cli) -- [jixunmoe/kugou-crypto](https://github.com/jixunmoe/kugou-crypto) -- [jixunmoe/qmc2](https://github.com/jixunmoe/qmc2) +- [Unlock Music Project - CLI Edition](https://git.unlock-music.dev/um/cli) +- [parakeet-rs/libparakeet](https://github.com/parakeet-rs/libparakeet) **本项目没有所谓的“默认密钥”或“内置密钥”,打开/保存任何类型的加密文件都需要你提供对应的密钥。你需要自行寻找解密所需密钥或加密参数,在调用时作为参数传入。** 你可以在内容提供商的应用程序中查找这些必需参数,或寻求同类项目以及他人的帮助,**但请不要在 Issues/讨论区直接向作者索要所谓“缺失”的“内置密钥”。** -**`libtakiyasha` 对输出数据的可用性(是否可以识别、播放等)不做任何保证。** +**LibTakiyasha 对输出数据的可用性(是否可以识别、播放等)不做任何保证。** --- + +## 特性 + +- 使用纯 Python 代码编写 + - **兼容 Python 3.8 及后续版本**,兼容多种 Python 解释器实现(见下文 [#性能测试](#性能测试)) + - 易于阅读,方便 Python 爱好者学习 + - (包括依赖库)无任何 C/C++ 扩展模块,跨平台性强 + +### 性能测试 + +由于 Python 语言自身原因,LibTakiyasha 相较于同类项目,运行速度较慢。因此我们使用不同解释器实现,对常用操作做了一些性能测试: + +| 操作 | 测试大小 | Python 3.10.9 (CPython) | Python 3.8.12 (Pyston 2.3.5) | Python 3.9.16 (PyPy 7.3.11) | +| :------------: | :------: | :---------------------: | :--------------------------: | :-------------------------: | +| NCM 加密 | 36.8 MiB | 4.159s | 2.159s | 1.366s | +| NCM 解密 | 36.8 MiB | 4.393s | 2.360s | 1.480s | +| QMCv1 加密 | 36.8 MiB | 3.841s | 2.116s | 1.594s | +| QMCv1 解密 | 36.8 MiB | 3.813s | 2.331s | 1.406s | +| QMCv2 掩码加密 | 36.8 MiB | 4.065s | 2.201s | 1.727s | +| QMCv2 掩码解密 | 36.8 MiB | 3.990s | 2.200s | 1.848s | +| QMCv2 RC4 加密 | 36.8 MiB | 12.820s | 5.596s | 2.717s | +| QMCv2 RC4 解密 | 36.8 MiB | 12.588s | 5.913s | 2.552s | +| KGM 解密 | 64.4 MiB | 49.014s | 22.053s | 8.376s | +| VPR 解密 | 87.9 MiB | 70.030s | 32.252s | 11.902s | + +仅在你对速度有要求时,可以考虑在调用 LibTakiyasha 时使用 PyPy/Pyston 解释器。 + +一般情况下,建议使用官方解释器实现(CPython)。 + +## 安装 + +可用的最新版本:2.1.0rc1,可前往[发布页面](https://github.com/nukemiko/libtakiyasha/releases/tag/2.1.0rc1)或 [PyPI](https://pypi.org/project/libtakiyasha/2.1.0rc1/) 下载。 + +如果你要下载其他版本: + +- PyPI:https://pypi.org/project/libtakiyasha/#history ,挑选自己所需的版本,下载安装包,手动安装。 + - 或者使用 pip 安装:`python -m pip install -U libtakiyasha==<你所需的版本>` +- 前往[发布页面](https://github.com/nukemiko/libtakiyasha/releases)挑选自己所需的版本,下载安装包,手动安装。 + +### 依赖项 + +LibTakiyasha 依赖以下包,均可从 PyPI 获取: + +- [pyaes](https://pypi.org/project/pyaes/) +- [mutagen](https://pypi.org/project/mutagen/) + +## 常见问题 + +> 为什么 2.x 打开文件需要密钥,而 1.x 版本不需要? + +这是出于以下考虑: + +- LibTakiyasha 是一个加解密库,当然需要为用户提供自定义密钥的权利 +- 为了保护本项目不受美国数字千年版权法Digital Millennium Copyright Act(DMCA)影响,避免仓库被误杀 + - 因此,本仓库所有 1.x 及更早版本的提交和发布版本都已删除。 + +> 如何使用? + +LibTakiyasha 的文档(DocStrings)写得非常清晰,你可以在导入后,使用 Python 内置函数 `help(<...>)` 查看用法。