feat(cli): Implement 3DS support

Also add attributes support for Wii U.
This commit is contained in:
Daniel López Guimaraes 2025-11-09 20:33:48 +00:00
parent dd8ef4dde6
commit 90f02ac286
No known key found for this signature in database
GPG Key ID: 6AC74DE3DEF050E0
4 changed files with 234 additions and 3 deletions

View File

@ -1,6 +1,7 @@
import { program as baseProgram } from 'commander';
import { taskCmd } from './tasks.cmd';
import { fileCmd } from './files.cmd';
import { file3DSCmd } from './files-3ds.cmd';
import { appCmd } from './apps.cmd';
import { importCmd } from './import.cmd';
@ -11,7 +12,8 @@ const program = baseProgram
.addCommand(appCmd)
.addCommand(taskCmd)
.addCommand(importCmd)
.addCommand(fileCmd);
.addCommand(fileCmd)
.addCommand(file3DSCmd);
program.parseAsync(process.argv)
.catch(console.error)

193
src/cli/files-3ds.cmd.ts Normal file
View File

@ -0,0 +1,193 @@
import fs from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { request } from 'undici';
import { Command } from 'commander';
import { decrypt3DS } from '@pretendonetwork/boss-crypto';
import { PlatformType } from '@pretendonetwork/grpc/boss/v2/platform_type';
import { commandHandler, getCliContext } from './utils';
import { logOutputList, logOutputObject } from './output';
const listCmd = new Command('ls')
.description('List all task files in BOSS')
.argument('<app_id>', 'BOSS app to search in')
.argument('<task_id>', 'Task to search in')
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
const [appId, taskId] = cmd.args;
const ctx = getCliContext();
const { files } = await ctx.grpc.listFilesCTR({
bossAppId: appId,
taskId: taskId
});
logOutputList(files, {
format: cmd.format,
onlyIncludeKeys: ['dataId', 'name', 'size'],
mapping: {
dataId: 'Data ID',
name: 'Name',
size: 'Size (bytes)'
}
});
}));
const viewCmd = new Command('view')
.description('Look up a specific task file')
.argument('<app_id>', 'BOSS app that contains the task')
.argument('<task_id>', 'Task that contains the task file')
.argument('<id>', 'Task file ID to lookup', BigInt)
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
const [appId, taskId, dataId] = cmd.args;
const ctx = getCliContext();
const { files } = await ctx.grpc.listFilesCTR({
bossAppId: appId,
taskId: taskId
});
const file = files.find(v => v.dataId === dataId);
if (!file) {
console.log(`Could not find task file with data ID ${dataId} in task ${taskId}`);
return;
}
logOutputObject({
dataId: file.dataId,
name: file.name,
size: file.size,
hash: file.hash,
supportedCountries: file.supportedCountries,
supportedLanguages: file.supportedLanguages,
attributes: file.attributes,
creatorPid: file.creatorPid,
payloadContents: file.payloadContents,
flags: file.flags,
createdAt: new Date(Number(file.createdTimestamp)),
updatedAt: new Date(Number(file.updatedTimestamp))
}, {
format: cmd.format
});
}));
const downloadCmd = new Command('download')
.description('Download a task file')
.argument('<app_id>', 'BOSS app that contains the task')
.argument('<task_id>', 'Task that contains the task file')
.argument('<id>', 'Task file ID to lookup', BigInt)
.option('-d, --decrypt', 'Decrypt the file before return')
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
const [appId, taskId, dataId] = cmd.args;
const ctx = getCliContext();
const { files } = await ctx.grpc.listFilesCTR({
bossAppId: appId,
taskId: taskId
});
const file = files.find(v => v.dataId === dataId);
if (!file) {
console.error(`Could not find task file with data ID ${dataId} in task ${taskId}`);
process.exit(1);
}
const npdl = ctx.getNpdlUrl();
const country = file.supportedCountries.length > 0 ? '/' + file.supportedCountries[0] : '';
const language = file.supportedLanguages.length > 0 ? '/' + file.supportedCountries[0] : '';
const { body, statusCode } = await request(`${npdl.url}/p01/nsa/${file.bossAppId}/${file.taskId}${country}${language}/${file.name}`, {
headers: {
Host: npdl.host
}
});
if (statusCode > 299) {
console.error(`Failed to download: invalid status code (${statusCode})`);
process.exit(1);
}
const chunks: Buffer[] = [];
for await (const chunk of body) {
chunks.push(Buffer.from(chunk));
}
let buffer: Buffer = Buffer.concat(chunks);
if (cmd.opts().decrypt) {
const keys = ctx.get3DSKeys();
const decrypted = decrypt3DS(buffer, keys.aesKey);
// TODO - Handle multiple payloads
buffer = decrypted.payload_contents[0].content;
}
await pipeline(Readable.from(buffer), process.stdout);
}));
const createCmd = new Command('create')
.description('Create a new task file')
.argument('<app_id>', 'BOSS app to store the task file in')
.argument('<task_id>', 'Task to store the task file in')
.requiredOption('--name <name>', 'Name of the task file')
.requiredOption('--title-id <titleId>', 'Target title ID of the payload')
.requiredOption('--content-datatype <contentDatatype>', 'Content datatype of the payload')
.requiredOption('--ns-data-id <nsDataId>', 'NS Data ID of the payload')
.requiredOption('--version <version>', 'Version of the payload')
.requiredOption('--file <file>', 'Path of the file to upload')
.option('--country <country...>', 'Countries for this task file')
.option('--lang <language...>', 'Languages for this task file')
.option('--attribute1 [attribute1]', 'Attribute 1 for this task file')
.option('--attribute2 [attribute2]', 'Attribute 2 for this task file')
.option('--attribute3 [attribute3]', 'Attribute 3 for this task file')
.option('--desc [desc]', 'Description for this task file')
.option('-m, --mark-arrived-privileged', 'Only notify of new content to privileged titles')
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
const [appId, taskId] = cmd.args;
// TODO - Handle multiple payload contents
const opts = cmd.opts<{ name: string; titleId: string; contentDatatype: string; nsDataId: string; version: string; file: string; country: string[]; lang: string[]; attribute1?: string; attribute2?: string; attribute3?: string; desc?: string; markArrivedPrivileged: boolean }>();
const fileBuf = await fs.readFile(opts.file);
const ctx = getCliContext();
const { file } = await ctx.grpc.uploadFileCTR({
taskId: taskId,
bossAppId: appId,
supportedCountries: opts.country,
supportedLanguages: opts.lang,
attributes: {
attribute1: opts.attribute1,
attribute2: opts.attribute2,
attribute3: opts.attribute3,
description: opts.desc
},
name: opts.name,
payloadContents: [{
titleId: BigInt(parseInt(opts.titleId, 16)),
contentDatatype: Number(opts.contentDatatype),
nsDataId: Number(opts.nsDataId),
version: Number(opts.version),
content: fileBuf
}],
flags: {
markArrivedPrivileged: opts.markArrivedPrivileged
}
});
if (!file) {
console.log(`Failed to create file!`);
return;
}
console.log(`Created file with ID ${file.dataId}`);
}));
const deleteCmd = new Command('delete')
.description('Delete a task file')
.argument('<app_id>', 'BOSS app that contains the task')
.argument('<task_id>', 'Task that contains the task file')
.argument('<id>', 'Task file ID to delete', BigInt)
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- I want to use destructuring
const [appId, _taskId, dataId] = cmd.args;
const ctx = getCliContext();
await ctx.grpc.deleteFile({
bossAppId: appId,
dataId: dataId,
platformType: PlatformType.PLATFORM_TYPE_CTR
});
console.log(`Deleted task file with ID ${dataId}`);
}));
export const file3DSCmd = new Command('file-3ds')
.description('Manage all the 3DS task files in BOSS')
.addCommand(listCmd)
.addCommand(createCmd)
.addCommand(deleteCmd)
.addCommand(downloadCmd)
.addCommand(viewCmd);

View File

@ -123,12 +123,16 @@ const createCmd = new Command('create')
.requiredOption('--file <file>', 'Path of the file to upload')
.option('--country <country...>', 'Countries for this task file')
.option('--lang <language...>', 'Languages for this task file')
.option('--attribute1 [attribute1]', 'Attribute 1 for this task file')
.option('--attribute2 [attribute2]', 'Attribute 2 for this task file')
.option('--attribute3 [attribute3]', 'Attribute 3 for this task file')
.option('--desc [desc]', 'Description for this task file')
.option('--name-as-id', 'Force the name as the data ID')
.option('--notify-new <type...>', 'Add entry to NotifyNew')
.option('--notify-led', 'Enable NotifyLED')
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
const [appId, taskId] = cmd.args;
const opts = cmd.opts<{ name: string; country: string[]; notifyNew: string[]; notifyLed: boolean; lang: string[]; nameAsId?: boolean; type: string; file: string }>();
const opts = cmd.opts<{ name: string; country: string[]; notifyNew: string[]; notifyLed: boolean; lang: string[]; attribute1?: string; attribute2?: string; attribute3?: string; desc?: string; nameAsId?: boolean; type: string; file: string }>();
const fileBuf = await fs.readFile(opts.file);
const ctx = getCliContext();
const { file } = await ctx.grpc.uploadFileWUP({
@ -137,6 +141,12 @@ const createCmd = new Command('create')
name: opts.name,
supportedCountries: opts.country,
supportedLanguages: opts.lang,
attributes: {
attribute1: opts.attribute1,
attribute2: opts.attribute2,
attribute3: opts.attribute3,
description: opts.desc
},
type: opts.type,
nameEqualsDataId: opts.nameAsId ?? false,
data: fileBuf,
@ -168,7 +178,7 @@ const deleteCmd = new Command('delete')
}));
export const fileCmd = new Command('file')
.description('Manage all the task files in BOSS')
.description('Manage all the Wii U task files in BOSS')
.addCommand(listCmd)
.addCommand(createCmd)
.addCommand(deleteCmd)

View File

@ -6,15 +6,22 @@ import type { Command } from 'commander';
import type { FormatOption } from './output';
export type WiiUKeys = { aesKey: string; hmacKey: string };
export type CtrKeys = { aesKey: string };
export type NpdiUrl = {
host: string;
url: string;
};
export type NpdlUrl = {
host: string;
url: string;
};
export type CliContext = {
grpc: BossServiceClient;
getNpdiUrl: () => NpdiUrl;
getNpdlUrl: () => NpdlUrl;
getWiiUKeys: () => WiiUKeys;
get3DSKeys: () => CtrKeys;
};
export function getCliContext(): CliContext {
@ -49,6 +56,15 @@ export function getCliContext(): CliContext {
host: npdiHost
};
},
getNpdlUrl(): NpdiUrl {
const npdlUrl = process.env.PN_BOSS_CLI_NPDL_URL ?? 'https://npdl.cdn.pretendo.cc';
const npdlHost = process.env.PN_BOSS_CLI_NPDL_HOST ?? new URL(npdlUrl).host;
return {
url: npdlUrl,
host: npdlHost
};
},
getWiiUKeys(): WiiUKeys {
const aesKey = process.env.PN_BOSS_CLI_WIIU_AES_KEY ?? '';
const hmacKey = process.env.PN_BOSS_CLI_WIIU_HMAC_KEY ?? '';
@ -63,6 +79,16 @@ export function getCliContext(): CliContext {
aesKey,
hmacKey
};
},
get3DSKeys(): CtrKeys {
const aesKey = process.env.PN_BOSS_CLI_3DS_AES_KEY ?? '';
if (!aesKey) {
throw new Error('Missing env variable PN_BOSS_CLI_3DS_AES_KEY - needed for decryption');
}
return {
aesKey
};
}
};
}