diff --git a/apps/electron-backend/src/app/events/stalker-identity.spec.ts b/apps/electron-backend/src/app/events/stalker-identity.spec.ts new file mode 100644 index 000000000..53c5ea3fc --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker-identity.spec.ts @@ -0,0 +1,95 @@ +import { + LEGACY_DEFAULT_STALKER_SERIAL, + buildStalkerIdentityRequestContext, +} from './stalker-identity'; + +describe('buildStalkerIdentityRequestContext', () => { + const macAddress = '00:1A:79:AA:BB:CC'; + + it('does not add SN header or force get_profile SN fields when serial is absent', () => { + const context = buildStalkerIdentityRequestContext({ + macAddress, + params: { + type: 'stb', + action: 'get_profile', + sn: 'STALE-SN', + metrics: JSON.stringify({ + mac: macAddress, + sn: 'STALE-SN', + }), + }, + }); + + expect(context.effectiveSerialNumber).toBeUndefined(); + expect(context.headers).not.toHaveProperty('SN'); + expect(context.cookieString).not.toContain('__cfduid='); + expect(context.requestParams).not.toHaveProperty('sn'); + expect( + JSON.parse(String(context.requestParams.metrics)) + ).not.toHaveProperty('sn'); + }); + + it('preserves a provided serial exactly in headers, params, and metrics', () => { + const context = buildStalkerIdentityRequestContext({ + macAddress, + serialNumber: 'CustomSn123', + params: { + type: 'stb', + action: 'get_profile', + metrics: JSON.stringify({ + mac: macAddress, + }), + }, + }); + + expect(context.effectiveSerialNumber).toBe('CustomSn123'); + expect(context.headers.SN).toBe('CustomSn123'); + expect(context.cookieString).toContain('__cfduid='); + expect( + context.cookieString.match(/__cfduid=([^;]+)/)?.[1] + ).toHaveLength(32); + expect(context.requestParams.sn).toBe('CustomSn123'); + expect(JSON.parse(String(context.requestParams.metrics))).toEqual( + expect.objectContaining({ + sn: 'CustomSn123', + }) + ); + }); + + it('treats the legacy default serial as absent', () => { + const context = buildStalkerIdentityRequestContext({ + macAddress, + serialNumber: LEGACY_DEFAULT_STALKER_SERIAL, + params: { + type: 'stb', + action: 'get_profile', + sn: LEGACY_DEFAULT_STALKER_SERIAL, + metrics: JSON.stringify({ + mac: macAddress, + sn: LEGACY_DEFAULT_STALKER_SERIAL, + }), + }, + }); + + expect(context.effectiveSerialNumber).toBeUndefined(); + expect(context.headers).not.toHaveProperty('SN'); + expect(context.cookieString).not.toContain('__cfduid='); + expect(context.requestParams).not.toHaveProperty('sn'); + expect( + JSON.parse(String(context.requestParams.metrics)) + ).not.toHaveProperty('sn'); + }); + + it('removes stale SN params from non-profile requests', () => { + const context = buildStalkerIdentityRequestContext({ + macAddress, + params: { + type: 'itv', + action: 'get_ordered_list', + sn: 'STALE-SN', + }, + }); + + expect(context.requestParams).not.toHaveProperty('sn'); + }); +}); diff --git a/apps/electron-backend/src/app/events/stalker-identity.ts b/apps/electron-backend/src/app/events/stalker-identity.ts new file mode 100644 index 000000000..4583f5b9f --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker-identity.ts @@ -0,0 +1,117 @@ +import { + buildStalkerSerialCfduid, + LEGACY_DEFAULT_STALKER_SERIAL, + normalizeStalkerSerialNumber, +} from '@iptvnator/shared/interfaces'; + +export { LEGACY_DEFAULT_STALKER_SERIAL }; + +interface StalkerIdentityRequestInput { + macAddress: string; + params: Record; + token?: string; + serialNumber?: string; +} + +interface StalkerIdentityRequestContext { + requestParams: Record; + headers: Record; + cookieString: string; + effectiveSerialNumber?: string; +} + +export function buildStalkerIdentityRequestContext({ + macAddress, + params, + token, + serialNumber, +}: StalkerIdentityRequestInput): StalkerIdentityRequestContext { + const effectiveSerialNumber = normalizeStalkerSerialNumber(serialNumber); + const requestParams = buildRequestParams(params, effectiveSerialNumber); + const cookieString = buildCookieString(macAddress, effectiveSerialNumber); + const headers: Record = { + Cookie: cookieString, + // Use MAG250 User-Agent matching stalker-to-m3u implementation + 'User-Agent': + 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250', + 'X-User-Agent': + 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250', + Accept: '*/*', + Connection: 'keep-alive', + 'Accept-Language': 'en-US,en;q=0.9', + }; + + if (effectiveSerialNumber) { + headers['SN'] = effectiveSerialNumber; + } + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return { + requestParams, + headers, + cookieString, + ...(effectiveSerialNumber ? { effectiveSerialNumber } : {}), + }; +} + +function buildRequestParams( + params: Record, + effectiveSerialNumber: string | undefined +): Record { + const requestParams = { ...params }; + const isProfileRequest = + requestParams.type === 'stb' && + requestParams.action === 'get_profile'; + + if (!isProfileRequest) { + delete requestParams.sn; + return requestParams; + } + + if (effectiveSerialNumber) { + requestParams.sn = effectiveSerialNumber; + } else { + delete requestParams.sn; + } + + if (typeof requestParams.metrics === 'string') { + try { + const parsedMetrics = JSON.parse(requestParams.metrics); + const nextMetrics = { ...(parsedMetrics ?? {}) }; + + if (effectiveSerialNumber) { + nextMetrics.sn = effectiveSerialNumber; + } else { + delete nextMetrics.sn; + } + + requestParams.metrics = JSON.stringify(nextMetrics); + } catch { + // Keep original metrics payload when malformed. + } + } + + return requestParams; +} + +function buildCookieString( + macAddress: string, + effectiveSerialNumber: string | undefined +): string { + const cookieParts = [ + `mac=${macAddress}`, + 'stb_lang=en_US@rg=dezzzz', + 'timezone=Europe/Berlin', + ]; + + if (effectiveSerialNumber) { + cookieParts.push( + `__cfduid=${buildStalkerSerialCfduid(effectiveSerialNumber)}` + ); + } + + return cookieParts.join('; '); +} diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 4d04de11a..81eaa5955 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -4,40 +4,11 @@ */ import axios, { AxiosRequestConfig } from 'axios'; -import { createHash } from 'crypto'; import { ipcMain } from 'electron'; import { PortalDebugEvent, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service'; import { emitPortalDebugEvent } from './portal-debug.events'; - -const LEGACY_DEFAULT_SERIAL = 'BEDACD4569BAF'; - -function deriveStalkerIdentity( - macAddress: string, - providedSerial?: string -): { serialNumber: string; cfduid: string } { - const normalizedMac = String(macAddress ?? '').trim().toUpperCase(); - const md5 = createHash('md5').update(normalizedMac).digest('hex'); - const derivedSerial = md5.slice(0, 13).toUpperCase(); - - const normalizedProvided = String(providedSerial ?? '') - .trim() - .toUpperCase(); - const useProvidedSerial = - normalizedProvided.length > 0 && - normalizedProvided !== LEGACY_DEFAULT_SERIAL; - - const serialNumber = useProvidedSerial ? normalizedProvided : derivedSerial; - - // Keep serial and __cfduid coherent: serial as prefix + stable MAC hash tail. - const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, ''); - const cfduid = `${serialPrefix}${md5.slice(serialPrefix.length)}`.slice( - 0, - 32 - ); - - return { serialNumber, cfduid }; -} +import { buildStalkerIdentityRequestContext } from './stalker-identity'; export default class StalkerEvents { static bootstrapStalkerEvents(): Electron.IpcMain { @@ -55,7 +26,7 @@ ipcMain.handle( payload: { url: string; macAddress: string; - params: Record; + params: Record; token?: string; serialNumber?: string; requestId?: string; @@ -66,29 +37,13 @@ ipcMain.handle( try { const { url, macAddress, params, token, serialNumber, requestId } = payload; - const identity = deriveStalkerIdentity(macAddress, serialNumber); - const effectiveSerialNumber = identity.serialNumber; - const requestParams = { ...params }; - - // Some providers validate sn/metrics strongly in get_profile. - if ( - requestParams.type === 'stb' && - requestParams.action === 'get_profile' - ) { - requestParams.sn = effectiveSerialNumber; - - if (typeof requestParams.metrics === 'string') { - try { - const parsedMetrics = JSON.parse(requestParams.metrics); - requestParams.metrics = JSON.stringify({ - ...(parsedMetrics ?? {}), - sn: effectiveSerialNumber, - }); - } catch { - // Keep original metrics payload when malformed. - } - } - } + const { requestParams, headers, effectiveSerialNumber } = + buildStalkerIdentityRequestContext({ + macAddress, + params, + token, + serialNumber, + }); // Build URL with query parameters // Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent) @@ -117,34 +72,6 @@ ipcMain.handle( // Build final URL with manually constructed query string const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`; - // Build cookie string matching the working curl example format - // Format: mac=XX:XX:XX:XX:XX:XX; stb_lang=de_DE; timezone=Europe/Berlin; __cfduid=... - // The __cfduid cookie uses the serial number lowercase + random suffix - const cookieString = `mac=${macAddress}; stb_lang=en_US@rg=dezzzz; timezone=Europe/Berlin; __cfduid=${identity.cfduid}`; - - // Build headers - using MAG250 User-Agent that stalker-to-m3u uses - const headers: Record = { - Cookie: cookieString, - // Use MAG250 User-Agent matching stalker-to-m3u implementation - 'User-Agent': - 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250', - 'X-User-Agent': - 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250', - Accept: '*/*', - Connection: 'keep-alive', - 'Accept-Language': 'en-US,en;q=0.9', - }; - - // Add SN (serial number) header if provided - required by some portals - if (effectiveSerialNumber) { - headers['SN'] = effectiveSerialNumber; - } - - // Add Authorization header if token is provided - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - // Determine timeout based on action type // create_link requests can take longer as server generates stream URL const isCreateLink = requestParams.action === 'create_link'; @@ -200,7 +127,7 @@ ipcMain.handle( const debugEvent: PortalDebugEvent = { requestId, provider: 'stalker', - operation: params.action ?? 'unknown', + operation: String(params.action ?? 'unknown'), transport: 'electron-main', startedAt: new Date(startedAt).toISOString(), durationMs: Date.now() - startedAt, @@ -217,7 +144,7 @@ ipcMain.handle( const debugEvent: PortalDebugEvent = { requestId: payload.requestId, provider: 'stalker', - operation: payload.params?.action ?? 'unknown', + operation: String(payload.params?.action ?? 'unknown'), transport: 'electron-main', startedAt: new Date(startedAt).toISOString(), durationMs: Date.now() - startedAt, diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts new file mode 100644 index 000000000..c2e252773 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts @@ -0,0 +1,55 @@ +import { LEGACY_DEFAULT_STALKER_SERIAL } from '@iptvnator/shared/interfaces'; +import { + getStalkerPlaybackContextHeaders, + rememberStalkerPlaybackContext, +} from './stalker-playback-context.service'; + +describe('stalker playback context', () => { + const macAddress = '00:1A:79:AA:BB:CC'; + + function rememberSameOriginContext( + name: string, + serialNumber?: string + ): Record { + const origin = `http://${name}.example.test`; + const streamUrl = `${origin}/stream/1.ts`; + + rememberStalkerPlaybackContext({ + streamUrl, + portalUrl: `${origin}/stalker_portal/server/load.php`, + macAddress, + serialNumber, + token: 'token-1', + }); + + return getStalkerPlaybackContextHeaders(streamUrl) ?? {}; + } + + it('does not add SN or a serial-derived __cfduid when serial is absent', () => { + const headers = rememberSameOriginContext('stalker-no-serial'); + + expect(headers).not.toHaveProperty('SN'); + expect(headers['Cookie']).not.toContain('__cfduid='); + }); + + it('preserves a provided serial and creates a canonical __cfduid', () => { + const headers = rememberSameOriginContext( + 'stalker-with-serial', + ' CustomSn123 ' + ); + const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1]; + + expect(headers['SN']).toBe('CustomSn123'); + expect(cfduid).toHaveLength(32); + }); + + it('treats the legacy default serial as absent', () => { + const headers = rememberSameOriginContext( + 'stalker-legacy-serial', + LEGACY_DEFAULT_STALKER_SERIAL + ); + + expect(headers).not.toHaveProperty('SN'); + expect(headers['Cookie']).not.toContain('__cfduid='); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts index 7e778548d..1b6b3a1e7 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts @@ -1,3 +1,8 @@ +import { + buildStalkerSerialCfduid, + normalizeStalkerSerialNumber, +} from '@iptvnator/shared/interfaces'; + const STALKER_MAG_USER_AGENT = 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250'; const STALKER_STREAM_USER_AGENT = 'KSPlayer'; @@ -66,16 +71,15 @@ export function rememberStalkerPlaybackContext(input: { const streamKey = normalizeStreamUrl(input.streamUrl); if (!streamKey || !input.macAddress) return; + const serialNumber = normalizeStalkerSerialNumber(input.serialNumber); const cookieParts = [ `mac=${input.macAddress}`, 'stb_lang=en_US@rg=dezzzz', 'timezone=Europe/Berlin', ]; - if (input.serialNumber) { - cookieParts.push( - `__cfduid=${input.serialNumber.toLowerCase()}e030245495acd6ebfc1` - ); + if (serialNumber) { + cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`); } const streamOrigin = getOrigin(input.streamUrl) || undefined; @@ -112,8 +116,8 @@ export function rememberStalkerPlaybackContext(input: { headers['Referer'] = portalOrigin; } - if (!crossOriginStream && input.serialNumber) { - headers['SN'] = input.serialNumber; + if (!crossOriginStream && serialNumber) { + headers['SN'] = serialNumber; } if (!crossOriginStream && input.token) { diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 023cbb01d..4516ce294 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -103,6 +103,29 @@ Failure-handling rule: not `undefined` collections or renderer exceptions. The workspace Stalker context panel and live layout rely on this guarantee. +## Stalker Identity Policy + +Full Stalker/Ministra portal authentication defaults to MAC-only identity. The +import UI can capture optional serial number, device IDs, and signatures, but +blank fields are not generated or forwarded to `get_profile`. + +- User-provided `sn`, `device_id`, `device_id2`, `signature`, and `signature2` + values are trimmed, persisted under the canonical `stalker*` playlist fields, + and reused for initial auth, token refresh, retry auth, normal API requests, + and same-origin playback headers. +- Empty optional identity fields remain absent. IPTVnator must not generate a + device ID from the MAC address or duplicate `device_id2` from `device_id1`. +- The legacy default serial value `BEDACD4569BAF` is treated as absent at + runtime so older blank imports do not keep sending a synthetic serial number. +- Playback headers use the same serial normalization, so the legacy default is + not sent as `SN` or as a serial-derived `__cfduid`. MAC-only API and + playback requests do not synthesize `__cfduid`; when a real serial is + present, same-origin playback uses a canonical 32-character `__cfduid` + protocol cookie. +- Generated MAG-like identity remains a future explicit setting. It must not be + the default because strict portals can bind accounts to the first device + fingerprint they receive. + ## Live TV and Radio The Stalker live route and radio route intentionally share diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts new file mode 100644 index 000000000..dd9034684 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -0,0 +1,120 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; +import { StalkerPortalImportComponent } from './stalker-portal-import.component'; + +describe('StalkerPortalImportComponent identity handling', () => { + let component: StalkerPortalImportComponent; + let stalkerSession: { authenticate: jest.Mock }; + let store: { dispatch: jest.Mock }; + + beforeEach(() => { + stalkerSession = { + authenticate: jest.fn().mockResolvedValue({ token: 'token-1' }), + }; + store = { + dispatch: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: StalkerSessionService, useValue: stalkerSession }, + { provide: Store, useValue: store }, + { + provide: MatSnackBar, + useValue: { open: jest.fn() }, + }, + { + provide: TranslateService, + useValue: { instant: jest.fn((value: string) => value) }, + }, + ], + }); + + component = TestBed.runInInjectionContext( + () => new StalkerPortalImportComponent() + ); + }); + + it('passes trimmed SN, device IDs, and signatures into initial authentication and persisted playlist metadata', async () => { + component.form.patchValue({ + _id: 'playlist-1', + title: 'Strict Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + serialNumber: ' CUSTOMSN123 ', + deviceId1: ' DEVICE-ID-1 ', + deviceId2: ' DEVICE-ID-2 ', + signature1: ' SIGNATURE-1 ', + signature2: ' SIGNATURE-2 ', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + expect(stalkerSession.authenticate).toHaveBeenCalledWith( + 'https://portal.example.com/stalker_portal/server/load.php', + '00:1A:79:AA:BB:CC', + { + serialNumber: 'CUSTOMSN123', + deviceId1: 'DEVICE-ID-1', + deviceId2: 'DEVICE-ID-2', + signature1: 'SIGNATURE-1', + signature2: 'SIGNATURE-2', + } + ); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist).toEqual( + expect.objectContaining({ + stalkerSerialNumber: 'CUSTOMSN123', + stalkerDeviceId1: 'DEVICE-ID-1', + stalkerDeviceId2: 'DEVICE-ID-2', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: 'SIGNATURE-2', + }) + ); + expect(playlist.serialNumber).toBeUndefined(); + expect(playlist.deviceId1).toBeUndefined(); + expect(playlist.deviceId2).toBeUndefined(); + expect(playlist.signature1).toBeUndefined(); + expect(playlist.signature2).toBeUndefined(); + }); + + it('keeps blank Stalker identity fields absent instead of generating defaults', async () => { + component.form.patchValue({ + _id: 'playlist-1', + title: 'MAC Only Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + serialNumber: ' ', + deviceId1: ' ', + deviceId2: '', + signature1: ' ', + signature2: '', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + expect(stalkerSession.authenticate).toHaveBeenCalledWith( + 'https://portal.example.com/stalker_portal/server/load.php', + '00:1A:79:AA:BB:CC', + {} + ); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist.stalkerSerialNumber).toBeUndefined(); + expect(playlist.stalkerDeviceId1).toBeUndefined(); + expect(playlist.stalkerDeviceId2).toBeUndefined(); + expect(playlist.stalkerSignature1).toBeUndefined(); + expect(playlist.stalkerSignature2).toBeUndefined(); + expect(playlist.serialNumber).toBeUndefined(); + expect(playlist.deviceId1).toBeUndefined(); + expect(playlist.deviceId2).toBeUndefined(); + expect(playlist.signature1).toBeUndefined(); + expect(playlist.signature2).toBeUndefined(); + }); +}); diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 397b5a368..a812afa05 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -13,8 +13,9 @@ import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { - STALKER_SERIAL_NUMBER, + StalkerPortalIdentity, StalkerSessionService, + normalizeStalkerPortalIdentity, } from '@iptvnator/portal/stalker/data-access'; import { Playlist } from '@iptvnator/shared/interfaces'; import { v4 as uuid } from 'uuid'; @@ -105,44 +106,30 @@ export class StalkerPortalImportComponent { this.isLoading.set(true); try { - const originalUrl = this.form.value.portalUrl; + const formValue = this.form.getRawValue(); + const originalUrl = formValue.portalUrl ?? ''; const transformedUrl = this.transformPortalUrl(originalUrl); const isFullStalkerPortal = this.isFullStalkerPortalUrl(originalUrl); + const stalkerIdentity = normalizeStalkerPortalIdentity({ + serialNumber: formValue.serialNumber, + deviceId1: formValue.deviceId1, + deviceId2: formValue.deviceId2, + signature1: formValue.signature1, + signature2: formValue.signature2, + }); let stalkerToken: string | undefined; let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined; - let stalkerSerialNumber: string | undefined; - let stalkerDeviceId1: string | undefined; - let stalkerDeviceId2: string | undefined; - let stalkerSignature1: string | undefined; - let stalkerSignature2: string | undefined; // For full stalker portal URLs, perform handshake and get profile if (isFullStalkerPortal) { - // Use provided serial number or generate a new one - stalkerSerialNumber = - this.form.value.serialNumber?.trim() || - STALKER_SERIAL_NUMBER; - - // Use provided device IDs if available (64 hex chars each) - stalkerDeviceId1 = - this.form.value.deviceId1?.trim() || undefined; - stalkerDeviceId2 = - this.form.value.deviceId2?.trim() || undefined; - stalkerSignature1 = - this.form.value.signature1?.trim() || undefined; - stalkerSignature2 = - this.form.value.signature2?.trim() || undefined; - try { const authResult = await this.stalkerSessionService.authenticate( transformedUrl, - this.form.value.macAddress, - stalkerSerialNumber, - stalkerDeviceId1, - stalkerDeviceId2 + formValue.macAddress ?? '', + stalkerIdentity ); stalkerToken = authResult.token; @@ -183,17 +170,22 @@ export class StalkerPortalImportComponent { } } + const { + serialNumber: _serialNumber, + deviceId1: _deviceId1, + deviceId2: _deviceId2, + signature1: _signature1, + signature2: _signature2, + ...playlistFormValue + } = formValue; + const playlist: Playlist = { - ...this.form.value, + ...playlistFormValue, portalUrl: transformedUrl, isFullStalkerPortal, stalkerToken, stalkerAccountInfo, - stalkerSerialNumber, - stalkerDeviceId1, - stalkerDeviceId2, - stalkerSignature1, - stalkerSignature2, + ...this.toPlaylistIdentityFields(stalkerIdentity), } as Playlist; this.store.dispatch(PlaylistActions.addPlaylist({ playlist })); @@ -211,6 +203,32 @@ export class StalkerPortalImportComponent { return url.includes('/stalker_portal'); } + private toPlaylistIdentityFields(identity: StalkerPortalIdentity): { + stalkerSerialNumber?: string; + stalkerDeviceId1?: string; + stalkerDeviceId2?: string; + stalkerSignature1?: string; + stalkerSignature2?: string; + } { + return { + ...(identity.serialNumber + ? { stalkerSerialNumber: identity.serialNumber } + : {}), + ...(identity.deviceId1 + ? { stalkerDeviceId1: identity.deviceId1 } + : {}), + ...(identity.deviceId2 + ? { stalkerDeviceId2: identity.deviceId2 } + : {}), + ...(identity.signature1 + ? { stalkerSignature1: identity.signature1 } + : {}), + ...(identity.signature2 + ? { stalkerSignature2: identity.signature2 } + : {}), + }; + } + /** * Transforms the portal URL to the correct API endpoint * - Simple URL (example.com/c) -> example.com/portal.php diff --git a/libs/portal/stalker/data-access/src/lib/stalker-identity.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-identity.utils.ts new file mode 100644 index 000000000..99b35c0c1 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-identity.utils.ts @@ -0,0 +1,33 @@ +import { + normalizeStalkerPortalIdentity as normalizeSharedStalkerPortalIdentity, + type Playlist, + type StalkerPortalIdentity, +} from '@iptvnator/shared/interfaces'; + +export { + buildStalkerSerialCfduid, + LEGACY_DEFAULT_STALKER_SERIAL, + normalizeStalkerIdentityValue, + normalizeStalkerPortalIdentity, + normalizeStalkerSerialNumber, + type StalkerPortalIdentity, +} from '@iptvnator/shared/interfaces'; + +export function getStalkerPortalIdentityFromPlaylist( + playlist: Pick< + Playlist, + | 'stalkerSerialNumber' + | 'stalkerDeviceId1' + | 'stalkerDeviceId2' + | 'stalkerSignature1' + | 'stalkerSignature2' + > +): StalkerPortalIdentity { + return normalizeSharedStalkerPortalIdentity({ + serialNumber: playlist.stalkerSerialNumber, + deviceId1: playlist.stalkerDeviceId1, + deviceId2: playlist.stalkerDeviceId2, + signature1: playlist.stalkerSignature1, + signature2: playlist.stalkerSignature2, + }); +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts new file mode 100644 index 000000000..c9517d0a3 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts @@ -0,0 +1,66 @@ +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { buildStalkerExternalPlaybackHeaders } from './stalker-live-playback.utils'; +import { STALKER_SERIAL_NUMBER } from './stalker-session.service'; + +function createPlaylist( + overrides: Partial = {} +): PlaylistMeta { + return { + _id: 'playlist-1', + title: 'Playlist', + filename: '', + count: 0, + url: '', + importDate: '', + filePath: '', + updateDate: '', + updateState: '', + position: 0, + autoRefresh: false, + favorites: [], + serverUrl: '', + username: '', + password: '', + macAddress: '00:1A:79:AA:BB:CC', + hiddenGroupTitles: [], + portalUrl: 'http://portal.test/stalker_portal/c/index.html', + recentlyViewed: [], + isFullStalkerPortal: true, + ...overrides, + }; +} + +describe('buildStalkerExternalPlaybackHeaders', () => { + it('treats the legacy default serial as absent', () => { + const headers = buildStalkerExternalPlaybackHeaders( + createPlaylist({ stalkerSerialNumber: STALKER_SERIAL_NUMBER }), + 'TOKEN', + 'http://portal.test/stalker_portal/server/load.php' + ); + + expect(headers).not.toHaveProperty('SN'); + expect(headers['Cookie']).not.toContain('__cfduid='); + }); + + it('trims a provided serial before adding same-origin playback headers', () => { + const headers = buildStalkerExternalPlaybackHeaders( + createPlaylist({ stalkerSerialNumber: ' CustomSn123 ' }), + 'TOKEN', + 'http://portal.test/stalker_portal/server/load.php' + ); + + expect(headers['SN']).toBe('CustomSn123'); + expect(headers['Cookie']).toContain('__cfduid='); + }); + + it('uses a canonical 32-character __cfduid when serial is provided', () => { + const headers = buildStalkerExternalPlaybackHeaders( + createPlaylist({ stalkerSerialNumber: ' CustomSn123 ' }), + 'TOKEN', + 'http://portal.test/stalker_portal/server/load.php' + ); + + const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1]; + expect(cfduid).toHaveLength(32); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts index 4318d1a77..c7ed7d597 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts @@ -1,4 +1,8 @@ import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + buildStalkerSerialCfduid, + normalizeStalkerSerialNumber, +} from './stalker-identity.utils'; export const STALKER_MAG_USER_AGENT = 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250'; @@ -59,19 +63,23 @@ export function buildStalkerExternalPlaybackHeaders( 'stb_lang=en_US@rg=dezzzz', 'timezone=Europe/Berlin', ]; + const serialNumber = normalizeStalkerSerialNumber( + playlist.stalkerSerialNumber + ); - if (playlist.stalkerSerialNumber) { - cookieParts.push( - `__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1` - ); + if (serialNumber) { + cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`); } const headers: Record = { Cookie: cookieParts.join('; '), 'X-User-Agent': STALKER_MAG_USER_AGENT, - SN: playlist.stalkerSerialNumber || '', }; + if (serialNumber) { + headers['SN'] = serialNumber; + } + if (token) { headers['Authorization'] = `Bearer ${token}`; } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts new file mode 100644 index 000000000..51810adb6 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -0,0 +1,189 @@ +import { TestBed } from '@angular/core/testing'; +import { DataService } from '@iptvnator/services'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { + STALKER_SERIAL_NUMBER, + StalkerProfileResponse, + StalkerSessionService, +} from './stalker-session.service'; + +type ExpectedStalkerPortalIdentity = { + serialNumber?: string; + deviceId1?: string; + deviceId2?: string; + signature1?: string; + signature2?: string; +}; + +type GetProfileWithIdentity = ( + portalUrl: string, + macAddress: string, + token: string, + identity: ExpectedStalkerPortalIdentity, + handshakeRandom: string +) => Promise; + +describe('StalkerSessionService identity payloads', () => { + const portalUrl = 'https://portal.example.com/stalker_portal/server/load.php'; + const macAddress = '00:1A:79:AA:BB:CC'; + + let service: StalkerSessionService; + let dataService: { sendIpcEvent: jest.Mock }; + + beforeEach(() => { + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + subtle: { + digest: jest.fn( + async () => new Uint8Array(20).fill(1).buffer + ), + }, + }, + }); + + dataService = { + sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }), + }; + + TestBed.configureTestingModule({ + providers: [ + StalkerSessionService, + { provide: DataService, useValue: dataService }, + ], + }); + + service = TestBed.inject(StalkerSessionService); + }); + + it('omits SN, device IDs, and signatures from get_profile when identity is blank', async () => { + const getProfile = + service.getProfile as unknown as GetProfileWithIdentity; + + await getProfile.call( + service, + portalUrl, + macAddress, + 'token-1', + {}, + 'random-1' + ); + + const payload = lastStalkerPayload(); + expect(payload.serialNumber).toBeUndefined(); + expect(payload.params).not.toHaveProperty('sn'); + expect(payload.params).not.toHaveProperty('device_id'); + expect(payload.params).not.toHaveProperty('device_id2'); + expect(payload.params).not.toHaveProperty('signature'); + expect(payload.params).not.toHaveProperty('signature2'); + expect(JSON.parse(String(payload.params.metrics))).not.toHaveProperty( + 'sn' + ); + }); + + it('sends provided SN, device IDs, and signatures exactly in get_profile', async () => { + const getProfile = + service.getProfile as unknown as GetProfileWithIdentity; + + await getProfile.call( + service, + portalUrl, + macAddress, + 'token-1', + { + serialNumber: 'CUSTOMSN123', + deviceId1: 'DEVICE-ID-1', + deviceId2: 'DEVICE-ID-2', + signature1: 'SIGNATURE-1', + signature2: 'SIGNATURE-2', + }, + 'random-1' + ); + + const payload = lastStalkerPayload(); + expect(payload.serialNumber).toBe('CUSTOMSN123'); + expect(payload.params).toEqual( + expect.objectContaining({ + sn: 'CUSTOMSN123', + device_id: 'DEVICE-ID-1', + device_id2: 'DEVICE-ID-2', + signature: 'SIGNATURE-1', + signature2: 'SIGNATURE-2', + }) + ); + expect(JSON.parse(String(payload.params.metrics))).toEqual( + expect.objectContaining({ + sn: 'CUSTOMSN123', + }) + ); + }); + + it('passes stored playlist identity into ensureToken re-authentication', async () => { + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'fresh-token' }); + + await service.ensureToken({ + _id: 'playlist-1', + portalUrl, + macAddress, + isFullStalkerPortal: true, + stalkerSerialNumber: 'CUSTOMSN123', + stalkerDeviceId1: 'DEVICE-ID-1', + stalkerDeviceId2: 'DEVICE-ID-2', + stalkerSignature1: 'SIGNATURE-1', + stalkerSignature2: 'SIGNATURE-2', + } as Playlist); + + expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, { + serialNumber: 'CUSTOMSN123', + deviceId1: 'DEVICE-ID-1', + deviceId2: 'DEVICE-ID-2', + signature1: 'SIGNATURE-1', + signature2: 'SIGNATURE-2', + }); + }); + + it('treats the legacy default serial number as absent during ensureToken', async () => { + const authenticate = jest + .spyOn(service, 'authenticate') + .mockResolvedValue({ token: 'fresh-token' }); + + const result = await service.ensureToken({ + _id: 'playlist-1', + portalUrl, + macAddress, + isFullStalkerPortal: true, + stalkerSerialNumber: STALKER_SERIAL_NUMBER, + } as Playlist); + + expect(result.serialNumber).toBeUndefined(); + expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {}); + }); + + it('passes an explicit serial into the initial handshake request', async () => { + dataService.sendIpcEvent + .mockResolvedValueOnce({ + js: { + token: 'token-1', + random: 'random-1', + }, + }) + .mockResolvedValueOnce({ js: {} }); + + await service.authenticate(portalUrl, macAddress, { + serialNumber: 'CUSTOMSN123', + }); + + const handshakePayload = dataService.sendIpcEvent.mock.calls[0][1]; + expect(handshakePayload.params.action).toBe('handshake'); + expect(handshakePayload.serialNumber).toBe('CUSTOMSN123'); + }); + + function lastStalkerPayload(): { + params: Record; + serialNumber?: string; + } { + return dataService.sendIpcEvent.mock.calls.at(-1)?.[1]; + } +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index f0083378f..8368691ec 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -1,6 +1,18 @@ import { Injectable, inject } from '@angular/core'; import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces'; import { DataService } from '@iptvnator/services'; +import { + getStalkerPortalIdentityFromPlaylist, + LEGACY_DEFAULT_STALKER_SERIAL, + normalizeStalkerPortalIdentity, + type StalkerPortalIdentity, +} from './stalker-identity.utils'; + +export { + getStalkerPortalIdentityFromPlaylist, + normalizeStalkerPortalIdentity, +}; +export type { StalkerPortalIdentity }; /** * SHA1 hash using native Web Crypto API @@ -36,11 +48,7 @@ function generateRandom(): string { return result; } -/** - * Deterministic serial number - 13 hex characters - * Constant value for consistency across all sessions - */ -export const STALKER_SERIAL_NUMBER = 'BEDACD4569BAF'; +export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL; const STALKER_WATCHDOG_INTERVAL_MS = 25_000; export interface StalkerHandshakeResponse { @@ -97,25 +105,6 @@ export class StalkerSessionService { private watchdogInFlight = new Set(); private activeWatchdogPlaylistId: string | null = null; - /** - * Generates a consistent 64-character device ID from MAC address - */ - private generateDeviceId(macAddress: string): string { - // Generate consistent device ID from MAC - const str = macAddress.toUpperCase().replace(/:/g, ''); - const chars = '0123456789ABCDEF'; - let result = ''; - let seed = 0; - for (let i = 0; i < str.length; i++) { - seed = (seed * 31 + str.charCodeAt(i)) >>> 0; - } - for (let i = 0; i < 64; i++) { - seed = (seed * 1103515245 + 12345) >>> 0; - result += chars[seed % 16]; - } - return result; - } - /** * Checks if a URL is a full stalker portal URL (requires handshake) * Full stalker portal URLs contain /stalker_portal/ in the path @@ -252,8 +241,10 @@ export class StalkerSessionService { */ async performHandshake( portalUrl: string, - macAddress: string + macAddress: string, + identity: StalkerPortalIdentity = {} ): Promise<{ token: string; random: string }> { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); const prehash = await generatePrehash(macAddress); const params: Record = { @@ -269,9 +260,12 @@ export class StalkerSessionService { await this.dataService.sendIpcEvent( STALKER_REQUEST, { - url: portalUrl, - macAddress, - params, + url: portalUrl, + macAddress, + params, + ...(normalizedIdentity.serialNumber + ? { serialNumber: normalizedIdentity.serialNumber } + : {}), } ); @@ -298,29 +292,20 @@ export class StalkerSessionService { portalUrl: string, macAddress: string, token: string, - serialNumber: string, - handshakeRandom: string, - providedDeviceId1?: string, - providedDeviceId2?: string, - providedSignature1?: string, - providedSignature2?: string + identity: StalkerPortalIdentity, + handshakeRandom: string ): Promise { - // Use provided device IDs or generate from MAC (64 hex chars each) - const deviceId1 = - providedDeviceId1?.trim() || this.generateDeviceId(macAddress); - const deviceId2 = providedDeviceId2?.trim() || deviceId1; - - // Use provided signatures or empty string (some portals don't require them) - const signature1 = providedSignature1?.trim() || ''; - const signature2 = providedSignature2?.trim() || ''; + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); // Build metrics JSON matching working app - const metrics = { + const metrics: Record = { mac: macAddress, model: 'MAG250', type: 'STB', random: handshakeRandom, - sn: serialNumber, + ...(normalizedIdentity.serialNumber + ? { sn: normalizedIdentity.serialNumber } + : {}), }; // Generate prehash for get_profile (same as handshake) @@ -337,11 +322,21 @@ export class StalkerSessionService { auth_second_step: '1', num_banks: '2', metrics: JSON.stringify(metrics), - sn: serialNumber, - device_id: deviceId1, - device_id2: deviceId2, - signature: signature1, - ...(signature2 ? { signature2: signature2 } : {}), + ...(normalizedIdentity.serialNumber + ? { sn: normalizedIdentity.serialNumber } + : {}), + ...(normalizedIdentity.deviceId1 + ? { device_id: normalizedIdentity.deviceId1 } + : {}), + ...(normalizedIdentity.deviceId2 + ? { device_id2: normalizedIdentity.deviceId2 } + : {}), + ...(normalizedIdentity.signature1 + ? { signature: normalizedIdentity.signature1 } + : {}), + ...(normalizedIdentity.signature2 + ? { signature2: normalizedIdentity.signature2 } + : {}), prehash: prehash, stb_type: '', JsHttpRequest: '1-xml', @@ -352,11 +347,13 @@ export class StalkerSessionService { await this.dataService.sendIpcEvent( STALKER_REQUEST, { - url: portalUrl, - macAddress, - params, - token, - serialNumber, + url: portalUrl, + macAddress, + params, + token, + ...(normalizedIdentity.serialNumber + ? { serialNumber: normalizedIdentity.serialNumber } + : {}), } ); @@ -380,8 +377,6 @@ export class StalkerSessionService { action: 'do_auth', login: '', password: '', - device_id: '', - device_id2: '', JsHttpRequest: '1-xml', }; @@ -390,10 +385,10 @@ export class StalkerSessionService { await this.dataService.sendIpcEvent( STALKER_REQUEST, { - url: portalUrl, - macAddress, - params, - token, + url: portalUrl, + macAddress, + params, + token, } ); @@ -416,19 +411,18 @@ export class StalkerSessionService { async authenticate( portalUrl: string, macAddress: string, - serialNumber: string, - deviceId1?: string, - deviceId2?: string, - signature1?: string, - signature2?: string + identity: StalkerPortalIdentity = {} ): Promise<{ token: string; accountInfo?: StalkerProfileResponse['js']['account_info']; }> { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + // Step 1: Handshake to get token and random const { token, random } = await this.performHandshake( portalUrl, - macAddress + macAddress, + normalizedIdentity ); // Step 2: Get profile to activate token and get account info @@ -438,12 +432,8 @@ export class StalkerSessionService { portalUrl, macAddress, token, - serialNumber, - random, - deviceId1, - deviceId2, - signature1, - signature2 + normalizedIdentity, + random ); // Check for profile-level errors @@ -480,10 +470,12 @@ export class StalkerSessionService { return { token: null }; } + const identity = getStalkerPortalIdentityFromPlaylist(playlist); + // Check in-memory cache first (valid for current session only) const cachedToken = this.getCachedToken(playlist._id); if (cachedToken) { - return { token: cachedToken }; + return { token: cachedToken, serialNumber: identity.serialNumber }; } // Check if there's already a pending authentication for this playlist @@ -503,12 +495,6 @@ export class StalkerSessionService { throw new Error('Portal URL and MAC address are required'); } - // Get or generate serial number - must be consistent for the MAC - let serialNumber = playlist.stalkerSerialNumber; - if (!serialNumber) { - serialNumber = STALKER_SERIAL_NUMBER; - } - // Create the authentication promise and store it to prevent concurrent auth attempts // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => { @@ -516,10 +502,10 @@ export class StalkerSessionService { const { token } = await this.authenticate( playlist.portalUrl, playlist.macAddress, - serialNumber + identity ); this.setCachedToken(playlist._id, token); - return { token, serialNumber }; + return { token, serialNumber: identity.serialNumber }; } finally { // Clean up pending promise regardless of success/failure this.pendingAuth.delete(playlist._id); @@ -532,8 +518,6 @@ export class StalkerSessionService { return authPromise; } - - /** * Checks if a response or error indicates an authorization failure */ @@ -578,8 +562,7 @@ export class StalkerSessionService { retryOnAuthFailure = true ): Promise { // Get token (will wait if auth is in progress) - const { token } = await this.ensureToken(playlist); - const serialNumber = playlist.stalkerSerialNumber; + const { token, serialNumber } = await this.ensureToken(playlist); try { const response = await this.dataService.sendIpcEvent( @@ -589,7 +572,7 @@ export class StalkerSessionService { macAddress: playlist.macAddress, params, token, - serialNumber, + ...(serialNumber ? { serialNumber } : {}), } ); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts index f06875ffd..9a42485d2 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts @@ -62,7 +62,7 @@ export function withStalkerPortal() { toStalkerSessionPlaylist(playlist) ); token = result.token ?? undefined; - serialNumber = playlist.stalkerSerialNumber; + serialNumber = result.serialNumber; } catch (error) { logger.error('Failed to get stalker token', error); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index e2ba5695d..bd5094dc8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -176,7 +176,7 @@ export class StalkerSearchComponent { playlist as Playlist ); token = result.token ?? undefined; - serialNumber = (playlist as Playlist).stalkerSerialNumber; + serialNumber = result.serialNumber; } catch (error) { this.logger.error('Failed to get stalker token', error); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index b63c9a86c..93670032f 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -43,6 +43,7 @@ export * from './lib/xtream-vod-stream.interface'; // Stalker interfaces export * from './lib/stalker-item.normalizer'; +export * from './lib/stalker-identity.utils'; export * from './lib/stalker-portal-item.interface'; export * from './lib/stalker-serial-details.interface'; export * from './lib/stalker-vod-details.interface'; diff --git a/libs/shared/interfaces/src/lib/stalker-identity.utils.ts b/libs/shared/interfaces/src/lib/stalker-identity.utils.ts new file mode 100644 index 000000000..8acedaada --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-identity.utils.ts @@ -0,0 +1,58 @@ +export const LEGACY_DEFAULT_STALKER_SERIAL = 'BEDACD4569BAF'; +const STALKER_CFDUID_LENGTH = 32; +const STALKER_SERIAL_CFDUID_SUFFIX = 'e030245495acd6ebfc1'; + +export interface StalkerPortalIdentity { + serialNumber?: string; + deviceId1?: string; + deviceId2?: string; + signature1?: string; + signature2?: string; +} + +export function normalizeStalkerIdentityValue( + value: string | undefined | null +): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeStalkerSerialNumber( + serialNumber: string | undefined | null +): string | undefined { + const trimmed = normalizeStalkerIdentityValue(serialNumber); + if ( + !trimmed || + trimmed.toUpperCase() === LEGACY_DEFAULT_STALKER_SERIAL + ) { + return undefined; + } + + return trimmed; +} + +export function normalizeStalkerPortalIdentity( + identity: StalkerPortalIdentity | undefined | null +): StalkerPortalIdentity { + const serialNumber = normalizeStalkerSerialNumber(identity?.serialNumber); + const deviceId1 = normalizeStalkerIdentityValue(identity?.deviceId1); + const deviceId2 = normalizeStalkerIdentityValue(identity?.deviceId2); + const signature1 = normalizeStalkerIdentityValue(identity?.signature1); + const signature2 = normalizeStalkerIdentityValue(identity?.signature2); + + return { + ...(serialNumber ? { serialNumber } : {}), + ...(deviceId1 ? { deviceId1 } : {}), + ...(deviceId2 ? { deviceId2 } : {}), + ...(signature1 ? { signature1 } : {}), + ...(signature2 ? { signature2 } : {}), + }; +} + +export function buildStalkerSerialCfduid(serialNumber: string): string { + const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, ''); + + return `${serialPrefix}${STALKER_SERIAL_CFDUID_SUFFIX}` + .slice(0, STALKER_CFDUID_LENGTH) + .padEnd(STALKER_CFDUID_LENGTH, '0'); +}