Created
August 23, 2022 06:37
-
-
Save georgeyjm/5aabd0671eec3a3973176b4e055ef528 to your computer and use it in GitHub Desktop.
Netease NCM Decryption with Metadata and Album Art Embedding
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import os | |
import sys | |
import json | |
import base64 | |
import struct | |
import binascii | |
from pathlib import Path | |
import argparse | |
from Crypto.Cipher import AES | |
from mutagen.id3 import ID3, TIT2, TPE1, TALB, APIC | |
from mutagen.flac import FLAC, Picture | |
AES_CORE_KEY = binascii.a2b_hex('687A4852416D736F356B496E62617857') | |
AES_META_KEY = binascii.a2b_hex('2331346C6A6B5F215C5D2630553C2728') | |
def unpad(s): | |
if isinstance(s[-1], int): | |
cutoff = s[-1] | |
else: | |
cutoff = ord(s[-1]) | |
return s[:-cutoff] | |
def unpack(data): | |
return struct.unpack('<I', bytes(data))[0] | |
def aes_decrypt(key, data, unpad_data=True, mode=AES.MODE_ECB): | |
cryptor = AES.new(key, mode) | |
decrypted = cryptor.decrypt(data) | |
if unpad: | |
decrypted = unpad(decrypted) | |
return decrypted | |
def remove_prefix(target, prefix): | |
if target.startswith(prefix): | |
return target[len(prefix):] | |
return target | |
def read_next_data(fp, length=4, is_array=False, xor_byte=None): | |
data_length = unpack(fp.read(length)) | |
data = fp.read(data_length) | |
if not is_array: | |
return data | |
data = bytearray(data) | |
if xor_byte is not None: | |
for i in range(len(data)): | |
data[i] ^= xor_byte | |
return data | |
def get_key_box(key_data): | |
key_box = bytearray(range(256)) | |
key_length = len(key_data) | |
j = 0 | |
for i in range(256): | |
j = (key_box[i] + j + key_data[i % key_length]) & 0xff | |
key_box[i], key_box[j] = key_box[j], key_box[i] | |
box_map = bytearray() | |
for i in range(256): | |
j = (i + 1) & 0xff | |
s = key_box[(j + key_box[j]) & 0xff] | |
box_map.append(key_box[(key_box[j] + s) & 0xff]) | |
return box_map | |
def recover_data(fp, key_box): | |
raw_data = bytearray(fp.read()) | |
for i in range(len(raw_data)): | |
raw_data[i] ^= key_box[i & 0xff] | |
return raw_data | |
def fill_metadata(file, metadata, album_art): | |
# Format artists | |
artists = [artist[0] for artist in metadata['artist']] | |
if len(artists) <= 2: | |
artists_string = ' & '.join(artists) | |
else: | |
artists_string = ', '.join(artists[:-1]) + ' & ' + artists[-1] | |
# Get album art format | |
if album_art: | |
album_art_format = metadata.get('albumPic', '').split('.')[-1] | |
album_art_format = 'jpeg' if album_art_format == 'jpg' else album_art_format | |
album_art_format = 'image/' + album_art_format | |
if metadata['format'].lower() == 'mp3': | |
audio = ID3(file) | |
audio.add(TPE1(encoding=3, text=artists_string)) # accepts list as input but for the sake of this project | |
audio.add(TIT2(encoding=3, text=metadata['musicName'])) | |
audio.add(TALB(encoding=3, text=metadata['album'])) | |
if album_art: | |
audio.add(APIC(encoding=3, type=3, mime=album_art_format, desc='Cover Art', data=album_art)) | |
audio.save() | |
elif metadata['format'].lower() == 'flac': | |
audio = FLAC(file) | |
if audio.tags is None: | |
audio.add_tags() | |
audio['ARTIST'] = artists_string # accepts list but for the sake of this project | |
audio['TITLE'] = metadata['musicName'] | |
audio['ALBUM'] = metadata['album'] | |
if album_art: | |
picture = Picture() | |
picture.type = 3 | |
picture.mime = album_art_format | |
picture.data = album_art | |
audio.add_picture(picture) | |
audio.save() | |
def recover_ncm(file): | |
with file.open('rb') as f: | |
### Get header data | |
header = f.read(8) | |
assert binascii.b2a_hex(header) == b'4354454e4644414d' | |
f.seek(2, 1) | |
### Get key data | |
key_data = read_next_data(f, is_array=True, xor_byte=0x64) | |
key_data = aes_decrypt(AES_CORE_KEY, bytes(key_data)) | |
key_data = remove_prefix(key_data, b'neteasecloudmusic') | |
key_box = get_key_box(key_data) | |
### Get metadata | |
metadata = read_next_data(f, is_array=True, xor_byte=0x63) | |
metadata = remove_prefix(bytes(metadata), b'163 key(Don\'t modify):') | |
metadata = base64.b64decode(metadata) | |
metadata = aes_decrypt(AES_META_KEY, metadata) | |
metadata = remove_prefix(metadata, b'music:') | |
metadata = json.loads(metadata.decode('utf-8')) | |
# TODO: implement CRC32 error-checking | |
crc32 = unpack(f.read(4)) | |
f.seek(5, 1) | |
### Get album image data | |
image_data = read_next_data(f) | |
### Create recovered file | |
recovered_data = recover_data(f, key_box) | |
recovered_file = file.with_suffix('.' + metadata['format']) | |
with recovered_file.open('wb') as recovered_f: | |
recovered_f.write(recovered_data) | |
fill_metadata(recovered_file, metadata, image_data) | |
print('Decrypted:', metadata['musicName']) | |
parser = argparse.ArgumentParser(description='Recover original files from proprietary NCM files.') | |
parser.add_argument('files', type=Path, nargs='*', help='NCM files') | |
args = parser.parse_args() | |
for i, file in enumerate(args.files): | |
print(f'({i+1}/{len(args.files)})', end=' ') | |
recover_ncm(file) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment