Update WTVClientSessionData

- Hash passwords before encrypting them for slightly extra security
  - Backwards compatible with previous 2 methods
- findAccountByUsername & setSSID - for MSNTV2 stuff
This commit is contained in:
zefie
2026-05-03 14:14:43 -04:00
parent f2e11f827f
commit 9aec2d3150
3 changed files with 505 additions and 8 deletions

View File

@@ -219,13 +219,63 @@ class WTVClientSessionData {
); );
} }
/**
* Returns the absolute path to the account store directory
* @returns {string} Absolute path to the account store directory
*/
getAccountStoreDirectory() { getAccountStoreDirectory() {
return this.wtvshared.getAbsolutePath(this.minisrv_config.config.SessionStore + this.path.sep + "accounts"); return this.wtvshared.getAbsolutePath(this.minisrv_config.config.SessionStore + this.path.sep + "accounts");
} }
/**
* Finds an account's SSID and User ID from just the username
* @param {string} username The username to search for
* @returns {Array} [found {boolean}, account_dir {string|null}, user_id {number|null}]
*/
findAccountByUsername(username) {
const accounts_dir = this.getAccountStoreDirectory();
if (this.fs.existsSync(accounts_dir)) {
const account_dirs = this.fs.readdirSync(accounts_dir);
for (let i = 0; i < account_dirs.length; i++) {
const account_dir = accounts_dir + this.path.sep + account_dirs[i];
if (this.fs.lstatSync(account_dir).isDirectory()) {
const user_dirs = this.fs.readdirSync(account_dir);
for (let j = 0; j < user_dirs.length; j++) {
const user_file = account_dir + this.path.sep + user_dirs[j] + this.path.sep + `user${j}.json`;
if (this.fs.existsSync(user_file)) {
const user_data = JSON.parse(this.fs.readFileSync(user_file));
if (user_data.subscriber_username.toLowerCase() === username.toLowerCase()) {
return [true, account_dirs[i], j];
}
}
}
}
}
}
return [false, null, null];
}
/**
* Switch the SSID for this session, and load a new user's session data, but only if the session
* was initialized with a null SSID. This is primarily used for MSNTV2/Passport services
* where the SSID is not known at the time of session initialization.
* @param {string} ssid The new SSID to set
* @param {number} userID The user ID to switch to after setting the SSID, defaults to 0 (primary account)
* @param {boolean} forceSwitch If true, allows switching SSID even if one is already set (use with caution, can cause data corruption if used improperly)
* @return {boolean} True if the SSID was set and session data loaded successfully, false otherwise
*/
setSSID(ssid, userID = 0, forceSwitch = false) {
if (this.ssid !== null && !forceSwitch) return false; // SSID already set, cannot switch
this.ssid = ssid;
this.clearUserSessionMemory();
this.switchUserID(userID);
return true;
}
/** /**
* Returns the absolute path to the user's file store, or false if unregistered * Returns the absolute path to the user's file store, or false if unregistered
* @param subscriber {boolean} Returns the parent subscriber directory instead of the user's directory * @param {boolean} subscriber Returns the parent subscriber directory instead of the user's directory
* @param {number|null} user_id The user ID to get the store for, or null for the current user
* @returns {string|boolean} Absolute path to the user's file store, or false if unregistered * @returns {string|boolean} Absolute path to the user's file store, or false if unregistered
*/ */
getUserStoreDirectory(subscriber = false, user_id = null) { getUserStoreDirectory(subscriber = false, user_id = null) {
@@ -236,6 +286,11 @@ class WTVClientSessionData {
return userstore + this.path.sep; return userstore + this.path.sep;
} }
/**
* Removes a user from the account store
* @param {number} user_id The ID of the user to remove
* @returns {boolean} True if the user was successfully removed, false otherwise
*/
removeUser(user_id) { removeUser(user_id) {
if (!this.isRegistered()) return false; // not registered if (!this.isRegistered()) return false; // not registered
if (parseInt(this.user_id) !== 0) return false; // not primary account if (parseInt(this.user_id) !== 0) return false; // not primary account
@@ -692,14 +747,13 @@ class WTVClientSessionData {
return CryptoJS.AES.decrypt(crypt, this.cryptoKey).toString(CryptoJS.enc.Utf8); return CryptoJS.AES.decrypt(crypt, this.cryptoKey).toString(CryptoJS.enc.Utf8);
} }
oldDecodePassword(passwd) { oldDecodePassword(passwd) {
return CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Base64); return CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Base64);
} }
encodePassword(passwd) { encodePassword(passwd) {
//return CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Base64); // SHA512 the user's password, then encrypt the hash with AES using the server's user_data_key.
return this.encryptPassword(passwd); return this.encryptPassword(CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Hex));
} }
setUserPassword(passwd) { setUserPassword(passwd) {
@@ -729,8 +783,12 @@ class WTVClientSessionData {
validateUserPassword(passwd) { validateUserPassword(passwd) {
if (!this.getUserPasswordEnabled()) return true; // no password is set so always validate if (!this.getUserPasswordEnabled()) return true; // no password is set so always validate
if (passwd === this.decryptPassword(this.getSessionData("subscriber_password"))) return true; // check against current encryption if (CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Hex) === this.decryptPassword(this.getSessionData("subscriber_password"))) return true; // check against current encryption
else if (this.oldDecodePassword(passwd) === this.getSessionData("subscriber_password")) { else if (passwd === this.decryptPassword(this.getSessionData("subscriber_password"))) {
// check against the short-lived new encryption, if it matches then update to new encryption
this.setUserPassword(passwd);
return true;
} else if (this.oldDecodePassword(passwd) === this.getSessionData("subscriber_password")) {
// if password matches old hash, update to new encryption // if password matches old hash, update to new encryption
this.setUserPassword(passwd); this.setUserPassword(passwd);
return true; return true;

View File

@@ -48,8 +48,7 @@
"show_diskmap": false, // Useful for debugging custom Diskmaps "show_diskmap": false, // Useful for debugging custom Diskmaps
"unauthorized_url": "wtv-1800:/unauthorized?", // Where to send unauthorized users "unauthorized_url": "wtv-1800:/unauthorized?", // Where to send unauthorized users
"enable_port_isolation": true, // Only respond to services on their correct ports "enable_port_isolation": true, // Only respond to services on their correct ports
"domain_name": "minisrv.local", // For MSNTV2 stuff "domain_name": "minisrv.local", // For personalizing mail and MSNTV2
"max_post_length": 20, // in megabytes
"require_valid_ssid": false, // require a valid SSID (with valid CRC) "require_valid_ssid": false, // require a valid SSID (with valid CRC)
"user_accounts": { // user account settings "user_accounts": { // user account settings
"max_users_per_account": 6, // Max total users (including primary) per account "max_users_per_account": 6, // Max total users (including primary) per account

View File

@@ -0,0 +1,440 @@
#!/usr/bin/env node
'use strict';
/**
* WebTV MPEG-1 PS Encoder
*
* Two-pass pipeline:
* 1. ffmpeg encodes input to MPEG-1 PS (codec settings only)
* 2. ES extracted via structure-aware pack walk (never naive payload scan)
* 3. Video ES patched: fr_code=10, constrained_parameters_flag=1
* 4. Output rebuilt as strict 2048-byte packs matching attract.mpg structure:
* - One PES per pack: BA(12) + PES_hdr(6) + ff_0f(2) + data(2028) = 2048
* - All PES optional headers: ff 0f (no timestamps)
* - 3 audio pre-fill packs, then 1 audio per ~7 video packs
* - No BB system header packet
*
* Usage: node encode_webtv_mpeg.js <input_video> <output.mpg> [duration_seconds]
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const PACK_SIZE = 2048;
// MPEG-1 pack header variants used by known working files.
const BA_HDR_MPEG1 = Buffer.from('000001ba2100010001802711', 'hex');
const BA_HDR_ATTRACT = Buffer.from('000001ba0000025447474747', 'hex');
const MP2_BITRATE_MPEG1_L2 = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0];
const MP2_SR_MPEG1 = [44100, 48000, 32000, 0];
// Usable data per pack: 2048 - BA(12) - PES_fixed_hdr(6) - ff_0f(2) = 2028
const DATA_PER_PACK = PACK_SIZE - BA_HDR_MPEG1.length - 6 - 2; // 2028
function runCmd(args, description) {
console.log(`[*] ${description}...`);
try {
execFileSync(args[0], args.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] });
console.log('[+] OK');
return true;
} catch (e) {
const stderr = e.stderr ? e.stderr.toString().slice(0, 500) : e.message;
console.error(`[!] Failed: ${stderr}`);
return false;
}
}
/**
* Extract video (E0) and audio (C0) elementary streams.
* Uses structure-aware pack walk — never scans payload bytes for start codes.
*/
function extractES(mpgPath) {
console.log('[*] Extracting elementary streams (structure-aware)...');
const d = fs.readFileSync(mpgPath);
const videoChunks = [];
const audioChunks = [];
let i = 0;
while (i < d.length - 4) {
if (d[i] !== 0x00 || d[i+1] !== 0x00 || d[i+2] !== 0x01) {
i++;
continue;
}
const sid = d[i+3];
if (sid === 0xBA) {
const mpeg2 = (d[i+4] & 0xC0) === 0x40;
const hlen = mpeg2 ? 14 + (d[i+13] & 0x07) : 12;
i += hlen;
} else if (sid === 0xE0 || sid === 0xC0) {
const pktLen = (d[i+4] << 8) | d[i+5];
const end = i + 6 + pktLen;
let j = i + 6;
// Skip stuffing bytes (0xFF)
while (j < end && d[j] === 0xFF) j++;
if (j < end) {
let h = d[j];
// Skip STD buffer (0x4x marker, 2 bytes)
if ((h & 0xC0) === 0x40) {
j += 2;
h = j < end ? d[j] : 0;
}
// Skip PTS-only (5 bytes) or PTS+DTS (10 bytes) or no-ts (1 byte)
if ((h & 0xF0) === 0x20) {
j += 5;
} else if ((h & 0xF0) === 0x30) {
j += 10;
} else if (h === 0x0F) {
j += 1;
}
}
if (j <= end) {
const payload = d.slice(j, end);
if (sid === 0xE0) videoChunks.push(payload);
else audioChunks.push(payload);
}
i = end;
} else if (sid === 0xB9) {
break; // PS end code
} else if (sid >= 0xBB && sid <= 0xBF) {
const pktLen = (d[i+4] << 8) | d[i+5];
i += 6 + pktLen;
} else {
i++;
}
}
const videoES = Buffer.concat(videoChunks);
const audioES = Buffer.concat(audioChunks);
console.log(`[+] Extracted: video=${videoES.length} bytes, audio=${audioES.length} bytes`);
return { videoES, audioES };
}
/**
* Patch fr_code=10 and constrained_parameters_flag=1 in video ES buffer in-place.
*/
function patchSequenceHeaders(videoES) {
let i = 0;
let n = 0;
while (i < videoES.length - 11) {
// Find 00 00 01 B3
if (videoES[i] === 0x00 && videoES[i+1] === 0x00 &&
videoES[i+2] === 0x01 && videoES[i+3] === 0xB3) {
videoES[i+7] = (videoES[i+7] & 0xF0) | 0x0A; // fr_code = 10
videoES[i+11] |= 0x04; // constrained_parameters_flag
n++;
i += 4;
} else {
i++;
}
}
console.log(`[+] Patched ${n} sequence header(s): fr_code=10, constrained=1`);
return n;
}
/**
* Normalize MP2 frame headers for maximum WebTV compatibility.
* Clears the "original" bit (header byte3 bit2), matching attract.mpg (fffd50c0).
*/
function normalizeMP2Headers(audioES) {
let i = 0;
let patched = 0;
while (i < audioES.length - 4) {
if (audioES[i] === 0xFF && (audioES[i + 1] & 0xE0) === 0xE0) {
const b1 = audioES[i + 1];
const b2 = audioES[i + 2];
const version = (b1 >> 3) & 0x03; // 3 => MPEG-1
const layer = (b1 >> 1) & 0x03; // 2 => Layer II
const brIdx = (b2 >> 4) & 0x0F;
const srIdx = (b2 >> 2) & 0x03;
const pad = (b2 >> 1) & 0x01;
if (version === 3 && layer === 2 && MP2_BITRATE_MPEG1_L2[brIdx] && MP2_SR_MPEG1[srIdx]) {
const bitrate = MP2_BITRATE_MPEG1_L2[brIdx] * 1000;
const sampleRate = MP2_SR_MPEG1[srIdx];
const frameLen = Math.floor((144 * bitrate) / sampleRate) + pad;
// Clear "original" bit (bit2 in 4th header byte): c4 -> c0
audioES[i + 3] &= 0xFB;
patched++;
i += frameLen;
continue;
}
}
i++;
}
console.log(`[+] Normalized MP2 headers: patched ${patched} frame(s)`);
}
/**
* Build MPEG-1 PS with strict 2048-byte packs matching attract.mpg structure:
* - One PES per pack
* - All PES optional headers: ff 0f (no timestamps)
* - 3 audio pre-fill packs, then 1 audio per ~7 video packs
* - No BB system header
*/
function buildWebTVPS(videoES, audioES, outputPath, audioIntervalOverride, baHeaderMode) {
console.log('[*] Building WebTV MPEG-1 PS...');
const P = DATA_PER_PACK; // 2028
const baHdr = baHeaderMode === 'attract' ? BA_HDR_ATTRACT : BA_HDR_MPEG1;
function makePack(streamId, data) {
// Pad or trim data to exactly P bytes
const payload = Buffer.alloc(P);
data.copy(payload, 0, 0, Math.min(data.length, P));
const pktLen = P + 2; // ff 0f(2) + data(P) = 2030
const pesHdr = Buffer.alloc(8);
pesHdr[0] = 0x00; pesHdr[1] = 0x00; pesHdr[2] = 0x01; pesHdr[3] = streamId;
pesHdr[4] = (pktLen >> 8) & 0xFF;
pesHdr[5] = pktLen & 0xFF;
pesHdr[6] = 0xFF; // ff 0f
pesHdr[7] = 0x0F;
return Buffer.concat([baHdr, pesHdr, payload]); // 12 + 6 + 2 + 2028 = 2048
}
// Split ES into P-byte chunks
const vChunks = [];
for (let i = 0; i < videoES.length; i += P) vChunks.push(videoES.slice(i, i + P));
const aChunks = [];
for (let i = 0; i < audioES.length; i += P) aChunks.push(audioES.slice(i, i + P));
if (!vChunks.length || !aChunks.length) {
console.error('[!] Empty video or audio stream');
return false;
}
// Match known-working WebTV cadence (attract.mpg is ~1 audio per 7 video packs)
const inferredInterval = Math.max(1, Math.round(vChunks.length / aChunks.length));
const audioInterval = Number.isFinite(audioIntervalOverride) && audioIntervalOverride > 0
? Math.floor(audioIntervalOverride)
: Math.max(1, Math.round((inferredInterval + 7) / 2));
console.log(`[*] ${vChunks.length} video chunks, ${aChunks.length} audio chunks, ` +
`1 audio per ~${audioInterval} video`);
const packs = [];
let aIdx = 0;
// Pre-fill: 3 audio packs to prime the WebTV audio buffer
const preFill = Math.min(3, aChunks.length);
for (let k = 0; k < preFill; k++, aIdx++) {
packs.push(makePack(0xC0, aChunks[aIdx]));
}
let vIdx = 0;
// Spread audio over the full video timeline to avoid starving early playback
// and dumping remaining audio at EOF.
while (vIdx < vChunks.length || aIdx < aChunks.length) {
if (vIdx >= vChunks.length) {
packs.push(makePack(0xC0, aChunks[aIdx++]));
continue;
}
if (aIdx >= aChunks.length) {
packs.push(makePack(0xE0, vChunks[vIdx++]));
continue;
}
const videoProgress = vIdx / vChunks.length;
const audioProgress = aIdx / aChunks.length;
// Prefer video until audio falls behind target cadence.
if (audioProgress + (1 / Math.max(1, audioInterval * aChunks.length)) < videoProgress) {
packs.push(makePack(0xC0, aChunks[aIdx++]));
} else {
packs.push(makePack(0xE0, vChunks[vIdx++]));
}
}
const endCode = Buffer.from([0x00, 0x00, 0x01, 0xB9]);
const output = Buffer.concat([...packs, endCode]);
fs.writeFileSync(outputPath, output);
console.log(`[+] Wrote ${packs.length} packs (${output.length} bytes)`);
return true;
}
function verifyFile(mpgPath) {
console.log('[*] Verifying file structure...');
try {
const result = execFileSync('ffprobe', [
'-v', 'error',
'-show_entries', 'stream=codec_name,codec_type',
'-of', 'default=noprint_wrappers=1',
mpgPath
], { encoding: 'utf8' });
if (result.includes('mpeg1video') && result.includes('mp2')) {
console.log('[+] Valid: mpeg1video + mp2');
return true;
}
console.error('[!] Missing video or audio stream');
return false;
} catch (e) {
console.error(`[!] ffprobe failed: ${e.message}`);
return false;
}
}
function checkPacks(mpgPath) {
console.log('[*] Checking pack structure...');
const d = fs.readFileSync(mpgPath);
const baPos = [];
for (let i = 0; i < d.length - 3; i++) {
if (d[i] === 0x00 && d[i+1] === 0x00 && d[i+2] === 0x01 && d[i+3] === 0xBA) {
baPos.push(i);
i += 3;
}
}
if (baPos.length < 2) {
console.error('[!] Less than 2 BA packs found');
return false;
}
const strides = new Set();
for (let i = 0; i < baPos.length - 1; i++) {
strides.add(baPos[i+1] - baPos[i]);
}
if (strides.size === 1 && strides.has(2048)) {
console.log(`[+] Perfect: all ${baPos.length} packs are 2048 bytes`);
return true;
}
console.log(`[!] Pack strides vary: ${[...strides].sort((a, b) => a - b).join(', ')}`);
return false;
}
/**
* Encode video to WebTV-compatible MPEG-1 PS.
*
* @param {string} inputFile Any video file ffmpeg can read
* @param {string} outputFile Output .mpg path
* @param {number|null} duration Optional clip length in seconds
*/
function encodeWebTV(inputFile, outputFile, duration, audioIntervalOverride, baHeaderMode, audioEncoder, audioESOverridePath) {
const tmpFile = outputFile.replace(/(\.[^.]+)$/, '_raw$1');
// Step 1: Encode with ffmpeg (MPEG-1 video + MP2 audio, raw mux)
const cmd = ['ffmpeg', '-y', '-i', inputFile];
if (duration != null) cmd.push('-t', String(duration));
cmd.push(
'-vf', 'fps=15,scale=272:208,setsar=1',
'-c:v', 'mpeg1video',
'-b:v', '500k',
'-maxrate', '700k',
'-bufsize', '1024k',
'-g', '15',
'-bf', '2',
'-c:a', audioEncoder,
'-ar', '44100',
'-ac', '1',
'-b:a', '80k',
'-strict', 'unofficial',
'-f', 'mpeg',
'-muxrate', '2000k',
tmpFile
);
if (!runCmd(cmd, 'Encoding with ffmpeg (MPEG-1 PS)')) return false;
// Step 2: Extract ES using structure-aware pack walk
const { videoES, audioES } = extractES(tmpFile);
if (!videoES.length || !audioES.length) {
console.error('[!] Failed to extract elementary streams');
return false;
}
// Step 3: Patch video sequence headers (fr_code=10, constrained=1)
const videoMut = Buffer.from(videoES);
patchSequenceHeaders(videoMut);
let audioSource = audioES;
if (audioESOverridePath) {
if (!fs.existsSync(audioESOverridePath)) {
console.error(`[!] Audio ES override not found: ${audioESOverridePath}`);
return false;
}
audioSource = fs.readFileSync(audioESOverridePath);
console.log(`[+] Using external audio ES override: ${audioESOverridePath} (${audioSource.length} bytes)`);
}
const audioMut = Buffer.from(audioSource);
normalizeMP2Headers(audioMut);
// Step 4: Rebuild as proper WebTV PS
if (!buildWebTVPS(videoMut, audioMut, outputFile, audioIntervalOverride, baHeaderMode)) return false;
// Step 5: Verify
if (!verifyFile(outputFile)) return false;
checkPacks(outputFile);
// Cleanup temp file
try { fs.unlinkSync(tmpFile); } catch (_) {}
console.log(`\n[+] Successfully encoded: ${outputFile}`);
return true;
}
// --- CLI entry point ---
const args = process.argv.slice(2);
if (args.length < 2) {
const script = path.basename(process.argv[1]);
console.error(`Usage: node ${script} <input_video> <output.mpg> [duration_seconds]`);
console.error(`Example: node ${script} myvideo.mp4 webtv.mpg 15`);
process.exit(1);
}
let audioIntervalOverride = null;
let baHeaderMode = 'mpeg1';
let audioEncoder = 'mp2fixed';
let audioESOverridePath = null;
const nonFlagArgs = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--audio-interval' && i + 1 < args.length) {
audioIntervalOverride = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '--ba-header' && i + 1 < args.length) {
baHeaderMode = String(args[i + 1]).toLowerCase() === 'attract' ? 'attract' : 'mpeg1';
i++;
} else if (args[i] === '--audio-encoder' && i + 1 < args.length) {
const v = String(args[i + 1]).toLowerCase();
audioEncoder = (v === 'mp2fixed') ? 'mp2fixed' : 'mp2';
i++;
} else if (args[i] === '--audio-es' && i + 1 < args.length) {
audioESOverridePath = args[i + 1];
i++;
} else {
nonFlagArgs.push(args[i]);
}
}
const [inputFile, outputFile, durationArg] = nonFlagArgs;
const duration = durationArg != null ? parseFloat(durationArg) : null;
if (!fs.existsSync(inputFile)) {
console.error(`[!] Input file not found: ${inputFile}`);
process.exit(1);
}
if (!encodeWebTV(inputFile, outputFile, duration, audioIntervalOverride, baHeaderMode, audioEncoder, audioESOverridePath)) {
process.exit(1);
}