diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..140ab94 --- /dev/null +++ b/.classpath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae3c172 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/bin/ diff --git a/.project b/.project new file mode 100644 index 0000000..97d02eb --- /dev/null +++ b/.project @@ -0,0 +1,17 @@ + + + JNUSLib + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/libs/lombok.jar b/libs/lombok.jar new file mode 100644 index 0000000..23cc160 Binary files /dev/null and b/libs/lombok.jar differ diff --git a/src/de/mas/jnus/lib/DecryptionService.java b/src/de/mas/jnus/lib/DecryptionService.java new file mode 100644 index 0000000..7e86491 --- /dev/null +++ b/src/de/mas/jnus/lib/DecryptionService.java @@ -0,0 +1,336 @@ +package de.mas.jnus.lib; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import de.mas.jnus.lib.entities.TMD; +import de.mas.jnus.lib.entities.Ticket; +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.entities.fst.FSTEntry; +import de.mas.jnus.lib.implementations.NUSDataProvider; +import de.mas.jnus.lib.utils.HashUtil; +import de.mas.jnus.lib.utils.StreamUtils; +import de.mas.jnus.lib.utils.Utils; +import de.mas.jnus.lib.utils.cryptography.NUSDecryption; +import lombok.Getter; +import lombok.Setter; + +public class DecryptionService { + private static Map instances = new HashMap<>(); + + public static DecryptionService getInstance(NUSTitle nustitle) { + if(!instances.containsKey(nustitle)){ + instances.put(nustitle, new DecryptionService(nustitle)); + } + return instances.get(nustitle); + } + + @Getter @Setter private NUSTitle NUSTitle = null; + + private DecryptionService(NUSTitle nustitle){ + setNUSTitle(nustitle); + } + + public Ticket getTicket() { + return getNUSTitle().getTicket(); + } + + public void decryptFSTEntryTo(boolean useFullPath,FSTEntry entry, String outputPath, boolean skipExistingFile) throws IOException { + if(entry.isNotInPackage() || entry.getContent() == null){ + return; + } + + String targetFilePath = new StringBuilder().append(outputPath).append("/").append(entry.getFilename()).toString(); + String fullPath = new StringBuilder().append(outputPath).toString(); + + if(useFullPath){ + targetFilePath = new StringBuilder().append(outputPath).append(entry.getFullPath()).toString(); + fullPath = new StringBuilder().append(outputPath).append(entry.getPath()).toString(); + if(entry.isDir()){ //If the entry is a directory. Create it and return. + Utils.createDir(targetFilePath); + return; + } + }else if(entry.isDir()){ + return; + } + + if(!Utils.createDir(fullPath)){ + return; + } + + File target = new File(targetFilePath); + + if(skipExistingFile){ + File targetFile = new File(targetFilePath); + if(targetFile.exists()){ + if(entry.isDir()){ + return; + } + if(targetFile.length() == entry.getFileSize()){ + Content c = entry.getContent(); + if(!c.isHashed()){ + if(Arrays.equals(HashUtil.hashSHA1(target,(int) c.getEncryptedFileSize()), c.getSHA2Hash())){ + System.out.println("File already exists: " + entry.getFilename()); + return; + }else{ + System.out.println("File already exists with the same filesize, but the hash doesn't match: " + entry.getFilename()); + } + }else{ + System.out.println("File already exists: " + entry.getFilename()); + return; + } + + }else{ + System.out.println("File already exists but the filesize doesn't match: " + entry.getFilename()); + } + } + } + + FileOutputStream outputStream = new FileOutputStream(new File(targetFilePath)); + + System.out.println("Decrypting " + entry.getFilename()); + decryptFSTEntryToStream(entry, outputStream); + } + + public void decryptFSTEntryToStream(FSTEntry entry, OutputStream outputStream) throws IOException { + if(entry.isNotInPackage() || entry.getContent() == null){ + return; + } + + Content c = entry.getContent(); + + long fileSize = entry.getFileSize(); + long fileOffset = entry.getFileOffset(); + long fileOffsetBlock = entry.getFileOffsetBlock(); + + NUSDataProvider dataProvider = getNUSTitle().getDataProvider(); + InputStream in = dataProvider.getInputStreamFromContent(c, fileOffsetBlock); + + decryptFSTEntryFromStreams(in,outputStream,fileSize,fileOffset,c); + } + + + private void decryptFSTEntryFromStreams(InputStream inputStream, OutputStream outputStream,long filesize, long fileoffset, Content content) throws IOException{ + decryptStreams(inputStream, outputStream, filesize, fileoffset, content); + } + + private void decryptContentFromStream(InputStream inputStream, OutputStream outputStream,Content content) throws IOException { + long filesize = content.getDecryptedFileSize(); + System.out.println("Decrypting Content " + String.format("%08X", content.getID())); + decryptStreams(inputStream, outputStream, filesize, 0L, content); + } + + + private void decryptStreams(InputStream inputStream, OutputStream outputStream,long size, long offset, Content content) throws IOException{ + NUSDecryption nusdecryption = new NUSDecryption(getTicket()); + short contentIndex = (short)content.getIndex(); + + long encryptedFileSize = content.getEncryptedFileSize(); + if(!content.isEncrypted()){ + StreamUtils.saveInputStreamToOutputStreamWithHash(inputStream, outputStream,size,content.getSHA2Hash(),encryptedFileSize); + }else{ + if(content.isHashed()){ + NUSDataProvider dataProvider = getNUSTitle().getDataProvider(); + byte[] h3 = dataProvider.getContentH3Hash(content); + nusdecryption.decryptFileStreamHashed(inputStream, outputStream, size, offset, (short) contentIndex, h3); + }else{ + nusdecryption.decryptFileStream(inputStream, outputStream, size, (short)contentIndex,content.getSHA2Hash(),encryptedFileSize); + } + } + + inputStream.close(); + outputStream.close(); + } + + + public void decryptContentTo(Content content,String outPath,boolean skipExistingFile) throws IOException { + String targetFilePath = outPath + File.separator + content.getFilenameDecrypted(); + if(skipExistingFile){ + File targetFile = new File(targetFilePath); + if(targetFile.exists()){ + if(targetFile.length() == content.getDecryptedFileSize()){ + System.out.println("File already exists : " + content.getFilenameDecrypted()); + return; + }else{ + System.out.println("File already exists but the filesize doesn't match: " +content.getFilenameDecrypted()); + } + } + } + + if(!Utils.createDir(outPath)){ + return; + } + + System.out.println("Decrypting Content " + String.format("%08X", content.getID())); + + FileOutputStream outputStream = new FileOutputStream(new File(targetFilePath)); + + decryptContentToStream(content, outputStream); + } + + + public void decryptContentToStream(Content content, OutputStream outputStream) throws IOException { + if(content == null){ + return; + } + + NUSDataProvider dataProvider = getNUSTitle().getDataProvider(); + InputStream inputStream = dataProvider.getInputStreamFromContent(content, 0); + + decryptContentFromStream(inputStream,outputStream,content); + } + + public InputStream getDecryptedOutputAsInputStream(FSTEntry fstEntry) throws IOException { + PipedInputStream in = new PipedInputStream(); + PipedOutputStream out = new PipedOutputStream(in); + + new Thread(() -> {try { + decryptFSTEntryToStream(fstEntry, out); + } catch (IOException e) {e.printStackTrace();}}).start(); + + return in; + } + public InputStream getDecryptedContentAsInputStream(Content content) throws IOException { + PipedInputStream in = new PipedInputStream(); + PipedOutputStream out = new PipedOutputStream(in); + + new Thread(() -> {try { + decryptContentToStream(content, out); + } catch (IOException e) {e.printStackTrace();}}).start(); + + return in; + } + + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + //Decrypt FSTEntry to OutputStream + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + public void decryptFSTEntryTo(String entryFullPath,OutputStream outputStream) throws IOException{ + FSTEntry entry = getNUSTitle().getFSTEntryByFullPath(entryFullPath); + if(entry == null){ + System.out.println("File not found"); + } + + decryptFSTEntryToStream(entry, outputStream); + } + + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + //Decrypt single FSTEntry to File + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + public void decryptFSTEntryTo(String entryFullPath,String outputFolder) throws IOException{ + decryptFSTEntryTo(false,entryFullPath,outputFolder); + } + + public void decryptFSTEntryTo(boolean fullPath,String entryFullPath,String outputFolder) throws IOException{ + decryptFSTEntryTo(fullPath,entryFullPath,outputFolder,getNUSTitle().isSkipExistingFiles()); + } + + public void decryptFSTEntryTo(String entryFullPath,String outputFolder, boolean skipExistingFiles) throws IOException{ + decryptFSTEntryTo(false,entryFullPath,outputFolder,getNUSTitle().isSkipExistingFiles()); + } + + public void decryptFSTEntryTo(boolean fullPath, String entryFullPath,String outputFolder, boolean skipExistingFiles) throws IOException{ + FSTEntry entry = getNUSTitle().getFSTEntryByFullPath(entryFullPath); + if(entry == null){ + System.out.println("File not found"); + return; + } + + decryptFSTEntryTo(fullPath,entry, outputFolder,skipExistingFiles); + } + + public void decryptFSTEntryTo(FSTEntry entry,String outputFolder) throws IOException{ + decryptFSTEntryTo(false,entry, outputFolder); + } + public void decryptFSTEntryTo(boolean fullPath,FSTEntry entry,String outputFolder) throws IOException{ + decryptFSTEntryTo(fullPath,entry,outputFolder,getNUSTitle().isSkipExistingFiles()); + } + + public void decryptFSTEntryTo(FSTEntry entry,String outputFolder, boolean skipExistingFiles) throws IOException{ + decryptFSTEntryTo(false,entry,outputFolder,getNUSTitle().isSkipExistingFiles()); + } + + /* + public void decryptFSTEntryTo(boolean fullPath, FSTEntry entry,String outputFolder, boolean skipExistingFiles) throws IOException{ + decryptFSTEntry(fullPath,entry,outputFolder,skipExistingFiles); + }*/ + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + //Decrypt list of FSTEntry to Files + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + public void decryptAllFSTEntriesTo(String outputFolder) throws IOException { + decryptFSTEntriesTo(true, ".*", outputFolder); + } + + public void decryptFSTEntriesTo(String regEx, String outputFolder) throws IOException { + decryptFSTEntriesTo(true,regEx, outputFolder); + } + public void decryptFSTEntriesTo(boolean fullPath,String regEx, String outputFolder) throws IOException { + List files = getNUSTitle().getAllFSTEntriesFlat(); + Pattern p = Pattern.compile(regEx); + + List result = new ArrayList<>(); + + for(FSTEntry f :files){ + String match = f.getFullPath().replaceAll("\\\\", "/"); + Matcher m = p.matcher(match); + if(m.matches()){ + result.add(f); + } + } + + decryptFSTEntryListTo(fullPath,result, outputFolder); + } + public void decryptFSTEntryListTo(List list,String outputFolder) throws IOException { + decryptFSTEntryListTo(true,list,outputFolder); + } + public void decryptFSTEntryListTo(boolean fullPath,List list,String outputFolder) throws IOException { + for(FSTEntry entry: list){ + decryptFSTEntryTo(fullPath,entry, outputFolder, getNUSTitle().isSkipExistingFiles()); + } + } + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + //Save decrypted contents + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + public void decryptPlainContentByID(int ID,String outputFolder) throws IOException { + decryptPlainContent(getTMDFromNUSTitle().getContentByID(ID),outputFolder); + } + + public void decryptPlainContentByIndex(int index,String outputFolder) throws IOException { + decryptPlainContent(getTMDFromNUSTitle().getContentByIndex(index),outputFolder); + } + + public void decryptPlainContent(Content c,String outputFolder) throws IOException { + decryptPlainContents(new ArrayList(Arrays.asList(c)), outputFolder); + } + + public void decryptPlainContents(List list,String outputFolder) throws IOException { + for(Content c : list){ + decryptContentTo(c,outputFolder,getNUSTitle().isSkipExistingFiles()); + } + } + public void decryptAllPlainContents(String outputFolder) throws IOException { + decryptPlainContents(new ArrayList(getTMDFromNUSTitle().getAllContents().values()),outputFolder); + } + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + //Other + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + private TMD getTMDFromNUSTitle(){ + return getNUSTitle().getTMD(); + } +} diff --git a/src/de/mas/jnus/lib/ExtractionService.java b/src/de/mas/jnus/lib/ExtractionService.java new file mode 100644 index 0000000..588012c --- /dev/null +++ b/src/de/mas/jnus/lib/ExtractionService.java @@ -0,0 +1,125 @@ +package de.mas.jnus.lib; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.implementations.NUSDataProvider; +import de.mas.jnus.lib.utils.FileUtils; +import de.mas.jnus.lib.utils.Utils; +import lombok.Getter; +import lombok.Setter; + +public class ExtractionService { + private static Map instances = new HashMap<>(); + + public static ExtractionService getInstance(NUSTitle nustitle) { + if(!instances.containsKey(nustitle)){ + instances.put(nustitle, new ExtractionService(nustitle)); + } + return instances.get(nustitle); + } + + @Getter @Setter private NUSTitle NUSTitle = null; + + private ExtractionService(NUSTitle nustitle){ + setNUSTitle(nustitle); + } + + private NUSDataProvider getDataProvider(){ + return getNUSTitle().getDataProvider(); + } + + public void extractAllEncrpytedContentFileHashes(String outputFolder) throws IOException { + extractEncryptedContentHashesTo(new ArrayList(getNUSTitle().getTMD().getAllContents().values()),outputFolder); + } + + public void extractEncryptedContentHashesTo(List list, String outputFolder) throws IOException { + Utils.createDir(outputFolder); + NUSDataProvider dataProvider = getDataProvider(); + for(Content c : list){ + dataProvider.saveContentH3Hash(c, outputFolder); + } + } + + public void extractAllEncryptedContentFiles(String outputFolder) throws IOException { + extractAllEncryptedContentFilesWithHashesTo(outputFolder); + } + + public void extractAllEncryptedContentFilesWithoutHashesTo(String outputFolder) throws IOException { + extractEncryptedContentFilesTo(new ArrayList(getNUSTitle().getTMD().getAllContents().values()),outputFolder,false); + } + + public void extractAllEncryptedContentFilesWithHashesTo(String outputFolder) throws IOException { + extractEncryptedContentFilesTo(new ArrayList(getNUSTitle().getTMD().getAllContents().values()),outputFolder,true); + } + + public void extractEncryptedContentFilesTo(List list,String outputFolder,boolean withHashes) throws IOException { + Utils.createDir(outputFolder); + NUSDataProvider dataProvider = getDataProvider(); + for(Content c : list){ + System.out.println("Saving " + c.getFilename()); + if(withHashes){ + dataProvider.saveEncryptedContentWithH3Hash(c, outputFolder); + }else{ + dataProvider.saveEncryptedContent(c, outputFolder); + } + } + } + + public void extractTMDTo(String output) throws IOException { + Utils.createDir(output); + + byte[] rawTMD= getDataProvider().getRawTMD(); + + if(rawTMD != null && rawTMD.length == 0){ + System.out.println("Couldn't write TMD: No TMD loaded"); + return; + } + String tmd_path = output + File.separator + Settings.TMD_FILENAME; + System.out.println("Extracting TMD to: " + tmd_path); + FileUtils.saveByteArrayToFile(tmd_path,rawTMD); + } + + public void extractTicketTo(String output) throws IOException { + Utils.createDir(output); + + byte[] rawTicket= getDataProvider().getRawTicket(); + + if(rawTicket != null && rawTicket.length == 0){ + System.out.println("Couldn't write Ticket: No Ticket loaded"); + return; + } + String ticket_path = output + File.separator + Settings.TICKET_FILENAME; + System.out.println("Extracting Ticket to: " + ticket_path); + FileUtils.saveByteArrayToFile(ticket_path,rawTicket); + } + + public void extractCertTo(String output) throws IOException { + Utils.createDir(output); + + byte[] rawCert = getDataProvider().getRawCert(); + + if(rawCert != null && rawCert.length == 0){ + System.out.println("Couldn't write Cert: No Cert loaded"); + return; + } + String cert_path = output + File.separator + Settings.CERT_FILENAME; + System.out.println("Extracting Cert to: " + cert_path); + FileUtils.saveByteArrayToFile(cert_path,rawCert); + } + + public void extractAll(String outputFolder) throws IOException { + Utils.createDir(outputFolder); + extractAllEncryptedContentFilesWithHashesTo(outputFolder); + extractCertTo(outputFolder); + extractTMDTo(outputFolder); + extractTicketTo(outputFolder); + + } + +} diff --git a/src/de/mas/jnus/lib/NUSTitle.java b/src/de/mas/jnus/lib/NUSTitle.java new file mode 100644 index 0000000..86efcf6 --- /dev/null +++ b/src/de/mas/jnus/lib/NUSTitle.java @@ -0,0 +1,103 @@ +package de.mas.jnus.lib; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map.Entry; + +import de.mas.jnus.lib.entities.TMD; +import de.mas.jnus.lib.entities.Ticket; +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.entities.content.ContentFSTInfo; +import de.mas.jnus.lib.entities.fst.FST; +import de.mas.jnus.lib.entities.fst.FSTEntry; +import de.mas.jnus.lib.implementations.NUSDataProvider; +import lombok.Getter; +import lombok.Setter; + +public class NUSTitle { + @Getter @Setter private String inputPath = ""; + + @Getter @Setter private FST FST; + @Getter @Setter private TMD TMD; + @Getter @Setter private Ticket ticket; + + @Getter @Setter private boolean skipExistingFiles = true; + @Getter @Setter private NUSDataProvider dataProvider = null; + + public List getAllFSTEntriesFlatByContentID(short ID){ + return getFSTEntriesFlatByContent(getTMD().getContentByID((int) ID)); + } + + public List getFSTEntriesFlatByContentIndex(int index){ + return getFSTEntriesFlatByContent(getTMD().getContentByIndex(index)); + } + + public List getFSTEntriesFlatByContent(Content content){ + return getFSTEntriesFlatByContents(new ArrayList(Arrays.asList(content))); + } + + public List getFSTEntriesFlatByContents(List list){ + List entries = new ArrayList<>(); + for(Content c : list){ + for(FSTEntry f : c.getEntries()){ + entries.add(f); + } + } + return entries; + } + + public List getAllFSTEntriesFlat(){ + return getFSTEntriesFlatByContents(new ArrayList(getTMD().getAllContents().values())); + } + + public FSTEntry getFSTEntryByFullPath(String givenFullPath) { + givenFullPath = givenFullPath.replaceAll("/", "\\\\"); + if(!givenFullPath.startsWith("\\")) givenFullPath = "\\" +givenFullPath; + for(FSTEntry f :getAllFSTEntriesFlat()){ + if(f.getFullPath().equals(givenFullPath)){ + return f; + } + } + return null; + } + + public void printFiles() { + getFST().getRoot().printRecursive(0); + } + + public void printContentFSTInfos(){ + for(Entry e : getFST().getContentFSTInfos().entrySet()){ + System.out.println(String.format("%08X", e.getKey()) + ": " +e.getValue()); + } + } + + public void printContentInfos(){ + for(Entry e : getTMD().getAllContents().entrySet()){ + + System.out.println(String.format("%08X", e.getKey()) + ": " +e.getValue()); + System.out.println(e.getValue().getContentFSTInfo()); + for(FSTEntry entry : e.getValue().getEntries()){ + System.out.println(entry.getFullPath() + String.format(" size: %016X", entry.getFileSize()) + + String.format(" offset: %016X", entry.getFileOffset()) + + String.format(" flags: %04X", entry.getFlags())); + } + System.out.println("-"); + } + } + + public void cleanup() throws IOException{ + if(getDataProvider() != null){ + getDataProvider().cleanup(); + } + } + + public void printDetailedData() { + printFiles(); + printContentFSTInfos(); + printContentInfos(); + + System.out.println(); + } +} diff --git a/src/de/mas/jnus/lib/NUSTitleConfig.java b/src/de/mas/jnus/lib/NUSTitleConfig.java new file mode 100644 index 0000000..64edb97 --- /dev/null +++ b/src/de/mas/jnus/lib/NUSTitleConfig.java @@ -0,0 +1,18 @@ +package de.mas.jnus.lib; + +import de.mas.jnus.lib.entities.Ticket; +import de.mas.jnus.lib.implementations.woomy.WoomyInfo; +import de.mas.jnus.lib.implementations.wud.parser.WUDInfo; +import lombok.Data; + +@Data +public class NUSTitleConfig { + private String inputPath = ""; + private WUDInfo WUDInfo = null; + private Ticket ticket = null; + + private int version = Settings.LATEST_TMD_VERSION; + private long titleID = 0x0L; + + private WoomyInfo woomyInfo; +} diff --git a/src/de/mas/jnus/lib/NUSTitleLoader.java b/src/de/mas/jnus/lib/NUSTitleLoader.java new file mode 100644 index 0000000..4bbd725 --- /dev/null +++ b/src/de/mas/jnus/lib/NUSTitleLoader.java @@ -0,0 +1,67 @@ +package de.mas.jnus.lib; + +import java.io.InputStream; +import java.util.Map; + +import de.mas.jnus.lib.entities.TMD; +import de.mas.jnus.lib.entities.Ticket; +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.entities.fst.FST; +import de.mas.jnus.lib.implementations.NUSDataProvider; +import de.mas.jnus.lib.utils.StreamUtils; +import de.mas.jnus.lib.utils.cryptography.AESDecryption; + +abstract class NUSTitleLoader { + protected NUSTitleLoader(){ + + } + + public NUSTitle loadNusTitle(NUSTitleConfig config) throws Exception{ + NUSTitle result = new NUSTitle(); + + NUSDataProvider dataProvider = getDataProvider(config); + result.setDataProvider(dataProvider); + dataProvider.setNUSTitle(result); + + + TMD tmd = TMD.parseTMD(dataProvider.getRawTMD()); + result.setTMD(tmd); + + if(tmd == null){ + System.out.println("TMD not found."); + throw new Exception(); + } + + Ticket ticket = config.getTicket(); + if(ticket == null){ + ticket = Ticket.parseTicket(dataProvider.getRawTicket()); + } + result.setTicket(ticket); + System.out.println(ticket); + + Content fstContent = tmd.getContentByIndex(0); + + + InputStream fstContentEncryptedStream = dataProvider.getInputStreamFromContent(fstContent, 0); + if(fstContentEncryptedStream == null){ + + return null; + } + + byte[] fstBytes = StreamUtils.getBytesFromStream(fstContentEncryptedStream, (int) fstContent.getEncryptedFileSize()); + + if(fstContent.isEncrypted()){ + AESDecryption aesDecryption = new AESDecryption(ticket.getDecryptedKey(), new byte[0x10]); + fstBytes = aesDecryption.decrypt(fstBytes); + } + + Map contents = tmd.getAllContents(); + + FST fst = FST.parseFST(fstBytes, contents); + result.setFST(fst); + + return result; + } + + protected abstract NUSDataProvider getDataProvider(NUSTitleConfig config); +} diff --git a/src/de/mas/jnus/lib/NUSTitleLoaderLocal.java b/src/de/mas/jnus/lib/NUSTitleLoaderLocal.java new file mode 100644 index 0000000..d22224b --- /dev/null +++ b/src/de/mas/jnus/lib/NUSTitleLoaderLocal.java @@ -0,0 +1,35 @@ +package de.mas.jnus.lib; + +import de.mas.jnus.lib.entities.Ticket; +import de.mas.jnus.lib.implementations.NUSDataProviderLocal; +import de.mas.jnus.lib.implementations.NUSDataProvider; + +public class NUSTitleLoaderLocal extends NUSTitleLoader { + + private NUSTitleLoaderLocal(){ + super(); + } + public static NUSTitle loadNUSTitle(String inputPath) throws Exception{ + return loadNUSTitle(inputPath, null); + } + + public static NUSTitle loadNUSTitle(String inputPath, Ticket ticket) throws Exception{ + NUSTitleLoader loader = new NUSTitleLoaderLocal(); + NUSTitleConfig config = new NUSTitleConfig(); + + if(ticket != null){ + config.setTicket(ticket); + } + config.setInputPath(inputPath); + + return loader.loadNusTitle(config); + } + + @Override + protected NUSDataProvider getDataProvider(NUSTitleConfig config) { + NUSDataProviderLocal result = new NUSDataProviderLocal(); + result.setLocalPath(config.getInputPath()); + return result; + } + +} diff --git a/src/de/mas/jnus/lib/NUSTitleLoaderRemote.java b/src/de/mas/jnus/lib/NUSTitleLoaderRemote.java new file mode 100644 index 0000000..2d65ed7 --- /dev/null +++ b/src/de/mas/jnus/lib/NUSTitleLoaderRemote.java @@ -0,0 +1,43 @@ +package de.mas.jnus.lib; + +import de.mas.jnus.lib.entities.Ticket; +import de.mas.jnus.lib.implementations.NUSDataProviderRemote; +import de.mas.jnus.lib.implementations.NUSDataProvider; + +public class NUSTitleLoaderRemote extends NUSTitleLoader{ + + private NUSTitleLoaderRemote(){ + super(); + } + + public static NUSTitle loadNUSTitle(long titleID) throws Exception{ + return loadNUSTitle(titleID, Settings.LATEST_TMD_VERSION ,null); + } + + public static NUSTitle loadNUSTitle(long titleID, int version) throws Exception{ + return loadNUSTitle(titleID, version,null); + } + + public static NUSTitle loadNUSTitle(long titleID, Ticket ticket) throws Exception{ + return loadNUSTitle(titleID, Settings.LATEST_TMD_VERSION, ticket); + } + + public static NUSTitle loadNUSTitle(long titleID,int version, Ticket ticket) throws Exception{ + NUSTitleLoader loader = new NUSTitleLoaderRemote(); + NUSTitleConfig config = new NUSTitleConfig(); + + config.setVersion(version); + config.setTitleID(titleID); + + return loader.loadNusTitle(config); + } + + @Override + protected NUSDataProvider getDataProvider(NUSTitleConfig config) { + NUSDataProviderRemote result = new NUSDataProviderRemote(); + result.setVersion(config.getVersion()); + result.setTitleID(config.getTitleID()); + return result; + } + +} diff --git a/src/de/mas/jnus/lib/NUSTitleLoaderWUD.java b/src/de/mas/jnus/lib/NUSTitleLoaderWUD.java new file mode 100644 index 0000000..15f80e2 --- /dev/null +++ b/src/de/mas/jnus/lib/NUSTitleLoaderWUD.java @@ -0,0 +1,49 @@ +package de.mas.jnus.lib; +import java.io.File; +import java.nio.file.Files; + +import de.mas.jnus.lib.implementations.NUSDataProviderWUD; +import de.mas.jnus.lib.implementations.NUSDataProvider; +import de.mas.jnus.lib.implementations.wud.WUDImage; +import de.mas.jnus.lib.implementations.wud.parser.WUDInfo; +import de.mas.jnus.lib.implementations.wud.parser.WUDInfoParser; + +public class NUSTitleLoaderWUD extends NUSTitleLoader { + + private NUSTitleLoaderWUD(){ + super(); + } + + public static NUSTitle loadNUSTitle(String WUDPath) throws Exception{ + return loadNUSTitle(WUDPath, null); + } + public static NUSTitle loadNUSTitle(String WUDPath, byte[] titleKey) throws Exception{ + NUSTitleLoader loader = new NUSTitleLoaderWUD(); + NUSTitleConfig config = new NUSTitleConfig(); + + File wudFile = new File(WUDPath); + if(!wudFile.exists()){ + System.out.println(WUDPath + " does not exist."); + System.exit(1); + } + + WUDImage image = new WUDImage(wudFile); + if(titleKey == null){ + File keyFile = new File(wudFile.getParentFile().getPath() + File.separator + Settings.WUD_KEY_FILENAME); + titleKey = Files.readAllBytes(keyFile.toPath()); + } + WUDInfo wudInfo = WUDInfoParser.createAndLoad(image.getWUDDiscReader(), titleKey); + + config.setWUDInfo(wudInfo); + + return loader.loadNusTitle(config); + } + + @Override + protected NUSDataProvider getDataProvider(NUSTitleConfig config) { + NUSDataProviderWUD result = new NUSDataProviderWUD(); + result.setWUDInfo(config.getWUDInfo()); + return result; + } + +} diff --git a/src/de/mas/jnus/lib/NUSTitleLoaderWoomy.java b/src/de/mas/jnus/lib/NUSTitleLoaderWoomy.java new file mode 100644 index 0000000..8fea3aa --- /dev/null +++ b/src/de/mas/jnus/lib/NUSTitleLoaderWoomy.java @@ -0,0 +1,33 @@ +package de.mas.jnus.lib; + +import java.io.File; + +import de.mas.jnus.lib.implementations.NUSDataProviderWoomy; +import de.mas.jnus.lib.implementations.NUSDataProvider; +import de.mas.jnus.lib.implementations.woomy.WoomyInfo; +import de.mas.jnus.lib.implementations.woomy.WoomyParser; +import lombok.extern.java.Log; + +@Log +public class NUSTitleLoaderWoomy extends NUSTitleLoader { + + public static NUSTitle loadNUSTitle(String inputFile) throws Exception{ + NUSTitleLoaderWoomy loader = new NUSTitleLoaderWoomy(); + NUSTitleConfig config = new NUSTitleConfig(); + + WoomyInfo woomyInfo = WoomyParser.createWoomyInfo(new File(inputFile)); + if(woomyInfo == null){ + log.info("Created woomy is null."); + return null; + } + config.setWoomyInfo(woomyInfo); + return loader.loadNusTitle(config); + } + @Override + protected NUSDataProvider getDataProvider(NUSTitleConfig config) { + NUSDataProviderWoomy dataProvider = new NUSDataProviderWoomy(); + dataProvider.setWoomyInfo(config.getWoomyInfo()); + return dataProvider; + } + +} diff --git a/src/de/mas/jnus/lib/Settings.java b/src/de/mas/jnus/lib/Settings.java new file mode 100644 index 0000000..1d8532a --- /dev/null +++ b/src/de/mas/jnus/lib/Settings.java @@ -0,0 +1,18 @@ +package de.mas.jnus.lib; + +public class Settings { + public static String URL_BASE = "http://ccs.cdn.wup.shop.nintendo.net/ccs/download"; + public static final int LATEST_TMD_VERSION = 0; + public static final String TMD_FILENAME = "title.tmd"; + public static final String TICKET_FILENAME = "title.tik"; + public static final String CERT_FILENAME = "title.cert"; + + public static final String ENCRYPTED_CONTENT_EXTENTION = ".app"; + public static final String DECRYPTED_CONTENT_EXTENTION = ".dec"; + public static final String WUD_KEY_FILENAME = "game.key"; + public static final String WOOMY_METADATA_FILENAME = "metadata.xml"; + public static final String H3_EXTENTION = ".h3"; + + public static byte[] commonKey = new byte[0x10]; + public static int WIIU_DECRYPTED_AREA_OFFSET = 0x18000; +} diff --git a/src/de/mas/jnus/lib/WUDService.java b/src/de/mas/jnus/lib/WUDService.java new file mode 100644 index 0000000..9dddb96 --- /dev/null +++ b/src/de/mas/jnus/lib/WUDService.java @@ -0,0 +1,176 @@ +package de.mas.jnus.lib; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; + +import com.sun.org.apache.xerces.internal.util.SynchronizedSymbolTable; + +import java.util.TreeMap; + +import de.mas.jnus.lib.implementations.wud.WUDImage; +import de.mas.jnus.lib.implementations.wud.WUDImageCompressedInfo; +import de.mas.jnus.lib.utils.ByteArrayBuffer; +import de.mas.jnus.lib.utils.ByteArrayWrapper; +import de.mas.jnus.lib.utils.HashUtil; +import de.mas.jnus.lib.utils.StreamUtils; +import de.mas.jnus.lib.utils.Utils; +import lombok.extern.java.Log; + +@Log +public class WUDService { + public static boolean compressWUDToWUX(WUDImage image,String outputFolder) throws IOException{ + return compressWUDToWUX(image, outputFolder, "game.wux"); + } + public static boolean compressWUDToWUX(WUDImage image,String outputFolder,String filename) throws IOException{ + if(image.isCompressed()){ + log.info("Given Image is already compressed"); + return false; + } + + if(image.getWUDFileSize() != WUDImage.WUD_FILESIZE) + { + log.info("Given WUD has not the expected filesize"); + return false; + } + + Utils.createDir(outputFolder); + + String filePath; + if(!outputFolder.isEmpty()){ + filePath = outputFolder+ File.separator + filename; + }else{ + filePath = filename; + } + File outputFile = new File(filePath); + + if(outputFile.exists()){ + log.info("Couldn't compress wud, target file already exists (" + outputFile.getAbsolutePath() + ")"); + //return false; + } + + RandomAccessFile fileOutput = new RandomAccessFile(outputFile, "rw"); + + WUDImageCompressedInfo info = WUDImageCompressedInfo.getDefaultCompressedInfo(); + + byte[] header = info.getHeaderAsBytes(); + log.info("Writing header"); + fileOutput.write(header); + + int sectorTableEntryCount = (int) ((image.getWUDFileSize()+ WUDImageCompressedInfo.SECTOR_SIZE-1) / (long)WUDImageCompressedInfo.SECTOR_SIZE); + + long sectorTableStart = fileOutput.getFilePointer(); + long sectorTableEnd = Utils.align(sectorTableEntryCount *0x04,WUDImageCompressedInfo.SECTOR_SIZE); + byte[] sectorTablePlaceHolder = new byte[(int) (sectorTableEnd-sectorTableStart)]; + + fileOutput.write(sectorTablePlaceHolder); + + Map sectorHashes = new HashMap<>(); + Map sectorMapping = new TreeMap<>(); + + InputStream in = image.getWUDDiscReader().readEncryptedToInputStream(0, image.getWUDFileSize()); + + int bufferSize = WUDImageCompressedInfo.SECTOR_SIZE; + byte[] blockBuffer = new byte[bufferSize]; + ByteArrayBuffer overflow = new ByteArrayBuffer(bufferSize); + + + long written = 0; + int curSector = 0; + int realSector = 0; + + log.info("Writing sectors"); + Integer oldOffset = null; + do{ + int read = StreamUtils.getChunkFromStream(in, blockBuffer, overflow, bufferSize); + ByteArrayWrapper hash = new ByteArrayWrapper(HashUtil.hashSHA1(blockBuffer)); + + if((oldOffset = sectorHashes.get(hash)) != null){ + sectorMapping.put(curSector, oldOffset); + oldOffset = null; + }else{ //its a new sector + sectorMapping.put(curSector, realSector); + sectorHashes.put(hash, realSector); + fileOutput.write(blockBuffer); + realSector++; + } + + written += read; + curSector++; + if(curSector % 1000 == 0){ + double readMB = written / 1024.0 / 1024.0; + double writtenMB = ((long)realSector * (long)bufferSize) / 1024.0 / 1024.0; + double percent = ((double)written / image.getWUDFileSize())*100; + double ratio = 1 / (writtenMB / readMB); + System.out.println(String.format(Locale.ROOT,"Compressing (%05.2f%%) | Ratio: 1:%.2f | Read: %08.2fMB | Written: %08.2fMB",percent,ratio,readMB,writtenMB)); + } + }while(written < image.getWUDFileSize()); + + log.info("Writing sector table"); + fileOutput.seek(sectorTableStart); + ByteBuffer buffer = ByteBuffer.allocate(sectorTablePlaceHolder.length); + buffer.order(ByteOrder.LITTLE_ENDIAN); + for(Entry e: sectorMapping.entrySet()){ + buffer.putInt(e.getValue()); + } + + fileOutput.write(buffer.array()); + + fileOutput.close(); + return true; + } + + public static boolean compareWUDImage(WUDImage firstImage,WUDImage secondImage) throws IOException{ + if(firstImage.getWUDFileSize() != secondImage.getWUDFileSize()){ + log.info("Filesize if different"); + return false; + } + InputStream in1 = firstImage.getWUDDiscReader().readEncryptedToInputStream(0, WUDImage.WUD_FILESIZE); + InputStream in2 = secondImage.getWUDDiscReader().readEncryptedToInputStream(0, WUDImage.WUD_FILESIZE); + + boolean result = true; + int bufferSize = 1024*1024+19; + long totalread = 0; + byte[] blockBuffer1 = new byte[bufferSize]; + byte[] blockBuffer2 = new byte[bufferSize]; + ByteArrayBuffer overflow1 = new ByteArrayBuffer(bufferSize); + ByteArrayBuffer overflow2 = new ByteArrayBuffer(bufferSize); + long curSector = 0; + do{ + int read1 = StreamUtils.getChunkFromStream(in1, blockBuffer1, overflow1, bufferSize); + int read2 = StreamUtils.getChunkFromStream(in2, blockBuffer2, overflow2, bufferSize); + if(read1 != read2){ + log.info("Verification error"); + result = false; + break; + } + + if(!Arrays.equals(blockBuffer1,blockBuffer2)){ + log.info("Verification error"); + result = false; + break; + } + + totalread += read1; + + curSector++; + if(curSector % 100 == 0){ + System.out.println(String.format("Checked: %016X bytes (%.2f%%)", (long)curSector*(long)bufferSize,(((long)curSector*(long)bufferSize*1.0)/(WUDImage.WUD_FILESIZE))*100)); + } + }while(totalread < WUDImage.WUD_FILESIZE); + + in1.close(); + in2.close(); + log.info("Verfication done!"); + + return result; + } +} diff --git a/src/de/mas/jnus/lib/entities/TMD.java b/src/de/mas/jnus/lib/entities/TMD.java new file mode 100644 index 0000000..67dee4c --- /dev/null +++ b/src/de/mas/jnus/lib/entities/TMD.java @@ -0,0 +1,167 @@ +package de.mas.jnus.lib.entities; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.entities.content.ContentInfo; +import lombok.Getter; +import lombok.Setter; + +public class TMD { + @Getter @Setter private int signatureType; // 0x000 + @Getter @Setter private byte[] signature = new byte[0x100]; // 0x004 + @Getter @Setter private byte[] issuer = new byte[0x40]; // 0x140 + @Getter @Setter private byte version; // 0x180 + @Getter @Setter private byte CACRLVersion; // 0x181 + @Getter @Setter private byte signerCRLVersion; // 0x182 + @Getter @Setter private long systemVersion; // 0x184 + @Getter @Setter private long titleID; // 0x18C + @Getter @Setter private int titleType; // 0x194 + @Getter @Setter private short groupID; // 0x198 + @Getter @Setter private byte[] reserved = new byte[62]; // 0x19A + @Getter @Setter private int accessRights; // 0x1D8 + @Getter @Setter private short titleVersion; // 0x1DC + @Getter @Setter private short contentCount; // 0x1DE + @Getter @Setter private short bootIndex; // 0x1E0 + @Getter @Setter private byte[] SHA2 = new byte[0x20]; // 0x1E4 + @Getter @Setter private ContentInfo[] contentInfos = new ContentInfo[0x40]; + Map contentToIndex = new HashMap<>(); + Map contentToID = new HashMap<>(); + + @Getter @Setter private byte[] rawTMD = new byte[0]; + private TMD(){ + + } + + public static TMD parseTMD(File tmd) throws IOException { + if(tmd == null || !tmd.exists()){ + System.out.println("TMD input file null or doesn't exist."); + return null; + } + return parseTMD(Files.readAllBytes(tmd.toPath())); + } + + public static TMD parseTMD(byte[] input) { + + TMD result = new TMD(); + result.setRawTMD(Arrays.copyOf(input,input.length)); + byte[] signature = new byte[0x100]; + byte[] issuer = new byte[0x40]; + byte[] reserved = new byte[62]; + byte[] SHA2 = new byte[0x20]; + + ContentInfo[] contentInfos = result.getContentInfos(); + + ByteBuffer buffer = ByteBuffer.allocate(input.length); + buffer.put(input); + + //Get Signature + buffer.position(0x00); + int signatureType = buffer.getInt(); + buffer.get(signature, 0, 0x100); + + //Get Issuer + buffer.position(0x140); + buffer.get(issuer, 0, 0x40); + + //Get CACRLVersion and signerCRLVersion + buffer.position(0x180); + byte version = buffer.get(); + byte CACRLVersion = buffer.get(); + byte signerCRLVersion = buffer.get(); + + //Get title information + buffer.position(0x184); + long systemVersion = buffer.getLong(); + long titleID = buffer.getLong(); + int titleType = buffer.getInt(); + short groupID = buffer.getShort(); + buffer.position(0x19A); + + //Get title information + buffer.get(reserved, 0, 62); + + //Get accessRights,titleVersion,contentCount,bootIndex + buffer.position(0x1D8); + int accessRights = buffer.getInt(); + short titleVersion = buffer.getShort(); + short contentCount = buffer.getShort(); + short bootIndex = buffer.getShort(); + + //Get hash + buffer.position(0x1E4); + buffer.get(SHA2, 0, 0x20); + + //Get contentInfos + buffer.position(0x204); + for(int i =0;i<64;i++){ + byte[] contentInfo = new byte[0x24]; + buffer.get(contentInfo, 0, 0x24); + contentInfos[i] = ContentInfo.parseContentInfo(contentInfo); + } + + //Get Contents + for(int i =0;i getAllContents() { + return contentToIndex; + } + + public void printContents() { + for(Content c: contentToIndex.values()){ + System.out.println(c); + } + } +} diff --git a/src/de/mas/jnus/lib/entities/Ticket.java b/src/de/mas/jnus/lib/entities/Ticket.java new file mode 100644 index 0000000..4879c82 --- /dev/null +++ b/src/de/mas/jnus/lib/entities/Ticket.java @@ -0,0 +1,122 @@ +package de.mas.jnus.lib.entities; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.Arrays; + +import de.mas.jnus.lib.Settings; +import de.mas.jnus.lib.utils.Utils; +import de.mas.jnus.lib.utils.cryptography.AESDecryption; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.java.Log; + +@Log +public class Ticket { + @Getter @Setter private byte[] encryptedKey = new byte[0x10]; + @Getter @Setter private byte[] decryptedKey = new byte[0x10]; + + @Getter @Setter private byte[] IV = new byte[0x10]; + + @Getter @Setter private byte[] cert0 = new byte[0x300]; + @Getter @Setter private byte[] cert1 = new byte[0x400]; + + @Getter @Setter private byte[] rawTicket = new byte[0]; + + private Ticket(){ + + } + + public static Ticket parseTicket(File ticket) throws IOException { + if(ticket == null || !ticket.exists()){ + log.warning("Ticket input file null or doesn't exist."); + return null; + } + return parseTicket(Files.readAllBytes(ticket.toPath())); + } + + public static Ticket parseTicket(byte[] ticket) throws IOException { + if(ticket == null){ + return null; + } + + ByteBuffer buffer = ByteBuffer.allocate(ticket.length); + buffer.put(ticket); + + //read key + byte[] encryptedKey = new byte[0x10]; + buffer.position(0x1BF); + buffer.get(encryptedKey,0x00,0x10); + + //read titleID + buffer.position(0x1DC); + long titleID = buffer.getLong(); + + Ticket result = createTicket(encryptedKey,titleID); + result.setRawTicket(Arrays.copyOf(ticket, ticket.length)); + + //read certs. + byte[] cert0 = new byte[0x300]; + byte[] cert1 = new byte[0x400]; + + if(ticket.length >= 0x650){ + buffer.position(0x350); + buffer.get(cert0,0x00,0x300); + } + if(ticket.length >= 0xA50){ + buffer.position(0x650); + buffer.get(cert1,0x00,0x400); + } + + result.setCert0(cert0); + result.setCert1(cert1); + + return result; + } + + public static Ticket createTicket(byte[] encryptedKey, long titleID) { + Ticket result = new Ticket(); + result.encryptedKey = encryptedKey; + + byte[] IV = ByteBuffer.allocate(0x10).putLong(titleID).array(); + result.decryptedKey = calculateDecryptedKey(result.encryptedKey,IV); + result.setIV(IV); + + return result; + } + + private static byte[] calculateDecryptedKey(byte[] encryptedKey, byte[] IV) { + AESDecryption decryption = new AESDecryption(Settings.commonKey, IV){}; + return decryption.decrypt(encryptedKey); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(encryptedKey); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Ticket other = (Ticket) obj; + if (!Arrays.equals(encryptedKey, other.encryptedKey)) + return false; + return true; + } + + @Override + public String toString() { + return "Ticket [encryptedKey=" + Utils.ByteArrayToString(encryptedKey) + ", decryptedKey=" + + Utils.ByteArrayToString(decryptedKey) + "]"; + } +} diff --git a/src/de/mas/jnus/lib/entities/content/Content.java b/src/de/mas/jnus/lib/entities/content/Content.java new file mode 100644 index 0000000..67f2d56 --- /dev/null +++ b/src/de/mas/jnus/lib/entities/content/Content.java @@ -0,0 +1,149 @@ +package de.mas.jnus.lib.entities.content; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import de.mas.jnus.lib.Settings; +import de.mas.jnus.lib.entities.fst.FSTEntry; +import de.mas.jnus.lib.utils.Utils; +import lombok.Getter; +import lombok.Setter; + +/** + * Represents a Content + * @author Maschell + * + */ +public class Content{ + public static final short CONTENT_HASHED = 0x0002; + public static final short CONTENT_ENCRYPTED = 0x0001; + + @Getter @Setter private int ID = 0x00; + @Getter @Setter private short index = 0x00; + @Getter @Setter private short type = 0x0000; + + @Getter @Setter private long encryptedFileSize = 0; + @Getter @Setter private byte[] SHA2Hash = new byte[0x14]; + + @Getter private List entries = new ArrayList<>(); + + @Getter @Setter private ContentFSTInfo contentFSTInfo = null; + + /** + * Creates a new Content object given be the raw byte data + * @param input 0x30 byte of data from the TMD (starting at 0xB04) + * @return content object + */ + public static Content parseContent(byte[] input) { + if(input == null || input.length != 0x30){ + System.out.println("Error: invalid Content byte[] input"); + return null; + } + ByteBuffer buffer = ByteBuffer.allocate(input.length); + buffer.put(input); + buffer.position(0); + int ID = buffer.getInt(0x00); + short index = buffer.getShort(0x04); + short type = buffer.getShort(0x06); + long encryptedFileSize = buffer.getLong(0x08); + buffer.position(0x10); + byte[] hash = new byte[0x14]; + buffer.get(hash, 0x00, 0x14); + return new Content(ID, index,type,encryptedFileSize, hash); + } + + public Content(int ID, short index, short type, long encryptedFileSize, byte[] hash) { + setID(ID); + setIndex(index); + setType(type); + setEncryptedFileSize(encryptedFileSize); + setSHA2Hash(hash); + } + + /** + * Returns if the content is hashed + * @return true if hashed + */ + public boolean isHashed() { + return (type & CONTENT_HASHED) == CONTENT_HASHED; + } + /** + * Returns if the content is encrypted + * @return true if encrypted + */ + public boolean isEncrypted() { + return (type & CONTENT_ENCRYPTED) == CONTENT_ENCRYPTED; + } + + /** + * Return the filename of the encrypted content. + * It's the ID as hex with an extension + * For example: 00000000.app + * @return filename of the encrypted content + */ + public String getFilename(){ + return String.format("%08X%s", getID(),Settings.ENCRYPTED_CONTENT_EXTENTION); + } + + /** + * Adds a content to the internal entry list. + * @param entry that will be added to the content list + */ + public void addEntry(FSTEntry entry) { + getEntries().add(entry); + } + + /** + * Returns the size of the decrypted content. + * @return size of the decrypted content + */ + public long getDecryptedFileSize() { + if(isHashed()){ + return getEncryptedFileSize()/0x10000*0xFC00; + }else{ + return getEncryptedFileSize(); + } + } + + /** + * Return the filename of the decrypted content. + * It's the ID as hex with an extension + * For example: 00000000.dec + * @return filename of the decrypted content + */ + public String getFilenameDecrypted() { + return String.format("%08X%s", getID(),Settings.DECRYPTED_CONTENT_EXTENTION); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ID; + result = prime * result + Arrays.hashCode(SHA2Hash); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Content other = (Content) obj; + if (ID != other.ID) + return false; + if (!Arrays.equals(SHA2Hash, other.SHA2Hash)) + return false; + return true; + } + + @Override + public String toString() { + return "Content [ID=" + Integer.toHexString(ID) + ", index=" + Integer.toHexString(index) + ", type=" + String.format("%04X", type) + ", encryptedFileSize=" + encryptedFileSize + ", SHA2Hash=" + Utils.ByteArrayToString(SHA2Hash) + "]"; + } +} \ No newline at end of file diff --git a/src/de/mas/jnus/lib/entities/content/ContentFSTInfo.java b/src/de/mas/jnus/lib/entities/content/ContentFSTInfo.java new file mode 100644 index 0000000..47dcbbc --- /dev/null +++ b/src/de/mas/jnus/lib/entities/content/ContentFSTInfo.java @@ -0,0 +1,85 @@ +package de.mas.jnus.lib.entities.content; + +import java.nio.ByteBuffer; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +@EqualsAndHashCode +/** + * Representation on an Object of the first section + * of an FST. + * @author Maschell + * + */ +public class ContentFSTInfo { + @Getter @Setter private long offsetSector; + @Getter @Setter private long sizeSector; + @Getter @Setter private long ownerTitleID; + @Getter @Setter private int groupID; + @Getter @Setter private byte unkown; + + private static int SECTOR_SIZE = 0x8000; + + private ContentFSTInfo(){ + + } + + + /** + * Creates a new ContentFSTInfo object given be the raw byte data + * @param input 0x20 byte of data from the FST (starting at 0x20) + * @return ContentFSTInfo object + */ + public static ContentFSTInfo parseContentFST(byte[] input) { + if(input == null ||input.length != 0x20){ + System.out.println("Error: invalid ContentFSTInfo byte[] input"); + return null; + } + ContentFSTInfo cFSTInfo = new ContentFSTInfo(); + ByteBuffer buffer = ByteBuffer.allocate(input.length); + buffer.put(input); + + buffer.position(0); + int offset = buffer.getInt(); + int size = buffer.getInt(); + long ownerTitleID = buffer.getLong(); + int groupID = buffer.getInt(); + byte unkown = buffer.get(); + + cFSTInfo.setOffsetSector(offset); + cFSTInfo.setSizeSector(size); + cFSTInfo.setOwnerTitleID(ownerTitleID); + cFSTInfo.setGroupID(groupID); + cFSTInfo.setUnkown(unkown); + + return cFSTInfo; + } + + /** + * Returns the offset of of the Content in the partition + * @return offset of the content in the partition in bytes + */ + public long getOffset() { + long result = (getOffsetSector() * SECTOR_SIZE) - SECTOR_SIZE; + if(result < 0){ + return 0; + } + return result; + } + + /** + * Returns the size in bytes, not in sectors + * @return size in bytes + */ + public int getSize() { + return (int) (getSizeSector() * SECTOR_SIZE); + } + + @Override + public String toString() { + return "ContentFSTInfo [offset=" + String.format("%08X", offsetSector) + ", size=" + String.format("%08X", sizeSector) + ", ownerTitleID=" + String.format("%016X", ownerTitleID) + ", groupID=" + + String.format("%08X", groupID) + ", unkown=" + unkown + "]"; + } +} diff --git a/src/de/mas/jnus/lib/entities/content/ContentInfo.java b/src/de/mas/jnus/lib/entities/content/ContentInfo.java new file mode 100644 index 0000000..0edc6b1 --- /dev/null +++ b/src/de/mas/jnus/lib/entities/content/ContentInfo.java @@ -0,0 +1,65 @@ +package de.mas.jnus.lib.entities.content; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +@EqualsAndHashCode +/** + * Represents a Object from the TMD before the actual Content Section. + * @author Maschell + * + */ +public class ContentInfo{ + @Getter @Setter private short indexOffset = 0x00; + @Getter @Setter private short commandCount = 0x00; + @Getter @Setter private byte[] SHA2Hash = new byte[0x20]; + + public ContentInfo() { + this((short) 0); + } + + public ContentInfo(short contentCount) { + this((short) 0,contentCount); + } + public ContentInfo(short indexOffset,short commandCount) { + this(indexOffset,commandCount,null); + } + public ContentInfo(short indexOffset,short commandCount,byte[] SHA2Hash) { + setIndexOffset(indexOffset); + setCommandCount(commandCount); + setSHA2Hash(SHA2Hash); + } + + /** + * Creates a new ContentInfo object given be the raw byte data + * @param input 0x24 byte of data from the TMD (starting at 0x208) + * @return ContentFSTInfo object + */ + public static ContentInfo parseContentInfo(byte[] input){ + if(input == null || input.length != 0x24){ + System.out.println("Error: invalid ContentInfo byte[] input"); + return null; + } + + ByteBuffer buffer = ByteBuffer.allocate(input.length); + buffer.put(input); + buffer.position(0); + short indexOffset = buffer.getShort(0x00); + short commandCount = buffer.getShort(0x02); + + byte[] sha2hash = new byte[0x20]; + buffer.position(0x04); + buffer.get(sha2hash, 0x00, 0x20); + + return new ContentInfo(indexOffset, commandCount, sha2hash); + } + + @Override + public String toString() { + return "ContentInfo [indexOffset=" + indexOffset + ", commandCount=" + commandCount + ", SHA2Hash=" + Arrays.toString(SHA2Hash) + "]"; + } +} diff --git a/src/de/mas/jnus/lib/entities/fst/FST.java b/src/de/mas/jnus/lib/entities/fst/FST.java new file mode 100644 index 0000000..f7ed1ac --- /dev/null +++ b/src/de/mas/jnus/lib/entities/fst/FST.java @@ -0,0 +1,81 @@ +package de.mas.jnus.lib.entities.fst; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.entities.content.ContentFSTInfo; +import de.mas.jnus.lib.utils.ByteUtils; +import lombok.Getter; +import lombok.Setter; +/** + * Represents the FST + * @author Maschell + * + */ +public class FST { + @Getter @Setter private FSTEntry root = FSTEntry.getRootFSTEntry(); + + @Getter @Setter private int unknown; + @Getter @Setter private int contentCount = 0; + + @Getter @Setter private Map contentFSTInfos = new HashMap<>(); + + private FST(){ + + } + + /** + * Creates a FST by the given raw byte data + * @param fstData raw decrypted FST data + * @param contentsMappedByIndex map of index/content + * @return + */ + public static FST parseFST(byte[] fstData,Map contentsMappedByIndex){ + if(!Arrays.equals(Arrays.copyOfRange(fstData, 0, 3), new byte[]{0x46,0x53,0x54})){ + throw new IllegalArgumentException("Not a FST. Maybe a wrong key?"); + } + FST result = new FST(); + + int unknownValue = ByteUtils.getIntFromBytes(fstData, 0x04); + int contentCount = ByteUtils.getIntFromBytes(fstData, 0x08); + + int contentfst_offset = 0x20; + int contentfst_size = 0x20*contentCount; + + int fst_offset = contentfst_offset+contentfst_size; + int fileCount = ByteUtils.getIntFromBytes(fstData, fst_offset + 0x08); + int fst_size = fileCount*0x10; + + int nameOff = fst_offset + fst_size; + int nameSize = nameOff +1; + + //Get list with null-terminated Strings. Ends with \0\0. + for(int i = nameOff;i contentFSTInfos = result.getContentFSTInfos(); + for(int i = 0;i children = null; + + @Getter @Setter private short flags; + + @Getter @Setter private long fileSize = 0; + @Getter @Setter private long fileOffset = 0; + + @Getter private Content content = null; + + @Getter @Setter private byte[] hash = new byte[0x14]; + + @Getter @Setter private boolean isDir = false; + @Getter @Setter private boolean isRoot = false; + @Getter @Setter private boolean notInPackage = false; + + @Getter @Setter private short contentFSTID = 0; + + public FSTEntry(){ + + } + + /** + * Creates and returns a new FST Entry + * @return + */ + public static FSTEntry getRootFSTEntry(){ + FSTEntry entry = new FSTEntry(); + entry.setRoot(true); + return entry; + } + + public String getFullPath() { + return getPath() + getFilename(); + } + + public int getEntryCount() { + int count = 1; + for(FSTEntry entry : getChildren()){ + count += entry.getEntryCount(); + } + return count; + } + + public void addChildren(FSTEntry fstEntry) { + getChildren().add(fstEntry); + fstEntry.setParent(this); + } + + public List getChildren() { + if(children == null){ + children = new ArrayList<>(); + } + return children; + } + + public List getDirChildren(){ + return getDirChildren(false); + } + + public List getDirChildren(boolean all){ + List result = new ArrayList<>(); + for(FSTEntry child : getChildren()){ + if(child.isDir() && (all || !child.isNotInPackage())){ + result.add(child); + } + } + return result; + } + + public List getFileChildren(){ + return getFileChildren(false); + } + + public List getFileChildren(boolean all){ + List result = new ArrayList<>(); + for(FSTEntry child : getChildren()){ + if((all && !child.isDir() || !child.isDir())){ + result.add(child); + } + } + return result; + } + + public List getFSTEntriesByContent(Content content) { + List entries = new ArrayList<>(); + if(this.content == null){ + log.warning("Error in getFSTEntriesByContent, content null"); + System.exit(0); + }else{ + if(this.content.equals(content)){ + entries.add(this); + } + } + for(FSTEntry child : getChildren()){ + entries.addAll(child.getFSTEntriesByContent(content)); + } + return entries; + } + + public void setContent(Content content) { + if(content == null){ + log.warning("Can't set content for "+ getFilename() + ": Content it null"); + System.out.println(); + return; + } + this.content = content; + content.addEntry(this); + } + + public long getFileOffsetBlock() { + if(getContent().isHashed()){ + return (getFileOffset()/0xFC00) * 0x10000; + }else{ + return getFileOffset(); + } + } + + public void printRecursive(int space){ + for(int i = 0;i contentsByIndex,Map contentsFSTByIndex) { + int totalEntries = ByteUtils.getIntFromBytes(fstSection, 0x08); + + int level = 0; + int[] LEntry = new int[16]; + int[] Entry = new int[16]; + + HashMap fstEntryToOffsetMap = new HashMap<>(); + + rootEntry.setDir(true); + Entry[level] = 0; + LEntry[level++] = 0; + + fstEntryToOffsetMap.put(0,rootEntry); + + for(int i = 1;i< totalEntries;i++){ + int entryOffset = i; + if( level > 0){ + while( LEntry[level-1] == i ){level--;} + } + + byte[] curEntry = Arrays.copyOfRange(fstSection,i*0x10,(i+1)*0x10); + + FSTEntry entry = new FSTEntry(); + + String path = getFullPath(level, fstSection, namesSection, Entry); + String filename = getName(curEntry,namesSection); + + long fileOffset = ByteUtils.getIntFromBytes(curEntry, 0x04); + long fileSize = ByteUtils.getUnsingedIntFromBytes(curEntry, 0x08); + + short flags = ByteUtils.getShortFromBytes(curEntry, 0x0C); + short contentIndex = ByteUtils.getShortFromBytes(curEntry, 0x0E); + + if((curEntry[0] & FSTEntry.FSTEntry_notInNUS) == FSTEntry.FSTEntry_notInNUS){ + entry.setNotInPackage(true); + } + + if((curEntry[0] & FSTEntry.FSTEntry_DIR) == FSTEntry.FSTEntry_DIR){ + entry.setDir(true); + int parentOffset = (int) fileOffset; + int nextOffset = (int) fileSize; + + FSTEntry parent = fstEntryToOffsetMap.get(parentOffset); + if(parent != null){ + log.info("no parent found for a FSTEntry"); + parent.addChildren(entry); + } + + Entry[level] = i; + LEntry[level++] = nextOffset ; + + if( level > 15 ){ + log.warning("level > 15"); + break; + } + }else{ + entry.setFileOffset(fileOffset<<5); + entry.setFileSize(fileSize); + FSTEntry parent = fstEntryToOffsetMap.get(Entry[level-1]); + if(parent != null){ + parent.addChildren(entry); + }else{ + log.warning(entryOffset +"couldn't find parent @ " + Entry[level-1]); + } + } + + entry.setFlags(flags); + entry.setFilename(filename); + entry.setPath(path); + + if(contentsByIndex != null){ + Content content = contentsByIndex.get((int)contentIndex); + if(content == null){ + log.warning("Content for FST Entry not found"); + }else{ + entry.setContent(content); + + ContentFSTInfo contentFSTInfo = contentsFSTByIndex.get((int)contentIndex); + if(contentFSTInfo == null){ + log.warning("ContentFSTInfo for FST Entry not found"); + }else{ + content.setContentFSTInfo(contentFSTInfo); + } + } + } + + entry.setContentFSTID(contentIndex); + + fstEntryToOffsetMap.put(entryOffset, entry); + } + } + + private static int getNameOffset(byte[] curEntry) { + //Its a 24bit number. We overwrite the first byte, then we can read it as an Integer. + //But at first we make a copy. + byte[] entryData = Arrays.copyOf(curEntry, curEntry.length); + entryData[0] = 0; + return ByteUtils.getIntFromBytes(entryData, 0); + } + + public static String getName(byte[] data, byte[] namesSection){ + int nameOffset = getNameOffset(data); + + int j = 0; + + while(namesSection[nameOffset + j] != 0){j++;} + return(new String(Arrays.copyOfRange(namesSection,nameOffset, nameOffset + j))); + } + + + public static String getFullPath(int level,byte[] fstSection,byte[] namesSection, int[] Entry){ + StringBuilder sb = new StringBuilder(); + for(int i=0; i < level; i++){ + int entryOffset = Entry[i]*0x10; + byte[] entryData = Arrays.copyOfRange(fstSection, entryOffset,entryOffset + 10); + String entryName = getName(entryData,namesSection); + + sb.append(entryName).append(File.separator); + } + return sb.toString(); + } +} diff --git a/src/de/mas/jnus/lib/implementations/NUSDataProvider.java b/src/de/mas/jnus/lib/implementations/NUSDataProvider.java new file mode 100644 index 0000000..00ba2af --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/NUSDataProvider.java @@ -0,0 +1,115 @@ +package de.mas.jnus.lib.implementations; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +import com.sun.istack.internal.NotNull; + +import de.mas.jnus.lib.NUSTitle; +import de.mas.jnus.lib.Settings; +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.utils.FileUtils; +import de.mas.jnus.lib.utils.Utils; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.java.Log; + +@Log +/** + * Service Methods for loading NUS/Content data from + * different sources + * @author Maschell + * + */ +public abstract class NUSDataProvider { + + @Getter @Setter private NUSTitle NUSTitle = null; + + public NUSDataProvider () { + } + + /** + * Saves the given content encrypted with his .h3 file in the given directory. + * The Target directory will be created if it's missing. + * If the content is not hashed, no .h3 will be saved + * @param content Content that should be saved + * @param outputFolder Target directory where the files will be stored in. + * @throws IOException + */ + public void saveEncryptedContentWithH3Hash(@NotNull Content content,@NotNull String outputFolder) throws IOException { + saveContentH3Hash(content, outputFolder); + saveEncryptedContent(content, outputFolder); + } + + /** + * Saves the .h3 file of the given content into the given directory. + * The Target directory will be created if it's missing. + * If the content is not hashed, no .h3 will be saved + * @param content The content of which the h3 hashes should be saved + * @param outputFolder + * @throws IOException + */ + public void saveContentH3Hash(@NotNull Content content,@NotNull String outputFolder) throws IOException { + if(!content.isHashed()){ + return; + } + byte[] hash = getContentH3Hash(content); + if(hash == null){ + return; + } + String h3Filename = String.format("%08X%s", content.getID(),Settings.H3_EXTENTION); + File output = new File(outputFolder + File.separator + h3Filename); + if(output.exists() && output.length() == hash.length){ + return; + } + + log.info("Saving " + h3Filename +" "); + + FileUtils.saveByteArrayToFile(output, hash); + } + + /** + * Saves the given content encrypted in the given directory. + * The Target directory will be created if it's missing. + * If the content is not encrypted at all, it will be just saved anyway. + * @param content Content that should be saved + * @param outputFolder Target directory where the files will be stored in. + * @throws IOException + */ + public void saveEncryptedContent(@NotNull Content content,@NotNull String outputFolder) throws IOException { + Utils.createDir(outputFolder); + InputStream inputStream = getInputStreamFromContent(content, 0); + if(inputStream == null){ + log.info("Couldn't save encrypted content. Input stream was null"); + return; + } + + File output = new File(outputFolder + File.separator + content.getFilename()); + if(output.exists()){ + if(output.length() == content.getEncryptedFileSize()){ + log.info("Encrypted content alreadys exists, skipped"); + return; + }else{ + log.info("Encrypted content alreadys exists, but the length is not as expected. Saving it again"); + } + } + FileUtils.saveInputStreamToFile(output,inputStream,content.getEncryptedFileSize()); + } + + /** + * + * @param content + * @param offset + * @return + * @throws IOException + */ + public abstract InputStream getInputStreamFromContent(Content content,long offset) throws IOException ; + + public abstract byte[] getContentH3Hash(Content content) throws IOException; + public abstract byte[] getRawTMD() throws IOException; + public abstract byte[] getRawTicket() throws IOException; + public abstract byte[] getRawCert() throws IOException; + + public abstract void cleanup() throws IOException; +} diff --git a/src/de/mas/jnus/lib/implementations/NUSDataProviderLocal.java b/src/de/mas/jnus/lib/implementations/NUSDataProviderLocal.java new file mode 100644 index 0000000..744af0e --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/NUSDataProviderLocal.java @@ -0,0 +1,73 @@ +package de.mas.jnus.lib.implementations; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; + +import de.mas.jnus.lib.Settings; +import de.mas.jnus.lib.entities.content.Content; +import lombok.Getter; +import lombok.Setter; + +public class NUSDataProviderLocal extends NUSDataProvider { + @Getter @Setter private String localPath = ""; + + public NUSDataProviderLocal() { + } + + public String getFilePathOnDisk(Content c) { + return getLocalPath() + File.separator + c.getFilename(); + } + + @Override + public InputStream getInputStreamFromContent(Content content, long offset) throws IOException { + File filepath = new File(getFilePathOnDisk(content)); + if(!filepath.exists()){ + + return null; + } + InputStream in = new FileInputStream(filepath); + in.skip(offset); + return in; + } + + @Override + public byte[] getContentH3Hash(Content content) throws IOException { + String h3Path = getLocalPath() + File.separator + String.format("%08X.h3", content.getID()); + File h3File = new File(h3Path); + if(!h3File.exists()){ + return null; + } + return Files.readAllBytes(h3File.toPath()); + } + + @Override + public byte[] getRawTMD() throws IOException { + String inputPath = getLocalPath(); + String tmdPath = inputPath + File.separator + Settings.TMD_FILENAME; + File tmdFile = new File(tmdPath); + return Files.readAllBytes(tmdFile.toPath()); + } + + @Override + public byte[] getRawTicket() throws IOException { + String inputPath = getLocalPath(); + String ticketPath = inputPath + File.separator + Settings.TICKET_FILENAME; + File ticketFile = new File(ticketPath); + return Files.readAllBytes(ticketFile.toPath()); + } + + @Override + public void cleanup() throws IOException { + } + + @Override + public byte[] getRawCert() throws IOException { + String inputPath = getLocalPath(); + String certPath = inputPath + File.separator + Settings.CERT_FILENAME; + File certFile = new File(certPath); + return Files.readAllBytes(certFile.toPath()); + } +} diff --git a/src/de/mas/jnus/lib/implementations/NUSDataProviderRemote.java b/src/de/mas/jnus/lib/implementations/NUSDataProviderRemote.java new file mode 100644 index 0000000..af56753 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/NUSDataProviderRemote.java @@ -0,0 +1,62 @@ +package de.mas.jnus.lib.implementations; + +import java.io.IOException; +import java.io.InputStream; + +import de.mas.jnus.lib.Settings; +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.utils.download.NUSDownloadService; +import lombok.Getter; +import lombok.Setter; + +public class NUSDataProviderRemote extends NUSDataProvider { + @Getter @Setter private int version = Settings.LATEST_TMD_VERSION; + @Getter @Setter private long titleID = 0L; + + @Override + public InputStream getInputStreamFromContent(Content content, long fileOffsetBlock) throws IOException { + NUSDownloadService downloadService = NUSDownloadService.getDefaultInstance(); + InputStream in = downloadService.getInputStreamForURL(getRemoteURL(content),fileOffsetBlock); + return in; + } + + private String getRemoteURL(Content content) { + return String.format("%016x/%08X", getNUSTitle().getTMD().getTitleID(),content.getID()); + } + + @Override + public byte[] getContentH3Hash(Content content) throws IOException { + NUSDownloadService downloadService = NUSDownloadService.getDefaultInstance(); + String url = getRemoteURL(content) + ".h3"; + return downloadService.downloadToByteArray(url); + } + + @Override + public byte[] getRawTMD() throws IOException { + NUSDownloadService downloadService = NUSDownloadService.getDefaultInstance(); + + long titleID = getTitleID(); + int version = getVersion(); + + return downloadService.downloadTMDToByteArray(titleID, version); + } + + @Override + public byte[] getRawTicket() throws IOException { + NUSDownloadService downloadService = NUSDownloadService.getDefaultInstance(); + + long titleID = getNUSTitle().getTMD().getTitleID(); + + return downloadService.downloadTicketToByteArray(titleID); + } + + @Override + public void cleanup() throws IOException { + // TODO Auto-generated method stub + } + + @Override + public byte[] getRawCert() throws IOException { + return nulL; + } +} diff --git a/src/de/mas/jnus/lib/implementations/NUSDataProviderWUD.java b/src/de/mas/jnus/lib/implementations/NUSDataProviderWUD.java new file mode 100644 index 0000000..f468f60 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/NUSDataProviderWUD.java @@ -0,0 +1,102 @@ +package de.mas.jnus.lib.implementations; + +import java.io.IOException; +import java.io.InputStream; + +import de.mas.jnus.lib.Settings; +import de.mas.jnus.lib.entities.TMD; +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.implementations.wud.parser.WUDInfo; +import de.mas.jnus.lib.implementations.wud.parser.WUDPartition; +import de.mas.jnus.lib.implementations.wud.parser.WUDPartitionHeader; +import de.mas.jnus.lib.implementations.wud.reader.WUDDiscReader; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.java.Log; +@Log +public class NUSDataProviderWUD extends NUSDataProvider { + @Getter @Setter private WUDInfo WUDInfo = null; + + @Setter(AccessLevel.PRIVATE) private TMD TMD = null; + + public NUSDataProviderWUD() { + super(); + } + + public long getOffsetInWUD(Content content) { + if(content.getContentFSTInfo() == null){ + return getAbsoluteReadOffset(); + }else{ + return getAbsoluteReadOffset() + content.getContentFSTInfo().getOffset(); + } + } + + public long getAbsoluteReadOffset(){ + return (long)Settings.WIIU_DECRYPTED_AREA_OFFSET + getGamePartition().getPartitionOffset(); + } + + @Override + public InputStream getInputStreamFromContent(Content content, long fileOffsetBlock) throws IOException { + WUDDiscReader discReader = getDiscReader(); + long offset = getOffsetInWUD(content) + fileOffsetBlock; + return discReader.readEncryptedToInputStream(offset, content.getEncryptedFileSize()); + } + + @Override + public byte[] getContentH3Hash(Content content) throws IOException { + + if(getGamePartitionHeader() == null){ + log.warning("GamePartitionHeader is null"); + return null; + } + + if(!getGamePartitionHeader().isCalculatedHashes()){ + log.info("Calculating h3 hashes"); + getGamePartitionHeader().calculateHashes(getTMD().getAllContents()); + + } + return getGamePartitionHeader().getH3Hash(content); + + } + + private TMD getTMD() { + if(TMD == null){ + setTMD(de.mas.jnus.lib.entities.TMD.parseTMD(getRawTMD())); + } + return TMD; + } + + @Override + public byte[] getRawTMD() { + return getWUDInfo().getGamePartition().getRawTMD(); + } + + @Override + public byte[] getRawTicket() { + return getWUDInfo().getGamePartition().getRawTicket(); + } + + @Override + public byte[] getRawCert() throws IOException { + return getWUDInfo().getGamePartition().getRawCert(); + } + + + public WUDPartition getGamePartition(){ + return getWUDInfo().getGamePartition(); + } + + public WUDPartitionHeader getGamePartitionHeader(){ + return getGamePartition().getPartitionHeader(); + } + + public WUDDiscReader getDiscReader(){ + return getWUDInfo().getWUDDiscReader(); + } + + @Override + public void cleanup() { + } + +} diff --git a/src/de/mas/jnus/lib/implementations/NUSDataProviderWoomy.java b/src/de/mas/jnus/lib/implementations/NUSDataProviderWoomy.java new file mode 100644 index 0000000..0097a75 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/NUSDataProviderWoomy.java @@ -0,0 +1,95 @@ +package de.mas.jnus.lib.implementations; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; + +import de.mas.jnus.lib.Settings; +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.implementations.woomy.WoomyInfo; +import de.mas.jnus.lib.implementations.woomy.WoomyZipFile; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import lombok.extern.java.Log; + +@Log +public class NUSDataProviderWoomy extends NUSDataProvider{ + @Getter @Setter private WoomyInfo woomyInfo; + @Setter private WoomyZipFile woomyZipFile; + + @Override + public InputStream getInputStreamFromContent(@NonNull Content content, long fileOffsetBlock) throws IOException { + WoomyZipFile zipFile = getSharedWoomyZipFile(); + ZipEntry entry = getWoomyInfo().getContentFiles().get(content.getFilename().toLowerCase()); + if(entry == null){ + log.warning("Inputstream for " + content.getFilename() + " not found"); + System.exit(1); + } + return zipFile.getInputStream(entry); + } + + @Override + public byte[] getContentH3Hash(Content content) throws IOException { + ZipEntry entry = getWoomyInfo().getContentFiles().get(content.getFilename().toLowerCase()); + if(entry != null){ + WoomyZipFile zipFile = getNewWoomyZipFile(); + byte[] result = zipFile.getEntryAsByte(entry); + zipFile.close(); + return result; + } + return null; + } + + @Override + public byte[] getRawTMD() throws IOException { + ZipEntry entry = getWoomyInfo().getContentFiles().get(Settings.TMD_FILENAME); + if(entry == null){ + log.warning(Settings.TMD_FILENAME + " not found in woomy file"); + System.exit(1); + } + WoomyZipFile zipFile = getNewWoomyZipFile(); + byte[] result = zipFile.getEntryAsByte(entry); + zipFile.close(); + return result; + } + + @Override + public byte[] getRawTicket() throws IOException { + ZipEntry entry = getWoomyInfo().getContentFiles().get(Settings.TICKET_FILENAME); + if(entry == null){ + log.warning(Settings.TICKET_FILENAME + " not found in woomy file"); + System.exit(1); + } + + WoomyZipFile zipFile = getNewWoomyZipFile(); + byte[] result = zipFile.getEntryAsByte(entry); + zipFile.close(); + return result; + } + + public WoomyZipFile getSharedWoomyZipFile() throws ZipException, IOException { + if(woomyZipFile == null || woomyZipFile.isClosed()){ + woomyZipFile = getNewWoomyZipFile(); + } + return woomyZipFile; + } + + private WoomyZipFile getNewWoomyZipFile() throws ZipException, IOException { + return new WoomyZipFile(getWoomyInfo().getWoomyFile()); + } + + @Override + public void cleanup() throws IOException { + if(woomyZipFile != null && woomyZipFile.isClosed()){ + woomyZipFile.close(); + } + } + + @Override + public byte[] getRawCert() throws IOException { + // TODO Auto-generated method stub + return null; + } +} diff --git a/src/de/mas/jnus/lib/implementations/woomy/WoomyInfo.java b/src/de/mas/jnus/lib/implementations/woomy/WoomyInfo.java new file mode 100644 index 0000000..5f59a51 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/woomy/WoomyInfo.java @@ -0,0 +1,14 @@ +package de.mas.jnus.lib.implementations.woomy; + +import java.io.File; +import java.util.Map; +import java.util.zip.ZipEntry; + +import lombok.Data; + +@Data +public class WoomyInfo { + private String name; + private File woomyFile; + private Map contentFiles; +} diff --git a/src/de/mas/jnus/lib/implementations/woomy/WoomyMeta.java b/src/de/mas/jnus/lib/implementations/woomy/WoomyMeta.java new file mode 100644 index 0000000..02586cc --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/woomy/WoomyMeta.java @@ -0,0 +1,39 @@ +package de.mas.jnus.lib.implementations.woomy; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class WoomyMeta { + private String name; + private int icon; + private List entries; + + public void addEntry(String name,String folder, int entryCount){ + WoomyEntry entry = new WoomyEntry(name, folder, entryCount); + getEntries().add(entry); + } + + public List getEntries(){ + if(entries == null){ + setEntries(new ArrayList<>()); + } + return entries; + } + + @Data + public class WoomyEntry { + + public WoomyEntry(String name, String folder, int entryCount) { + setName(name); + setFolder(folder); + setEntryCount(entryCount); + } + + private String name; + private String folder; + private int entryCount; + } +} diff --git a/src/de/mas/jnus/lib/implementations/woomy/WoomyMetaParser.java b/src/de/mas/jnus/lib/implementations/woomy/WoomyMetaParser.java new file mode 100644 index 0000000..66b3f99 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/woomy/WoomyMetaParser.java @@ -0,0 +1,62 @@ +package de.mas.jnus.lib.implementations.woomy; + +import java.io.InputStream; + +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import de.mas.jnus.lib.utils.XMLParser; +import lombok.extern.java.Log; + +@Log +public class WoomyMetaParser extends XMLParser{ + private static final String WOOMY_METADATA_NAME = "name"; + private static final String WOOMY_METADATA_ICON = "icon"; + + private static final String WOOMY_METADATA_ENTRIES = "entries"; + private static final String WOOMY_METADATA_ENTRY_NAME = "name"; + private static final String WOOMY_METADATA_ENTRY_FOLDER = "folder"; + private static final String WOOMY_METADATA_ENTRY_ENTRIES = "entries"; + /** + * Overwrite the default constructor to force the user to use the factory. + */ + private WoomyMetaParser(){ + } + + public static WoomyMeta parseMeta(InputStream data){ + XMLParser parser = new WoomyMetaParser(); + WoomyMeta result = new WoomyMeta(); + try { + parser.loadDocument(data); + } catch(Exception e){ + log.info("Error while loading the data into the WoomyMetaParser"); + return null; + } + + String name = parser.getValueOfElement(WOOMY_METADATA_NAME); + if(name != null && !name.isEmpty()){ + result.setName(name); + } + + String icon = parser.getValueOfElement(WOOMY_METADATA_ICON); + if(icon != null && !icon.isEmpty()){ + int icon_val = Integer.parseInt(icon); + result.setIcon(icon_val); + } + Node entries_node = parser.getNodeByValue(WOOMY_METADATA_ENTRIES); + + NodeList entry_list = entries_node.getChildNodes(); + for(int i = 0;i contentFiles = loadFileList(zipFile,regEx); + result.setContentFiles(contentFiles); + + }catch(ZipException e){ + + } + return result; + } + + private static Map loadFileList(@NotNull ZipFile zipFile, @NotNull String regEx) { + Enumeration zipEntries = zipFile.entries(); + Map result = new HashMap<>(); + Pattern pattern = Pattern.compile(regEx); + while (zipEntries.hasMoreElements()) { + ZipEntry entry = (ZipEntry) zipEntries.nextElement(); + if(!entry.isDirectory()){ + String entryName = entry.getName(); + Matcher matcher = pattern.matcher(entryName); + if(matcher.matches()){ + String[] tokens = entryName.split("[\\\\|/]"); //We only want the filename! + String filename = tokens[tokens.length - 1]; + result.put(filename.toLowerCase(), entry); + } + } + } + return result; + } +} diff --git a/src/de/mas/jnus/lib/implementations/woomy/WoomyZipFile.java b/src/de/mas/jnus/lib/implementations/woomy/WoomyZipFile.java new file mode 100644 index 0000000..f50759f --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/woomy/WoomyZipFile.java @@ -0,0 +1,35 @@ +package de.mas.jnus.lib.implementations.woomy; + +import java.io.File; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; + +import de.mas.jnus.lib.utils.StreamUtils; +import lombok.Getter; +import lombok.Setter; + +/** + * Woomy files are just zip files. This class is just the + * normal ZipFile class extended by an "isClosed" attribute. + * @author Maschell + * + */ +public class WoomyZipFile extends ZipFile { + @Getter @Setter boolean isClosed; + public WoomyZipFile(File file) throws ZipException, IOException { + super(file); + } + + @Override + public void close() throws IOException { + super.close(); + setClosed(true); + } + + public byte[] getEntryAsByte(ZipEntry entry) throws IOException { + return StreamUtils.getBytesFromStream(getInputStream(entry),(int) entry.getSize()); + } + +} diff --git a/src/de/mas/jnus/lib/implementations/wud/WUDImage.java b/src/de/mas/jnus/lib/implementations/wud/WUDImage.java new file mode 100644 index 0000000..bef9b73 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/WUDImage.java @@ -0,0 +1,108 @@ +package de.mas.jnus.lib.implementations.wud; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteOrder; +import java.util.HashMap; +import java.util.Map; + +import de.mas.jnus.lib.implementations.wud.reader.WUDDiscReader; +import de.mas.jnus.lib.implementations.wud.reader.WUDDiscReaderCompressed; +import de.mas.jnus.lib.implementations.wud.reader.WUDDiscReaderSplitted; +import de.mas.jnus.lib.implementations.wud.reader.WUDDiscReaderUncompressed; +import de.mas.jnus.lib.utils.ByteUtils; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.java.Log; + +@Log +public class WUDImage { + public static long WUD_FILESIZE = 0x5D3A00000L; + + @Getter @Setter private File fileHandle = null; + @Getter @Setter private WUDImageCompressedInfo compressedInfo = null; + + @Getter @Setter private boolean isCompressed = false; + @Getter @Setter private boolean isSplitted = false; + private long inputFileSize = 0L; + @Setter private WUDDiscReader WUDDiscReader = null; + + public WUDImage(File file) throws IOException{ + if(file == null || !file.exists()){ + System.out.println("WUD file is null or does not exist"); + System.exit(1); + } + + RandomAccessFile fileStream = new RandomAccessFile(file,"r"); + fileStream.seek(0); + byte[] wuxheader = new byte[WUDImageCompressedInfo.WUX_HEADER_SIZE]; + fileStream.read(wuxheader); + WUDImageCompressedInfo compressedInfo = new WUDImageCompressedInfo(wuxheader); + + if(compressedInfo.isWUX()){ + log.info("Image is compressed"); + setCompressed(true); + Map indexTable = new HashMap<>(); + long offsetIndexTable = compressedInfo.getOffsetIndexTable(); + fileStream.seek(offsetIndexTable); + + byte[] tableData = new byte[(int) (compressedInfo.getIndexTableEntryCount() * 0x04)]; + fileStream.read(tableData); + int cur_offset = 0x00; + for(long i = 0;i indexTable = new HashMap<>(); + + public WUDImageCompressedInfo(byte[] headData){ + if(headData.length < WUX_HEADER_SIZE){ + System.out.println("WUX header length wrong"); + System.exit(1); + } + setMagic0(ByteUtils.getIntFromBytes(headData, 0x00,ByteOrder.LITTLE_ENDIAN)); + setMagic1(ByteUtils.getIntFromBytes(headData, 0x04,ByteOrder.LITTLE_ENDIAN)); + setSectorSize(ByteUtils.getIntFromBytes(headData, 0x08,ByteOrder.LITTLE_ENDIAN)); + setFlags(ByteUtils.getIntFromBytes(headData, 0x0C,ByteOrder.LITTLE_ENDIAN)); + setUncompressedSize(ByteUtils.getLongFromBytes(headData, 0x10,ByteOrder.LITTLE_ENDIAN)); + + calculateOffsets(); + } + + public static WUDImageCompressedInfo getDefaultCompressedInfo(){ + return new WUDImageCompressedInfo(SECTOR_SIZE, 0, WUDImage.WUD_FILESIZE); + } + + public WUDImageCompressedInfo(int sectorSize,int flags, long uncompressedSize) { + setMagic0(WUX_MAGIC_0); + setMagic1(WUX_MAGIC_1); + setSectorSize(sectorSize); + setFlags(flags); + setUncompressedSize(uncompressedSize); + } + + private void calculateOffsets() { + long indexTableEntryCount = (getUncompressedSize()+ getSectorSize()-1) / getSectorSize(); + setIndexTableEntryCount(indexTableEntryCount); + setOffsetIndexTable(0x20); + long offsetSectorArray = (getOffsetIndexTable() + ((long)getIndexTableEntryCount() * 0x04L)); + // align to SECTOR_SIZE + offsetSectorArray = (offsetSectorArray + (long)(getSectorSize()-1)); + offsetSectorArray = offsetSectorArray - (offsetSectorArray%(long)getSectorSize()); + setOffsetSectorArray(offsetSectorArray); + // read index table + setIndexTableSize(0x04 * getIndexTableEntryCount()); + } + + public boolean isWUX() { + return (getMagic0() == WUX_MAGIC_0 && getMagic1() == WUX_MAGIC_1); + } + + @Override + public String toString() { + return "WUDImageCompressedInfo [magic0=" + String.format("0x%08X", magic0) + ", magic1=" + String.format("0x%08X", magic1) + ", sectorSize=" + String.format("0x%08X", sectorSize) + + ", uncompressedSize=" + String.format("0x%016X", uncompressedSize) + ", flags=" + String.format("0x%08X", flags) + ", indexTableEntryCount=" + + indexTableEntryCount + ", offsetIndexTable=" + offsetIndexTable + ", offsetSectorArray=" + + offsetSectorArray + ", indexTableSize=" + indexTableSize + "]"; + } + + public long getSectorIndex(int sectorIndex) { + return getIndexTable().get(sectorIndex); + } + + public void setIndexTable(Map indexTable) { + this.indexTable = indexTable; + } + + public byte[] getHeaderAsBytes() { + ByteBuffer result = ByteBuffer.allocate(WUX_HEADER_SIZE); + result.order(ByteOrder.LITTLE_ENDIAN); + result.putInt(getMagic0()); + result.putInt(getMagic1()); + result.putInt(getSectorSize()); + result.putInt(getFlags()); + result.putLong(getUncompressedSize()); + return result.array(); + } +} diff --git a/src/de/mas/jnus/lib/implementations/wud/parser/WUDGamePartition.java b/src/de/mas/jnus/lib/implementations/wud/parser/WUDGamePartition.java new file mode 100644 index 0000000..d6f94a7 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/parser/WUDGamePartition.java @@ -0,0 +1,12 @@ +package de.mas.jnus.lib.implementations.wud.parser; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper=true) +public class WUDGamePartition extends WUDPartition { + private byte[] rawTMD; + private byte[] rawCert; + private byte[] rawTicket; +} diff --git a/src/de/mas/jnus/lib/implementations/wud/parser/WUDInfo.java b/src/de/mas/jnus/lib/implementations/wud/parser/WUDInfo.java new file mode 100644 index 0000000..b6e30c2 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/parser/WUDInfo.java @@ -0,0 +1,41 @@ +package de.mas.jnus.lib.implementations.wud.parser; + +import java.util.Map; +import java.util.Map.Entry; + +import de.mas.jnus.lib.implementations.wud.reader.WUDDiscReader; +import lombok.AccessLevel; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +@Data +public class WUDInfo { + private byte[] titleKey = null; + + private WUDDiscReader WUDDiscReader = null; + private Map partitions = null; + + @Getter(AccessLevel.PRIVATE) @Setter(AccessLevel.PROTECTED) + private String gamePartitionName; + + WUDInfo(){ + } + + + private WUDGamePartition cachedGamePartition = null; + public WUDGamePartition getGamePartition(){ + if(cachedGamePartition == null){ + cachedGamePartition = findGamePartition(); + } + return cachedGamePartition; + } + private WUDGamePartition findGamePartition() { + for(Entry e: getPartitions().entrySet()){ + if(e.getKey().equals(getGamePartitionName())){ + return (WUDGamePartition) e.getValue(); + } + } + return null; + } +} diff --git a/src/de/mas/jnus/lib/implementations/wud/parser/WUDInfoParser.java b/src/de/mas/jnus/lib/implementations/wud/parser/WUDInfoParser.java new file mode 100644 index 0000000..5702b06 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/parser/WUDInfoParser.java @@ -0,0 +1,155 @@ +package de.mas.jnus.lib.implementations.wud.parser; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import de.mas.jnus.lib.Settings; +import de.mas.jnus.lib.entities.content.ContentFSTInfo; +import de.mas.jnus.lib.entities.fst.FST; +import de.mas.jnus.lib.entities.fst.FSTEntry; +import de.mas.jnus.lib.implementations.wud.reader.WUDDiscReader; +import de.mas.jnus.lib.utils.ByteUtils; +import de.mas.jnus.lib.utils.FileUtils; +import de.mas.jnus.lib.utils.Utils; +import lombok.extern.java.Log; + +@Log +public class WUDInfoParser { + public static byte[] DECRYPTED_AREA_SIGNATURE = new byte[] { (byte) 0xCC, (byte) 0xA6, (byte) 0xE6, 0x7B }; + public static byte[] PARTITION_FILE_TABLE_SIGNATURE = new byte[] { 0x46, 0x53, 0x54, 0x00 }; // "FST" + public final static int PARTITION_TOC_OFFSET = 0x800; + public final static int PARTITION_TOC_ENTRY_SIZE = 0x80; + + public static final String WUD_TMD_FILENAME = "title.tmd"; + public static final String WUD_TICKET_FILENAME = "title.tik"; + public static final String WUD_CERT_FILENAME = "title.cert"; + + public static WUDInfo createAndLoad(WUDDiscReader discReader,byte[] titleKey) throws IOException { + WUDInfo result = new WUDInfo(); + + result.setTitleKey(titleKey); + result.setWUDDiscReader(discReader); + + byte[] PartitionTocBlock = discReader.readDecryptedToByteArray(Settings.WIIU_DECRYPTED_AREA_OFFSET, 0, 0x8000, titleKey, null); + + // verify DiscKey before proceeding + if(!Arrays.equals(Arrays.copyOfRange(PartitionTocBlock, 0, 4),DECRYPTED_AREA_SIGNATURE)){ + System.out.println("Decryption of PartitionTocBlock failed"); + return null; + } + + Map partitions = readPartitions(result,PartitionTocBlock); + result.setPartitions(partitions); + //parsePartitions(wudInfo,partitions); + + return result; + } + + + private static Map readPartitions(WUDInfo wudInfo,byte[] partitionTocBlock) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(partitionTocBlock.length); + + buffer.order(ByteOrder.BIG_ENDIAN); + buffer.put(partitionTocBlock); + buffer.position(0); + + int partitionCount = (int) ByteUtils.getUnsingedIntFromBytes(partitionTocBlock, 0x1C,ByteOrder.BIG_ENDIAN); + + Map partitions = new HashMap<>(); + + WUDGamePartition gamePartition = new WUDGamePartition(); + + String realGamePartitionName = null; + // populate partition information from decrypted TOC + for (int i = 0; i < partitionCount; i++){ + WUDPartition partition = new WUDPartition(); + + int offset = (PARTITION_TOC_OFFSET + (i * PARTITION_TOC_ENTRY_SIZE)); + byte[] partitionIdentifier = Arrays.copyOfRange(partitionTocBlock, offset, offset+ 0x19); + int j = 0; + for(j = 0;j>16).array(); + + return discReader.readDecryptedToByteArray(Settings.WIIU_DECRYPTED_AREA_OFFSET + (long)partition.getPartitionOffset() + (long)info.getOffset(),entry.getFileOffset(), (int) entry.getFileSize(), key, IV); + } + + private static FSTEntry getEntryByName(FSTEntry root,String name){ + for(FSTEntry cur : root.getFileChildren()){ + if(cur.getFilename().equals(name)){ + return cur; + } + } + for(FSTEntry cur : root.getDirChildren()){ + FSTEntry dir_result = getEntryByName(cur,name); + if(dir_result != null){ + return dir_result; + } + } + return null; + } +} diff --git a/src/de/mas/jnus/lib/implementations/wud/parser/WUDPartition.java b/src/de/mas/jnus/lib/implementations/wud/parser/WUDPartition.java new file mode 100644 index 0000000..ea47926 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/parser/WUDPartition.java @@ -0,0 +1,10 @@ +package de.mas.jnus.lib.implementations.wud.parser; + +import lombok.Data; +@Data +public class WUDPartition { + private String partitionName = ""; + private long partitionOffset = 0; + + private WUDPartitionHeader partitionHeader; +} diff --git a/src/de/mas/jnus/lib/implementations/wud/parser/WUDPartitionHeader.java b/src/de/mas/jnus/lib/implementations/wud/parser/WUDPartitionHeader.java new file mode 100644 index 0000000..b6b5f01 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/parser/WUDPartitionHeader.java @@ -0,0 +1,96 @@ +package de.mas.jnus.lib.implementations.wud.parser; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import de.mas.jnus.lib.entities.content.Content; +import de.mas.jnus.lib.utils.ByteUtils; +import de.mas.jnus.lib.utils.HashUtil; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.java.Log; + +@Log +public class WUDPartitionHeader { + @Getter @Setter + private boolean calculatedHashes = false; + private HashMap h3Hashes; + @Getter(AccessLevel.PRIVATE) @Setter(AccessLevel.PRIVATE) + private byte[] rawData; + + private WUDPartitionHeader(){ + } + + //TODO: real processing. Currently we are ignoring everything except the hashes + public static WUDPartitionHeader parseHeader(byte[] header) { + WUDPartitionHeader result = new WUDPartitionHeader(); + result.setRawData(header); + return result; + } + + public HashMap getH3Hashes() { + if(h3Hashes == null){ + h3Hashes = new HashMap<>(); + } + return h3Hashes; + } + + public void addH3Hashes(short index, byte[] hash) { + getH3Hashes().put(index, hash); + } + + public byte[] getH3Hash(Content content) { + if(content == null){ + log.info("Can't find h3 hash, given content is null."); + return null; + } + + return getH3Hashes().get(content.getIndex()); + } + + public void calculateHashes(Map allContents) { + byte[] header = getRawData(); + + //Calculating offset for the hashes + int cnt = ByteUtils.getIntFromBytes(header,0x10); + int start_offset = 0x40 + cnt*0x04; + + int offset = 0; + + //We have to make sure, that the list is ordered by index + List contents = new ArrayList<>(allContents.values()); + Collections.sort( contents, new Comparator() { + @Override + public int compare(Content o1, Content o2) { + return Short.compare(o1.getIndex(), o2.getIndex()); + } + } ); + + for(Content c : allContents.values()){ + if(!c.isHashed() || !c.isEncrypted()){ + continue; + } + + //The encrypted content are splitted in 0x10000 chunk. For each 0x1000 chunk we need one entry in the h3 + int cnt_hashes = (int) (c.getEncryptedFileSize()/0x10000/0x1000)+1; + + byte[] hash = Arrays.copyOfRange(header, start_offset+ offset*0x14, start_offset+ (offset+cnt_hashes)*0x14); + + //Checking the hash of the h3 file. + if(!Arrays.equals(HashUtil.hashSHA1(hash), c.getSHA2Hash())){ + log.info("h3 incorrect from WUD"); + } + + addH3Hashes(c.getIndex(), hash); + offset += cnt_hashes; + } + + setCalculatedHashes(true); + } +} diff --git a/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReader.java b/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReader.java new file mode 100644 index 0000000..14830ec --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReader.java @@ -0,0 +1,127 @@ +package de.mas.jnus.lib.implementations.wud.reader; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.RandomAccessFile; +import java.util.Arrays; + +import de.mas.jnus.lib.implementations.wud.WUDImage; +import de.mas.jnus.lib.utils.cryptography.AESDecryption; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.java.Log; +@Log +public abstract class WUDDiscReader { + @Getter @Setter private WUDImage image = null; + + public WUDDiscReader(WUDImage image){ + setImage(image); + } + + public InputStream readEncryptedToInputStream(long offset,long size) throws IOException { + PipedInputStream in = new PipedInputStream(); + PipedOutputStream out = new PipedOutputStream(in); + + new Thread(() -> {try { + readEncryptedToOutputStream(out,offset,size); + } catch (IOException e) {e.printStackTrace();}}).start(); + + return in; + } + + public byte[] readEncryptedToByteArray(long offset,long fileoffset, long size) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + readEncryptedToOutputStream(out,offset,size); + return out.toByteArray(); + } + + public InputStream readDecryptedToInputStream(long offset,long fileoffset, long size, byte[] key,byte[] iv) throws IOException { + PipedInputStream in = new PipedInputStream(); + PipedOutputStream out = new PipedOutputStream(in); + + new Thread(() -> {try { + readDecryptedToOutputStream(out,offset,fileoffset,size,key,iv); + } catch (IOException e) {e.printStackTrace();}}).start(); + + return in; + } + + public byte[] readDecryptedToByteArray(long offset,long fileoffset, long size, byte[] key,byte[] iv) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + readDecryptedToOutputStream(out,offset,fileoffset,size,key,iv); + return out.toByteArray(); + } + + protected abstract void readEncryptedToOutputStream(OutputStream out, long offset, long size) throws IOException; + + /** + * + * @param readOffset Needs to be aligned to 0x8000 + * @param key + * @param IV + * @return + * @throws IOException + */ + public byte[] readDecryptedChunk(long readOffset,byte[] key, byte[]IV) throws IOException{ + int chunkSize = 0x8000; + + byte[] encryptedChunk = readEncryptedToByteArray(readOffset, 0, chunkSize); + byte[] decryptedChunk = new byte[chunkSize]; + + AESDecryption aesDecryption = new AESDecryption(key, IV); + decryptedChunk = aesDecryption.decrypt(encryptedChunk); + + return decryptedChunk; + } + + public void readDecryptedToOutputStream(OutputStream outputStream,long clusterOffset, long fileOffset, long size,byte[] key,byte[] IV) throws IOException { + if(IV == null){ + IV = new byte[0x10]; + } + + byte[] buffer; + + long maxCopySize; + long copySize; + + long readOffset; + + int blockSize = 0x8000; + long totalread = 0; + + do{ + long blockNumber = (fileOffset / blockSize); + long blockOffset = (fileOffset % blockSize); + + readOffset = clusterOffset + (blockNumber * blockSize); + // (long)WiiUDisc.WIIU_DECRYPTED_AREA_OFFSET + volumeOffset + clusterOffset + (blockStructure.getBlockNumber() * 0x8000); + + buffer = readDecryptedChunk(readOffset,key, IV); + maxCopySize = 0x8000 - blockOffset; + copySize = (size > maxCopySize) ? maxCopySize : size; + + outputStream.write(Arrays.copyOfRange(buffer, (int) blockOffset, (int) copySize)); + totalread += copySize; + + // update counters + size -= copySize; + fileOffset += copySize; + }while(totalread < size); + + outputStream.close(); + } + + public RandomAccessFile getRandomAccessFileStream() throws FileNotFoundException{ + if(getImage() == null || getImage().getFileHandle() == null){ + log.warning("No image or image filehandle set."); + System.exit(1); + } + return new RandomAccessFile(getImage().getFileHandle(), "r"); + } +} diff --git a/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReaderCompressed.java b/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReaderCompressed.java new file mode 100644 index 0000000..66a23b8 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReaderCompressed.java @@ -0,0 +1,69 @@ +package de.mas.jnus.lib.implementations.wud.reader; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.util.Arrays; + +import de.mas.jnus.lib.implementations.wud.WUDImage; +import de.mas.jnus.lib.implementations.wud.WUDImageCompressedInfo; +import lombok.extern.java.Log; + +@Log +public class WUDDiscReaderCompressed extends WUDDiscReader{ + + public WUDDiscReaderCompressed(WUDImage image) { + super(image); + } +/** + * Expects the .wux format by Exzap. You can more infos about it here. + * https://gbatemp.net/threads/wii-u-image-wud-compression-tool.397901/ + */ + @Override + protected void readEncryptedToOutputStream(OutputStream out, long offset, long size) throws IOException { + // make sure there is no out-of-bounds read + WUDImageCompressedInfo info = getImage().getCompressedInfo(); + + long fileBytesLeft = info.getUncompressedSize() - offset; + if( fileBytesLeft <= 0 ){ + log.warning("offset too big"); + System.exit(1); + } + if( fileBytesLeft < size ){ + size = fileBytesLeft; + } + // compressed read must be handled on a per-sector level + + int bufferSize = 0x8000; + byte[] buffer = new byte[bufferSize]; + + RandomAccessFile input = getRandomAccessFileStream(); + while( size > 0 ){ + long sectorOffset = (offset % info.getSectorSize()); + long remainingSectorBytes = info.getSectorSize() - sectorOffset; + long sectorIndex = (offset / info.getSectorSize()); + int bytesToRead = (int) ((remainingSectorBytes= WUD_SPLITTED_FILE_SIZE){ //Will we read above the part? + long toRead = WUD_SPLITTED_FILE_SIZE - offsetInFile; + if(toRead == 0){ //just load the new file + input.close(); + input = getFileByOffset(curOffset); + part++; + offsetInFile = getOffsetInFilePart(part, curOffset); + }else{ + curReadSize = (int) toRead; //And first only read until the part ends + } + } + + int read = input.read(buffer,0,curReadSize); + if(read < 0) break; + if(totalread + read > size){ + read = (int) (size - totalread); + } + try{ + outputStream.write(Arrays.copyOfRange(buffer, 0, read)); + }catch(IOException e){ + if(e.getMessage().equals("Pipe closed")){ + break; + }else{ + input.close(); + throw e; + } + } + totalread += read; + curOffset += read; + }while(totalread < size); + + + input.close(); + outputStream.close(); + } + + private int getFilePartByOffset(long offset){ + return (int) (offset/WUD_SPLITTED_FILE_SIZE)+1; + } + + private long getOffsetInFilePart(int part,long offset){ + return offset - ((long)(part-1) * WUD_SPLITTED_FILE_SIZE); + } + + private RandomAccessFile getFileByOffset(long offset) throws IOException{ + File filehandlePart1 = getImage().getFileHandle(); + String pathToFiles = filehandlePart1.getParentFile().getAbsolutePath(); + + int filePart = getFilePartByOffset(offset); + + String filePartPath = pathToFiles + File.separator + String.format(WUD_SPLITTED_DEFAULT_FILEPATTERN, filePart); + + File part = new File(filePartPath); + + if(!part.exists()){ + System.out.println("File does not exist"); + return null; + } + RandomAccessFile result = new RandomAccessFile(part, "r"); + result.seek(getOffsetInFilePart(filePart, offset)); + return result; + } +} diff --git a/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReaderUncompressed.java b/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReaderUncompressed.java new file mode 100644 index 0000000..3dfb174 --- /dev/null +++ b/src/de/mas/jnus/lib/implementations/wud/reader/WUDDiscReaderUncompressed.java @@ -0,0 +1,45 @@ +package de.mas.jnus.lib.implementations.wud.reader; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; + +import de.mas.jnus.lib.implementations.wud.WUDImage; + +public class WUDDiscReaderUncompressed extends WUDDiscReader { + public WUDDiscReaderUncompressed(WUDImage image) { + super(image); + } + + @Override + protected void readEncryptedToOutputStream(OutputStream outputStream, long offset,long size) throws IOException{ + + FileInputStream input = new FileInputStream(getImage().getFileHandle()); + input.skip(offset); + int bufferSize = 0x8000; + byte[] buffer = new byte[bufferSize]; + long totalread = 0; + do{ + int read = input.read(buffer); + if(read < 0) break; + if(totalread + read > size){ + read = (int) (size - totalread); + } + try{ + outputStream.write(Arrays.copyOfRange(buffer, 0, read)); + }catch(IOException e){ + if(e.getMessage().equals("Pipe closed")){ + break; + }else{ + input.close(); + throw e; + } + } + totalread += read; + }while(totalread < size); + input.close(); + outputStream.close(); + } + +} diff --git a/src/de/mas/jnus/lib/utils/ByteArrayBuffer.java b/src/de/mas/jnus/lib/utils/ByteArrayBuffer.java new file mode 100644 index 0000000..0e7d844 --- /dev/null +++ b/src/de/mas/jnus/lib/utils/ByteArrayBuffer.java @@ -0,0 +1,25 @@ +package de.mas.jnus.lib.utils; + +import lombok.Getter; +import lombok.Setter; + +public class ByteArrayBuffer{ + @Getter public byte[] buffer; + @Getter @Setter int lengthOfDataInBuffer; + + public ByteArrayBuffer(int length){ + buffer = new byte[(int) length]; + } + + public int getSpaceLeft() { + return buffer.length - getLengthOfDataInBuffer(); + } + + public void addLengthOfDataInBuffer(int bytesRead) { + lengthOfDataInBuffer += bytesRead; + } + + public void resetLengthOfDataInBuffer() { + setLengthOfDataInBuffer(0); + } +} diff --git a/src/de/mas/jnus/lib/utils/ByteArrayWrapper.java b/src/de/mas/jnus/lib/utils/ByteArrayWrapper.java new file mode 100644 index 0000000..5af776b --- /dev/null +++ b/src/de/mas/jnus/lib/utils/ByteArrayWrapper.java @@ -0,0 +1,33 @@ +package de.mas.jnus.lib.utils; + +import java.util.Arrays; + +public final class ByteArrayWrapper +{ + private final byte[] data; + + public ByteArrayWrapper(byte[] data) + { + if (data == null) + { + throw new NullPointerException(); + } + this.data = data; + } + + @Override + public boolean equals(Object other) + { + if (!(other instanceof ByteArrayWrapper)) + { + return false; + } + return Arrays.equals(data, ((ByteArrayWrapper)other).data); + } + + @Override + public int hashCode() + { + return Arrays.hashCode(data); + } +} \ No newline at end of file diff --git a/src/de/mas/jnus/lib/utils/ByteUtils.java b/src/de/mas/jnus/lib/utils/ByteUtils.java new file mode 100644 index 0000000..aa1d200 --- /dev/null +++ b/src/de/mas/jnus/lib/utils/ByteUtils.java @@ -0,0 +1,76 @@ +package de.mas.jnus.lib.utils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; + +public class ByteUtils { + public static int getIntFromBytes(byte[] input,int offset){ + return getIntFromBytes(input, offset, ByteOrder.BIG_ENDIAN); + } + public static int getIntFromBytes(byte[] input,int offset,ByteOrder bo){ + ByteBuffer buffer = ByteBuffer.allocate(4); + buffer.order(bo); + Arrays.copyOfRange(input,offset, offset+4); + buffer.put(Arrays.copyOfRange(input,offset, offset+4)); + + return buffer.getInt(0); + } + + public static long getUnsingedIntFromBytes(byte[] input,int offset){ + return getUnsingedIntFromBytes(input, offset, ByteOrder.BIG_ENDIAN); + } + + public static long getUnsingedIntFromBytes(byte[] input,int offset,ByteOrder bo){ + ByteBuffer buffer = ByteBuffer.allocate(8); + buffer.order(bo); + if(bo.equals(ByteOrder.BIG_ENDIAN)){ + buffer.position(4); + }else{ + buffer.position(0); + } + buffer.put(Arrays.copyOfRange(input,offset, offset+4)); + + return buffer.getLong(0); + } + + public static long getLongFromBytes(byte[] input,int offset){ + return getLongFromBytes(input, offset, ByteOrder.BIG_ENDIAN); + } + + public static long getLongFromBytes(byte[] input,int offset,ByteOrder bo){ + return ByteBuffer.wrap(Arrays.copyOfRange(input,offset, offset+8)).order(bo).getLong(0); + } + + public static short getShortFromBytes(byte[] input,int offset){ + return getShortFromBytes(input, offset, ByteOrder.BIG_ENDIAN); + } + public static short getShortFromBytes(byte[] input, int offset,ByteOrder bo) { + return ByteBuffer.wrap(Arrays.copyOfRange(input,offset, offset+2)).order(bo).getShort(); + } + + public static byte[] getBytesFromLong(long value) { + return getBytesFromLong(value,ByteOrder.BIG_ENDIAN); + } + public static byte[] getBytesFromLong(long value,ByteOrder bo) { + byte[] result = new byte[0x08]; + ByteBuffer.allocate(8).order(bo).putLong(value).get(result); + return result; + } + + public static byte[] getBytesFromInt(int value) { + return getBytesFromInt(value,ByteOrder.BIG_ENDIAN); + } + public static byte[] getBytesFromInt(int value,ByteOrder bo) { + byte[] result = new byte[0x04]; + ByteBuffer.allocate(4).order(bo).putInt(value).get(result); + return result; + } + + public static byte[] getBytesFromShort(short value) { + byte[] result = new byte[0x02]; + ByteBuffer.allocate(2).putShort(value).get(result); + return result; + } + +} diff --git a/src/de/mas/jnus/lib/utils/FileUtils.java b/src/de/mas/jnus/lib/utils/FileUtils.java new file mode 100644 index 0000000..dfbd121 --- /dev/null +++ b/src/de/mas/jnus/lib/utils/FileUtils.java @@ -0,0 +1,52 @@ +package de.mas.jnus.lib.utils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import lombok.NonNull; + +public class FileUtils { + + public static boolean saveByteArrayToFile(String filePath,byte[] data) throws IOException { + File target = new File(filePath); + if(target.isDirectory()){ + return false; + } + File parent = target.getParentFile(); + if(parent != null){ + Utils.createDir(parent.getAbsolutePath()); + } + return saveByteArrayToFile(target,data); + } + + /** + * Saves a byte array to a file (and overwrite it if its already exists) + * DOES NOT IF THE PATH/FILE EXIST OR IS IT EVEN A FILE + * @param output + * @param data + * @return + * @throws IOException + */ + public static boolean saveByteArrayToFile(@NonNull File output,byte[] data) throws IOException { + FileOutputStream out = new FileOutputStream(output); + out.write(data); + out.close(); + return true; + } + + /** + * Saves a byte array to a file (and overwrite it if its already exists) + * DOES NOT IF THE PATH/FILE EXIST OR IS IT EVEN A FILE + * @param output + * @param inputStream + * @throws IOException + */ + public static void saveInputStreamToFile(@NonNull File output,InputStream inputStream,long filesize) throws IOException { + + FileOutputStream out = new FileOutputStream(output); + StreamUtils.saveInputStreamToOutputStream(inputStream,out,filesize); + } + +} diff --git a/src/de/mas/jnus/lib/utils/HashUtil.java b/src/de/mas/jnus/lib/utils/HashUtil.java new file mode 100644 index 0000000..ea30b97 --- /dev/null +++ b/src/de/mas/jnus/lib/utils/HashUtil.java @@ -0,0 +1,185 @@ +package de.mas.jnus.lib.utils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import lombok.extern.java.Log; +@Log +public class HashUtil { + public static byte[] hashSHA256(byte[] data){ + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + return new byte[0x20]; + } + + return sha256.digest(data); + } + + public static byte[] hashSHA256(File file) { + return hashSHA256(file, 0); + } + + //TODO: testing + public static byte[] hashSHA256(File file,int aligmnent) { + byte[] hash = new byte[0x20]; + MessageDigest sha1 = null; + try { + InputStream in = new FileInputStream(file); + sha1 = MessageDigest.getInstance("SHA-256"); + hash = hash(sha1,in,file.length(),0x8000,aligmnent); + } catch (NoSuchAlgorithmException | FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + + return hash; + } + + public static byte[] hashSHA1(byte[] data){ + MessageDigest sha1; + try { + sha1 = MessageDigest.getInstance("SHA1"); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + return new byte[0x14]; + } + + return sha1.digest(data); + } + + public static byte[] hashSHA1(File file) { + return hashSHA1(file, 0); + } + + public static byte[] hashSHA1(File file,int aligmnent) { + byte[] hash = new byte[0x14]; + MessageDigest sha1 = null; + try { + InputStream in = new FileInputStream(file); + sha1 = MessageDigest.getInstance("SHA1"); + hash = hash(sha1,in,file.length(),0x8000,aligmnent); + } catch (NoSuchAlgorithmException | FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + + return hash; + } + + public static byte [] hash(MessageDigest digest, InputStream in,long inputSize1, int bufferSize,int alignment) throws IOException { + long target_size = alignment == 0 ? inputSize1: Utils.align(inputSize1, alignment); + long cur_position = 0; + int inBlockBufferRead = 0; + byte[] blockBuffer = new byte[bufferSize]; + ByteArrayBuffer overflow = new ByteArrayBuffer(bufferSize); + do{ + if(cur_position + bufferSize > target_size){ + int expectedSize = (int) (target_size - cur_position); + ByteBuffer buffer = ByteBuffer.allocate(expectedSize); + buffer.position(0); + inBlockBufferRead = StreamUtils.getChunkFromStream(in,blockBuffer,overflow,expectedSize); + buffer.put(Arrays.copyOfRange(blockBuffer, 0, inBlockBufferRead)); + blockBuffer = buffer.array(); + inBlockBufferRead = blockBuffer.length; + }else{ + int expectedSize = bufferSize; + inBlockBufferRead = StreamUtils.getChunkFromStream(in,blockBuffer,overflow,expectedSize); + } + if(inBlockBufferRead == 0){ + inBlockBufferRead = (int) (target_size - cur_position); + blockBuffer = new byte[inBlockBufferRead]; + } + if(inBlockBufferRead <= 0) break; + + digest.update(blockBuffer, 0, inBlockBufferRead); + cur_position += inBlockBufferRead; + + }while(cur_position < target_size); + + in.close(); + + return digest.digest(); + } + + public static void checkFileChunkHashes(byte[] hashes,byte[] h3Hashes, byte[] output,int block) { + int H0_start = (block % 16) * 20; + int H1_start = (16 + (block / 16) % 16) * 20; + int H2_start = (32 + (block / 256) % 16) * 20; + int H3_start = ((block / 4096) % 16) * 20; + + byte[] real_h0_hash = HashUtil.hashSHA1(output); + byte[] expected_h0_hash = Arrays.copyOfRange(hashes,H0_start,H0_start + 20); + + if(!Arrays.equals(real_h0_hash,expected_h0_hash)){ + System.out.println("h0 checksum failed"); + System.out.println("real hash :" + Utils.ByteArrayToString(real_h0_hash)); + System.out.println("expected hash:" + Utils.ByteArrayToString(expected_h0_hash)); + System.exit(2); + //throw new IllegalArgumentException("h0 checksumfail"); + }else{ + log.finest("h1 checksum right!"); + } + + if ((block % 16) == 0){ + byte[] expected_h1_hash = Arrays.copyOfRange(hashes,H1_start,H1_start + 20); + byte[] real_h1_hash = HashUtil.hashSHA1(Arrays.copyOfRange(hashes,H0_start,H0_start + (16*20))); + + if(!Arrays.equals(expected_h1_hash, real_h1_hash)){ + System.out.println("h1 checksum failed"); + System.out.println("real hash :" + Utils.ByteArrayToString(real_h1_hash)); + System.out.println("expected hash:" + Utils.ByteArrayToString(expected_h1_hash)); + System.exit(2); + //throw new IllegalArgumentException("h1 checksumfail"); + }else{ + log.finer("h1 checksum right!"); + } + } + + if ((block % 256) == 0){ + byte[] expected_h2_hash = Arrays.copyOfRange(hashes,H2_start,H2_start + 20); + byte[] real_h2_hash = HashUtil.hashSHA1(Arrays.copyOfRange(hashes,H1_start,H1_start + (16*20))); + + if(!Arrays.equals(expected_h2_hash, real_h2_hash)){ + System.out.println("h2 checksum failed"); + System.out.println("real hash :" + Utils.ByteArrayToString(real_h2_hash)); + System.out.println("expected hash:" + Utils.ByteArrayToString(expected_h2_hash)); + System.exit(2); + //throw new IllegalArgumentException("h2 checksumfail"); + + }else{ + log.fine("h2 checksum right!"); + } + } + + if(h3Hashes == null){ + log.info("didn't check the h3, its missing."); + return; + } + if ((block % 4096) == 0){ + byte[] expected_h3_hash = Arrays.copyOfRange(h3Hashes,H3_start,H3_start + 20); + byte[] real_h3_hash = HashUtil.hashSHA1(Arrays.copyOfRange(hashes,H2_start,H2_start + (16*20))); + + if(!Arrays.equals(expected_h3_hash, real_h3_hash)){ + System.out.println("h3 checksum failed"); + System.out.println("real hash :" + Utils.ByteArrayToString(real_h3_hash)); + System.out.println("expected hash:" + Utils.ByteArrayToString(expected_h3_hash)); + System.exit(2); + //throw new IllegalArgumentException("h3 checksumfail"); + }else{ + log.fine("h3 checksum right!"); + } + } + } +} diff --git a/src/de/mas/jnus/lib/utils/StreamUtils.java b/src/de/mas/jnus/lib/utils/StreamUtils.java new file mode 100644 index 0000000..df3d9a7 --- /dev/null +++ b/src/de/mas/jnus/lib/utils/StreamUtils.java @@ -0,0 +1,127 @@ +package de.mas.jnus.lib.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import lombok.extern.java.Log; + +@Log +public class StreamUtils { + public static byte[] getBytesFromStream(InputStream in,int size) throws IOException{ + byte[] result = new byte[size]; + byte[] buffer = new byte[0x8000]; + int totalRead = 0; + do{ + int read = in.read(buffer); + if(read < 0) break; + System.arraycopy(buffer, 0, result, totalRead, read); + totalRead += read; + }while(totalRead < size); + in.close(); + return result; + } + + public static int getChunkFromStream(InputStream inputStream,byte[] output, ByteArrayBuffer overflowbuffer,int BLOCKSIZE) throws IOException { + int bytesRead = -1; + int inBlockBuffer = 0; + byte[] overflowbuf = overflowbuffer.getBuffer(); + do{ + try{ + bytesRead = inputStream.read(overflowbuf,overflowbuffer.getLengthOfDataInBuffer(),overflowbuffer.getSpaceLeft()); + }catch(IOException e){ + if(!e.getMessage().equals("Write end dead")){ + throw e; + } + bytesRead = -1; + } + if(bytesRead <= 0){ + if(overflowbuffer.getLengthOfDataInBuffer() > 0){ + System.arraycopy(overflowbuf, 0, output, 0, overflowbuffer.getLengthOfDataInBuffer()); + inBlockBuffer = overflowbuffer.getLengthOfDataInBuffer(); + } + break; + } + + overflowbuffer.addLengthOfDataInBuffer(bytesRead); + + if(inBlockBuffer + overflowbuffer.getLengthOfDataInBuffer() > BLOCKSIZE){ + int tooMuch = (inBlockBuffer + bytesRead) - BLOCKSIZE; + int toRead = BLOCKSIZE - inBlockBuffer; + + System.arraycopy(overflowbuf, 0, output, inBlockBuffer, toRead); + inBlockBuffer += toRead; + + System.arraycopy(overflowbuf, toRead, overflowbuf, 0, tooMuch); + overflowbuffer.setLengthOfDataInBuffer(tooMuch); + }else{ + System.arraycopy(overflowbuf, 0, output, inBlockBuffer, overflowbuffer.getLengthOfDataInBuffer()); + inBlockBuffer +=overflowbuffer.getLengthOfDataInBuffer(); + overflowbuffer.resetLengthOfDataInBuffer(); + } + }while(inBlockBuffer != BLOCKSIZE); + return inBlockBuffer; + } + + public static void saveInputStreamToOutputStream(InputStream inputStream, OutputStream outputStream, long filesize) throws IOException { + saveInputStreamToOutputStreamWithHash(inputStream, outputStream, filesize, null,0L); + } + + public static void saveInputStreamToOutputStreamWithHash(InputStream inputStream, OutputStream outputStream,long filesize, byte[] hash,long expectedSizeForHash) throws IOException { + MessageDigest sha1 = null; + if(hash != null){ + try { + sha1 = MessageDigest.getInstance("SHA1"); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + } + + int BUFFER_SIZE = 0x8000; + byte[] buffer = new byte[BUFFER_SIZE]; + int read = 0; + long totalRead = 0; + long written = 0; + do{ + read = inputStream.read(buffer); + if(read < 0){ + break; + } + totalRead +=read; + + if(totalRead > filesize){ + read = (int) (read - (totalRead - filesize)); + } + + outputStream.write(buffer, 0, read); + written += read; + + if(sha1 != null){ + sha1.update(buffer,0,read); + } + }while(written < filesize); + + if(sha1 != null){ + long missingInHash = expectedSizeForHash - written; + if(missingInHash > 0){ + sha1.update(new byte[(int) missingInHash]); + } + + byte[] calculated_hash = sha1.digest(); + byte[] expected_hash = hash; + if(!Arrays.equals(calculated_hash, expected_hash)){ + log.info(Utils.ByteArrayToString(calculated_hash)); + log.info(Utils.ByteArrayToString(expected_hash)); + log.info("Hash doesn't match saves output stream."); + }else{ + //log.warning("Hash DOES match saves output stream."); + } + } + + outputStream.close(); + inputStream.close(); + } +} diff --git a/src/de/mas/jnus/lib/utils/Utils.java b/src/de/mas/jnus/lib/utils/Utils.java new file mode 100644 index 0000000..22dfd9d --- /dev/null +++ b/src/de/mas/jnus/lib/utils/Utils.java @@ -0,0 +1,66 @@ +package de.mas.jnus.lib.utils; + +import java.io.File; + +public class Utils { + + public static long align(long numToRound, int multiple){ + if((multiple>0) && ((multiple & (multiple -1)) == 0)){ + return alignPower2(numToRound, multiple); + }else{ + return alignGeneriv(numToRound,multiple); + } + } + + private static long alignGeneriv(long numToRound,int multiple){ + int isPositive = 0; + if(numToRound >=0){ + isPositive = 1; + } + return ((numToRound + isPositive * (multiple - 1)) / multiple) * multiple; + } + + private static long alignPower2(long numToRound, int multiple) + { + if(!((multiple>0) && ((multiple & (multiple -1)) == 0))) return 0L; + return (numToRound + (multiple - 1)) & ~(multiple - 1); + } + + public static String ByteArrayToString(byte[] ba) + { + if(ba == null) return null; + StringBuilder hex = new StringBuilder(ba.length * 2); + for(byte b : ba){ + hex.append(String.format("%02X", b)); + } + return hex.toString(); + } + + public static byte[] StringToByteArray(String s) { + int len = s.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + + Character.digit(s.charAt(i+1), 16)); + } + return data; + } + + public static boolean createDir(String path){ + if(path == null || path.isEmpty()){ + return false; + } + File pathFile = new File(path); + if(!pathFile.exists()){ + boolean succes = new File(path).mkdirs(); + if(!succes){ + System.out.println("Creating directory \"" +path + "\" failed."); + return false; + } + }else if(!pathFile.isDirectory()){ + System.out.println("\"" +path + "\" already exists but is no directoy"); + return false; + } + return true; + } +} diff --git a/src/de/mas/jnus/lib/utils/XMLParser.java b/src/de/mas/jnus/lib/utils/XMLParser.java new file mode 100644 index 0000000..8842219 --- /dev/null +++ b/src/de/mas/jnus/lib/utils/XMLParser.java @@ -0,0 +1,77 @@ +package de.mas.jnus.lib.utils; + + +import java.io.IOException; +import java.io.InputStream; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import com.sun.istack.internal.NotNull; + +public class XMLParser { + private Document document; + + public void loadDocument(InputStream inputStream) throws ParserConfigurationException, SAXException, IOException{ + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(inputStream); + this.document = document; + + } + + public long getValueOfElementAsInt(String element,int index){ + return Integer.parseInt(getValueOfElement(element,index)); + } + + public long getValueOfElementAsLong(String element,int index){ + return Long.parseLong(getValueOfElement(element,index)); + } + + public String getValueOfElement(String element){ + return getValueOfElement(element,0); + } + + public Node getNodeByValue(String element){ + return getNodeByValue(element, 0); + } + public Node getNodeByValue(String element,int index){ + if(document == null){ + System.out.println("Please load the document first."); + } + NodeList list = document.getElementsByTagName(element); + if(list == null){ + return null; + } + return list.item(index); + } + + public String getValueOfElementAttribute(String element,int index,String attribute){ + Node node = getNodeByValue(element, index); + if(node == null){ + //System.out.println("Node is null"); + return ""; + } + return getAttributeValueFromNode(node,attribute); + } + + public static String getAttributeValueFromNode(@NotNull Node element,String attribute){ + return element.getAttributes().getNamedItem(attribute).getTextContent().toString(); + } + + public String getValueOfElement(String element,int index){ + Node node = getNodeByValue(element, index); + if(node == null){ + //System.out.println("Node is null"); + return ""; + } + + return node.getTextContent().toString(); + } +} diff --git a/src/de/mas/jnus/lib/utils/cryptography/AESDecryption.java b/src/de/mas/jnus/lib/utils/cryptography/AESDecryption.java new file mode 100644 index 0000000..f63256f --- /dev/null +++ b/src/de/mas/jnus/lib/utils/cryptography/AESDecryption.java @@ -0,0 +1,71 @@ +package de.mas.jnus.lib.utils.cryptography; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import lombok.Getter; +import lombok.Setter; + +public class AESDecryption { + private Cipher cipher; + + @Getter @Setter private byte[] AESKey; + @Getter @Setter private byte[] IV; + + public AESDecryption(byte[] AESKey, byte[] IV) { + try { + cipher = Cipher.getInstance("AES/CBC/NoPadding"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + e.printStackTrace(); + } + setAESKey(AESKey); + setIV(IV); + init(); + } + + protected void init() { + init(getAESKey(),getIV()); + } + + protected void init(byte[] decryptedKey,byte[] iv){ + SecretKeySpec secretKeySpec = new SecretKeySpec(decryptedKey, "AES"); + try { + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new IvParameterSpec(iv)); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + e.printStackTrace(); + System.exit(2); + } + } + + public byte[] decrypt(byte[] input){ + try { + return cipher.doFinal(input); + } catch (IllegalBlockSizeException | BadPaddingException e) { + e.printStackTrace(); + System.exit(2); + } + return input; + } + + public byte[] decrypt(byte[] input,int len){ + return decrypt(input,0,len); + } + + public byte[] decrypt(byte[] input,int offset,int len){ + try { + return cipher.doFinal(input, offset, len); + } catch (IllegalBlockSizeException | BadPaddingException e) { + e.printStackTrace(); + System.exit(2); + } + return input; + } +} diff --git a/src/de/mas/jnus/lib/utils/cryptography/NUSDecryption.java b/src/de/mas/jnus/lib/utils/cryptography/NUSDecryption.java new file mode 100644 index 0000000..468086b --- /dev/null +++ b/src/de/mas/jnus/lib/utils/cryptography/NUSDecryption.java @@ -0,0 +1,171 @@ +package de.mas.jnus.lib.utils.cryptography; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import de.mas.jnus.lib.entities.Ticket; +import de.mas.jnus.lib.utils.ByteArrayBuffer; +import de.mas.jnus.lib.utils.HashUtil; +import de.mas.jnus.lib.utils.StreamUtils; +import de.mas.jnus.lib.utils.Utils; +import lombok.extern.java.Log; +@Log +public class NUSDecryption extends AESDecryption{ + public NUSDecryption(byte[] AESKey, byte[] IV) { + super(AESKey, IV); + } + + public NUSDecryption(Ticket ticket) { + this(ticket.getDecryptedKey(),ticket.getIV()); + } + + private byte[] decryptFileChunk(byte[] blockBuffer, int BLOCKSIZE, byte[] IV) { + return decryptFileChunk(blockBuffer,0,BLOCKSIZE, IV); + } + + private byte[] decryptFileChunk(byte[] blockBuffer, int offset, int BLOCKSIZE, byte[] IV) { + if(IV != null){ + setIV(IV); + init(); + } + return decrypt(blockBuffer,offset,BLOCKSIZE); + } + + public void decryptFileStream(InputStream inputStream,OutputStream outputStream,long filesize,short contentIndex,byte[] h3hash,long expectedSizeForHash) throws IOException { + MessageDigest sha1 = null; + if(h3hash != null){ + try { + sha1 = MessageDigest.getInstance("SHA1"); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + } + + int BLOCKSIZE = 0x8000; + long dlFileLength = filesize; + if(dlFileLength > (dlFileLength/BLOCKSIZE)*BLOCKSIZE){ + dlFileLength = ((dlFileLength/BLOCKSIZE)*BLOCKSIZE) +BLOCKSIZE; + } + + byte[] IV = new byte[0x10]; + + IV[0] = (byte)((contentIndex >> 8) & 0xFF); + IV[1] = (byte)(contentIndex); + + byte[] blockBuffer = new byte[BLOCKSIZE]; + + int inBlockBuffer; + long written = 0; + + ByteArrayBuffer overflow = new ByteArrayBuffer(BLOCKSIZE); + + do{ + inBlockBuffer = StreamUtils.getChunkFromStream(inputStream,blockBuffer,overflow,BLOCKSIZE); + + byte[] output = decryptFileChunk(blockBuffer,BLOCKSIZE,IV); + IV = Arrays.copyOfRange(blockBuffer,BLOCKSIZE-16, BLOCKSIZE); + + if((written + inBlockBuffer) > filesize){ + inBlockBuffer = (int) (filesize- written); + } + + written += inBlockBuffer; + + outputStream.write(output, 0, inBlockBuffer); + + if(sha1 != null){ + sha1.update(output,0,inBlockBuffer); + } + }while(inBlockBuffer == BLOCKSIZE); + + if(sha1 != null){ + long missingInHash = expectedSizeForHash - written; + if(missingInHash > 0){ + sha1.update(new byte[(int) missingInHash]); + } + + byte[] calculated_hash = sha1.digest(); + byte[] expected_hash = h3hash; + if(!Arrays.equals(calculated_hash, expected_hash)){ + log.info(Utils.ByteArrayToString(calculated_hash)); + log.info(Utils.ByteArrayToString(expected_hash)); + log.info("Hash doesn't match decrypted content."); + }else{ + //log.warning("###################################################Hash DOES match saves output stream."); + } + } + + outputStream.close(); + inputStream.close(); + } + + public void decryptFileStreamHashed(InputStream inputStream,OutputStream outputStream,long filesize,long fileoffset,short contentIndex,byte[] h3Hash) throws IOException{ + int BLOCKSIZE = 0x10000; + int HASHBLOCKSIZE = 0xFC00; + + long writeSize = HASHBLOCKSIZE; + + long block = (fileoffset / HASHBLOCKSIZE); + long soffset = fileoffset - (fileoffset / HASHBLOCKSIZE * HASHBLOCKSIZE); + + if( soffset+filesize > writeSize ) + writeSize = writeSize - soffset; + + byte[] encryptedBlockBuffer = new byte[BLOCKSIZE]; + ByteArrayBuffer overflow = new ByteArrayBuffer(BLOCKSIZE); + + long wrote = 0; + int inBlockBuffer; + do{ + inBlockBuffer = StreamUtils.getChunkFromStream(inputStream,encryptedBlockBuffer,overflow,BLOCKSIZE); + + if( writeSize > filesize ) + writeSize = filesize; + + byte[] output = decryptFileChunkHash(encryptedBlockBuffer, (int) block,contentIndex,h3Hash); + + if((wrote + writeSize) > filesize){ + writeSize = (int) (filesize- wrote); + } + + outputStream.write(output, (int)(0+soffset), (int)writeSize); + + wrote +=writeSize; + + block++; + + if( soffset > 0){ + writeSize = HASHBLOCKSIZE; + soffset = 0; + } + }while(wrote < filesize && (inBlockBuffer == BLOCKSIZE)); + + outputStream.close(); + inputStream.close(); + } + + private byte[] decryptFileChunkHash(byte[] blockBuffer, int block, int contentIndex,byte[] h3_hashes){ + int hashSize = 0x400; + int blocksize = 0xFC00; + byte[] IV = ByteBuffer.allocate(16).putShort((short) contentIndex).array(); + + byte[] hashes = decryptFileChunk(blockBuffer,hashSize,IV); + + hashes[0] ^= (byte)((contentIndex >> 8) & 0xFF); + hashes[1] ^= (byte)(contentIndex & 0xFF); + + int H0_start = (block % 16) * 20; + + IV = Arrays.copyOfRange(hashes,H0_start,H0_start + 16); + byte[] output = decryptFileChunk(blockBuffer,hashSize,blocksize,IV); + + HashUtil.checkFileChunkHashes(hashes,h3_hashes,output,block); + + return output; + } +} diff --git a/src/de/mas/jnus/lib/utils/download/Downloader.java b/src/de/mas/jnus/lib/utils/download/Downloader.java new file mode 100644 index 0000000..3013105 --- /dev/null +++ b/src/de/mas/jnus/lib/utils/download/Downloader.java @@ -0,0 +1,38 @@ +package de.mas.jnus.lib.utils.download; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +public class Downloader { + public static byte[] downloadFileToByteArray(String fileURL) throws IOException { + int BUFFER_SIZE = 0x800; + URL url = new URL(fileURL); + HttpURLConnection httpConn = (HttpURLConnection) url.openConnection(); + int responseCode = httpConn.getResponseCode(); + + byte[] file = null; + + if (responseCode == HttpURLConnection.HTTP_OK) { + int contentLength = httpConn.getContentLength(); + + file = new byte[contentLength]; + + InputStream inputStream = httpConn.getInputStream(); + + int bytesRead = -1; + byte[] buffer = new byte[BUFFER_SIZE]; + int filePostion = 0; + while ((bytesRead = inputStream.read(buffer)) != -1) { + System.arraycopy(buffer, 0, file, filePostion,bytesRead); + filePostion+=bytesRead; + } + inputStream.close(); + }else{ + System.out.println("File not found: " + fileURL); + } + httpConn.disconnect(); + return file; + } +} diff --git a/src/de/mas/jnus/lib/utils/download/NUSDownloadService.java b/src/de/mas/jnus/lib/utils/download/NUSDownloadService.java new file mode 100644 index 0000000..760a196 --- /dev/null +++ b/src/de/mas/jnus/lib/utils/download/NUSDownloadService.java @@ -0,0 +1,70 @@ +package de.mas.jnus.lib.utils.download; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +import de.mas.jnus.lib.Settings; +import lombok.Setter; + +public class NUSDownloadService extends Downloader{ + private static NUSDownloadService defaultInstance; + + public static NUSDownloadService getDefaultInstance(){ + if(defaultInstance == null){ + defaultInstance = new NUSDownloadService(); + defaultInstance.setURL_BASE(Settings.URL_BASE); + } + return defaultInstance; + } + + public static NUSDownloadService getInstance(String URL){ + NUSDownloadService instance = new NUSDownloadService(); + instance.setURL_BASE(URL); + return instance; + } + + private NUSDownloadService(){ + + } + + @Setter private String URL_BASE = ""; + + + public byte[] downloadTMDToByteArray(long titleID, int version) throws IOException { + String version_suf = ""; + if(version > Settings.LATEST_TMD_VERSION) version_suf = "." + version; + String URL = URL_BASE + "/" + String.format("%016X", titleID) + "/tmd" +version_suf; + return downloadFileToByteArray(URL); + } + + public byte[] downloadTicketToByteArray(long titleID) throws IOException { + String URL = URL_BASE + "/" + String.format("%016X", titleID) + "/cetk"; + return downloadFileToByteArray(URL); + } + + public byte[] downloadToByteArray(String url) throws IOException { + String URL = URL_BASE + "/" + url; + return downloadFileToByteArray(URL); + } + + public InputStream getInputStream(String URL,long offset) throws IOException{ + URL url_obj = new URL(URL); + HttpURLConnection connection = (HttpURLConnection) url_obj.openConnection(); + connection.setRequestProperty("Range", "bytes=" + offset +"-"); + try{ + connection.connect(); + }catch(Exception e){ + e.printStackTrace(); + } + + return connection.getInputStream(); + } + + public InputStream getInputStreamForURL(String url, long offset) throws IOException { + String URL = URL_BASE + "/" + url; + + return getInputStream(URL,offset); + } +}