Created
November 16, 2019 03:21
-
-
Save jonbarrow/56c062f22df4653b755ecca7b4d382bc to your computer and use it in GitHub Desktop.
Script to download title tickets and certificates from the NUS CDN and parse them
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const got = require('got'); | |
const fs = require('fs'); | |
const NodeRSA = require('node-rsa'); | |
const { xml2js } = require('xml-js'); | |
// Client to connect to the eShop SOAP api | |
const soapClient = got.extend({ | |
baseUrl: 'https://ecs.wup.shop.nintendo.net', | |
method: 'post', | |
cert: fs.readFileSync('./eshop-common.crt'), // Client certificates. You can find these online, they are common to all consoles | |
key: fs.readFileSync('./eshop-common.key'), // Client certificates. You can find these online, they are common to all consoles | |
throwHttpErrors: false, | |
rejectUnauthorized: false, | |
}); | |
// Signature options | |
const SIGNATURE_SIZES = { | |
RSA_4096_SHA1: { | |
SIZE: 0x200, | |
PADDING_SIZE: 0x3C | |
}, | |
RSA_2048_SHA1: { | |
SIZE: 0x100, | |
PADDING_SIZE: 0x3C | |
}, | |
ELLIPTIC_CURVE_SHA1: { | |
SIZE: 0x3C, | |
PADDING_SIZE: 0x40 | |
}, | |
RSA_4096_SHA256: { | |
SIZE: 0x200, | |
PADDING_SIZE: 0x3C | |
}, | |
RSA_2048_SHA256: { | |
SIZE: 0x100, | |
PADDING_SIZE: 0x3C | |
}, | |
ECDSA_SHA256: { | |
SIZE: 0x3C, | |
PADDING_SIZE: 0x40 | |
} | |
}; | |
// Account and device information | |
const deviceId = '[REDACTED]'; | |
const deviceToken = '[REDACTED]'; | |
const accountId = '[REDACTED]'; | |
const serialNumber = '[REDACTED]'; | |
const deviceCert = '[REDACTED]'; | |
// Testing | |
(async () => { | |
// Title IV (TIV). In the ticket data this is called `ticket_id` | |
// Used internally by Nintendo to track owned titles | |
// A unique TIV is generated per-account per-title when a user purchases a title from the eShop | |
// It is unique to each owned copy of a game and is linked to it's specific ticket | |
// This is not the same thing as a TID, which is common between all copies of the same game | |
// You can get a list of your owned TIVs by calling getAccountTivs | |
const tiv = '1686923775444701'; // Change this | |
console.log('Requesting ticket for', tiv); | |
const ticketData = await getTivTicket(tiv, deviceId, deviceToken, accountId, serialNumber, deviceCert); | |
const ticket = parseTicket(ticketData.ticket, ticketData.ticket_certificate, ticketData.ca); | |
console.log('Parsed ticket', ticket); | |
})(); | |
// Get all TIVs for an account | |
async function getAccountTivs(deviceId, deviceToken, accountId) { | |
const response = await soapClient('/ecs/services/ECommerceSOAP', { | |
headers: { | |
'SOAPAction': 'urn:ecs.wsapi.broadon.com/AccountListETicketIds', | |
'User-Agent': 'wii libnup/1.1', | |
'Cache-Control': 'max-age=0, no-cache, no-store' | |
}, | |
body: `<?xml version="1.0" encoding="UTF-8"?> | |
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" | |
xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" | |
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xmlns:xsd="http://www.w3.org/2001/XMLSchema" | |
xmlns:ecs="urn:ecs.wsapi.broadon.com"> | |
<SOAP-ENV:Body> | |
<ecs:AccountListETicketIds xsi:type="ecs:AccountListETicketIdsRequestType"> | |
<ecs:Version>2.0</ecs:Version> | |
<ecs:MessageId>EC-${accountId}-123456789</ecs:MessageId> | |
<ecs:DeviceId>${deviceId}</ecs:DeviceId> | |
<ecs:DeviceToken>${deviceToken}</ecs:DeviceToken> | |
<ecs:AccountId>${accountId}</ecs:AccountId> | |
</ecs:AccountListETicketIds> | |
</SOAP-ENV:Body> | |
</SOAP-ENV:Envelope> | |
` | |
}); | |
const xml = xml2js(response.body); | |
return xml // root | |
.elements[0] // soapenv:Envelope | |
.elements[0] // soapenv:Body | |
.elements[0] // AccountListETicketIdsResponse | |
.elements // elements of response | |
.filter(({ name }) => name === 'TIV') // only get TIV elements | |
.map(({ elements }) => elements[0].text); // get the TIV value | |
} | |
// Gets a ticket for a given TIV | |
async function getTivTicket(tiv, deviceId, deviceToken, accountId, serialNumber, deviceCert) { | |
tiv = tiv.split('.')[0]; // Server throws a Java NumberFormatException if the decimal is kept | |
const response = await soapClient('/ecs/services/ECommerceSOAP', { | |
headers: { | |
'SOAPAction': 'urn:ecs.wsapi.broadon.com/AccountGetETickets', | |
'User-Agent': 'wii libnup/1.1', | |
'Cache-Control': 'max-age=0, no-cache, no-store' | |
}, | |
body: `<?xml version="1.0" encoding="UTF-8"?> | |
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" | |
xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" | |
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xmlns:xsd="http://www.w3.org/2001/XMLSchema" | |
xmlns:ecs="urn:ecs.wsapi.broadon.com"> | |
<SOAP-ENV:Body> | |
<ecs:AccountGetETickets xsi:type="ecs:AccountGetETicketsRequestType"> | |
<ecs:Version>2.0</ecs:Version> | |
<ecs:MessageId>EC-${accountId}-123456789</ecs:MessageId> | |
<ecs:DeviceId>${deviceId}</ecs:DeviceId> | |
<ecs:DeviceToken>${deviceToken}</ecs:DeviceToken> | |
<ecs:AccountId>${accountId}</ecs:AccountId> | |
<ecs:SerialNo>${serialNumber}</ecs:SerialNo> | |
<ecs:TicketId>${tiv}</ecs:TicketId><ecs:DeviceCert>${deviceCert}</ecs:DeviceCert> | |
</ecs:AccountGetETickets> | |
</SOAP-ENV:Body> | |
</SOAP-ENV:Envelope> | |
` | |
}); | |
const xml = xml2js(response.body); | |
const { elements } = xml // root | |
.elements[0] // soapenv:Envelope | |
.elements[0] // soapenv:Body | |
.elements[0]; // AccountGetETicketsResponse | |
const ticketBase64 = elements.find(({ name }) => name === 'ETickets').elements[0].text; | |
const certs = elements.filter(({ name }) => name === 'Certs'); | |
const ticketCertificateBase64 = certs[0].elements[0].text; // Used to validate the ticket | |
const caBase64 = certs[1].elements[0].text; // Used to validate the ticket certificate | |
return { | |
ticket: ticketBase64, | |
ticket_certificate: ticketCertificateBase64, | |
ca: caBase64 | |
}; | |
} | |
// Parse a given ticket | |
function parseTicket(ticketBase64, certificateBase64, caBase64) { | |
const ticketBuffer = Buffer.from(ticketBase64, 'base64'); | |
const certificateBuffer = Buffer.from(certificateBase64, 'base64'); | |
const caBuffer = Buffer.from(caBase64, 'base64'); | |
// Get the signature type and setup some values for later | |
const signatureType = ticketBuffer.subarray(0x0, 0x4).readInt32BE(); | |
let signature; | |
let dataOffset = 0; | |
// Get the signature using the signature length and the data offset using the padding | |
switch (signatureType) { | |
case 0x10000: | |
signature = ticketBuffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.RSA_4096_SHA1.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.RSA_4096_SHA1.SIZE + SIGNATURE_SIZES.RSA_4096_SHA1.PADDING_SIZE; | |
break; | |
case 0x10001: | |
signature = ticketBuffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.RSA_2048_SHA1.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.RSA_2048_SHA1.SIZE + SIGNATURE_SIZES.RSA_2048_SHA1.PADDING_SIZE; | |
break; | |
case 0x10002: | |
signature = ticketBuffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.ELLIPTIC_CURVE_SHA1.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.ELLIPTIC_CURVE_SHA1.SIZE + SIGNATURE_SIZES.ELLIPTIC_CURVE_SHA1.PADDING_SIZE; | |
break; | |
case 0x10003: | |
signature = ticketBuffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.RSA_4096_SHA256.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.RSA_4096_SHA256.SIZE + SIGNATURE_SIZES.RSA_4096_SHA256.PADDING_SIZE; | |
break; | |
case 0x10004: | |
signature = ticketBuffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.RSA_2048_SHA256.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.RSA_2048_SHA256.SIZE + SIGNATURE_SIZES.RSA_2048_SHA256.PADDING_SIZE; | |
break; | |
case 0x10005: | |
signature = ticketBuffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.ECDSA_SHA256); | |
dataOffset = 0x4 + SIGNATURE_SIZES.ECDSA_SHA256.SIZE + SIGNATURE_SIZES.ECDSA_SHA256.PADDING_SIZE; | |
break; | |
default: | |
throw new Error(`Unknown signature type ${signatureType}`); | |
} | |
// Real ticket data | |
const ticketData = ticketBuffer.subarray(dataOffset); | |
// Make sure the signature matches | |
// (As of right now this ALWAYS fails!!!!!!) | |
try { | |
validateTicket(ticketData, signature, certificateBuffer, caBuffer); | |
} catch (error) { | |
console.log(error); | |
return null; | |
} | |
// Return parsed information | |
return { | |
signature_type: signatureType, | |
signature, | |
issuer: ticketData.subarray(0x0, 0x40), | |
ecdh: ticketData.subarray(0x40, 0x7C), | |
encrypted_title_key: ticketData.subarray(0x7F, 0x8F).toString('hex'), | |
ticket_id: ticketData.subarray(0x90, 0x98).toString('hex'), | |
console_id: ticketData.subarray(0x98, 0x9C).toString('hex'), | |
title_id: ticketData.subarray(0x9C, 0xA4).toString('hex'), | |
ticket_title_version: ticketData.subarray(0xA6, 0xA8).readUInt16LE(), | |
permitted_titles: ticketData.subarray(0xA8, 0xAC).toString('hex'), | |
permit_mask: ticketData.subarray(0xAC, 0xB0).toString('hex'), | |
export_allowed: !!(ticketData.subarray(0xB0, 0xB1).readUInt8()), | |
key_index: ticketData.subarray(0xB1, 0xB2).readUInt8(), | |
access_permissions: ticketData.subarray(0xE2, 0x122).toString('hex'), | |
}; | |
} | |
// Validate a ticket signature as well as validate the certificates | |
function validateTicket(ticketData, signature, certificateBuffer, caBuffer) { | |
// Parse the given certificates | |
const certificateData = parseCertificate(certificateBuffer); | |
const caData = parseCertificate(caBuffer); | |
// The certificate signature is only over the certificate body | |
const certificateBody = Buffer.concat([ | |
certificateData.issuer, | |
certificateData.key_type, | |
certificateData.name, | |
certificateData.unknown, | |
certificateData.public_key_data, | |
]); | |
// Make sure things match up | |
if (!caData.public_key.verify(certificateBody, certificateData.signature)) { | |
throw new Error('Ticket Certificate bad signature'); | |
} | |
if (!certificateData.public_key.verify(ticketData, signature)) { | |
throw new Error('Ticket bad signature'); | |
} | |
return true; | |
} | |
// Parses a certificate file | |
function parseCertificate(buffer) { | |
// Get the signature type and setup some values for later | |
const signatureType = buffer.subarray(0x0, 0x4).readInt32BE(); | |
let signature; | |
let dataOffset = 0; | |
// Get the signature using the signature length and the data offset using the padding | |
switch (signatureType) { | |
case 0x10000: | |
signature = buffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.RSA_4096_SHA1.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.RSA_4096_SHA1.SIZE + SIGNATURE_SIZES.RSA_4096_SHA1.PADDING_SIZE; | |
break; | |
case 0x10001: | |
signature = buffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.RSA_2048_SHA1.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.RSA_2048_SHA1.SIZE + SIGNATURE_SIZES.RSA_2048_SHA1.PADDING_SIZE; | |
break; | |
case 0x10002: | |
signature = buffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.ELLIPTIC_CURVE_SHA1.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.ELLIPTIC_CURVE_SHA1.SIZE + SIGNATURE_SIZES.ELLIPTIC_CURVE_SHA1.PADDING_SIZE; | |
break; | |
case 0x10003: | |
signature = buffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.RSA_4096_SHA256.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.RSA_4096_SHA256.SIZE + SIGNATURE_SIZES.RSA_4096_SHA256.PADDING_SIZE; | |
break; | |
case 0x10004: | |
signature = buffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.RSA_2048_SHA256.SIZE); | |
dataOffset = 0x4 + SIGNATURE_SIZES.RSA_2048_SHA256.SIZE + SIGNATURE_SIZES.RSA_2048_SHA256.PADDING_SIZE; | |
break; | |
case 0x10005: | |
signature = buffer.subarray(0x4, 0x4 + SIGNATURE_SIZES.ECDSA_SHA256); | |
dataOffset = 0x4 + SIGNATURE_SIZES.ECDSA_SHA256.SIZE + SIGNATURE_SIZES.ECDSA_SHA256.PADDING_SIZE; | |
break; | |
default: | |
throw new Error(`Unknown signature type ${signatureType}`); | |
} | |
const data = buffer.subarray(dataOffset); | |
// Dont directly return this because we have to run additional parsing on some parts | |
const metadata = { | |
signature_type: signatureType, | |
signature, | |
issuer: data.subarray(0x0, 0x40), | |
key_type: data.subarray(0x40, 0x44), | |
name: data.subarray(0x44, 0x84), | |
unknown: data.subarray(0x84, 0x88), | |
public_key_data: data.subarray(0x88), | |
public_key: null // Eventually the public key gets put here | |
}; | |
// Populate public_key with the given type | |
switch (metadata.key_type.readInt32BE()) { | |
case 0x0: // RSA 4096 | |
// Blank key | |
metadata.public_key = new NodeRSA(); | |
// Create the public key with the modulus and exponent | |
metadata.public_key.importKey({ | |
n: metadata.public_key_data.subarray(0x0, 0x200), | |
e: metadata.public_key_data.subarray(0x200, 0x204) | |
}, 'components-public'); | |
break; | |
case 0x1: // RSA 2048 | |
// Blank key | |
metadata.public_key = new NodeRSA(); | |
// Create the public key with the modulus and exponent | |
metadata.public_key.importKey({ | |
n: metadata.public_key_data.subarray(0x0, 0x100), | |
e: metadata.public_key_data.subarray(0x100, 0x104) | |
}, 'components-public'); | |
break; | |
default: | |
throw new Error('Unknown certificate type', metadata.key_type); | |
} | |
return metadata; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment