Skip to content

Instantly share code, notes, and snippets.

@jonbarrow
Created November 16, 2019 03:21
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jonbarrow/56c062f22df4653b755ecca7b4d382bc to your computer and use it in GitHub Desktop.
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
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