From b99abacc5db1c3ccc51848a31139f86e74c22c5a Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Thu, 23 Apr 2026 12:45:04 -0700 Subject: [PATCH 01/14] feat: remote platform erase capability --- docker-compose.yml | 17 ++- src/amt/DeviceAction.ts | 101 +++++++++++++- src/amt/deviceAction.test.ts | 33 ++++- src/routes/amt/amtFeatureValidator.ts | 3 +- src/routes/amt/getAMTFeatures.test.ts | 14 +- src/routes/amt/getAMTFeatures.ts | 10 +- src/routes/amt/getBootCapabilities.test.ts | 65 +++++++++ src/routes/amt/getBootCapabilities.ts | 27 ++++ src/routes/amt/index.ts | 6 + src/routes/amt/powerCapabilities.test.ts | 8 +- src/routes/amt/powerCapabilities.ts | 2 +- src/routes/amt/sendRemoteErase.test.ts | 98 ++++++++++++++ src/routes/amt/sendRemoteErase.ts | 49 +++++++ src/routes/amt/setAMTFeatures.ts | 28 +++- src/routes/amt/setRPEEnabled.test.ts | 68 ++++++++++ src/routes/amt/setRPEEnabled.ts | 39 ++++++ src/server/webserver.ts | 2 +- .../collections/MPS.postman_collection.json | 123 +++++++++++++++++- 18 files changed, 661 insertions(+), 32 deletions(-) create mode 100644 src/routes/amt/getBootCapabilities.test.ts create mode 100644 src/routes/amt/getBootCapabilities.ts create mode 100644 src/routes/amt/sendRemoteErase.test.ts create mode 100644 src/routes/amt/sendRemoteErase.ts create mode 100644 src/routes/amt/setRPEEnabled.test.ts create mode 100644 src/routes/amt/setRPEEnabled.ts diff --git a/docker-compose.yml b/docker-compose.yml index 5b1c4f920..23b33417d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,10 @@ services: ports: - 3000:3000 depends_on: - - 'vault' - - 'db' + db: + condition: service_healthy + vault: + condition: service_healthy build: context: . dockerfile: ./Dockerfile @@ -35,6 +37,11 @@ services: POSTGRES_PASSWORD: admin123 volumes: - ./data:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgresadmin -d mpsdb"] + interval: 5s + timeout: 5s + retries: 10 vault: image: hashicorp/vault:1.21 networks: @@ -44,8 +51,14 @@ services: environment: VAULT_DEV_ROOT_TOKEN_ID: myroot VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + SKIP_SETCAP: 'true' cap_add: - IPC_LOCK + healthcheck: + test: ["CMD-SHELL", "VAULT_ADDR=http://localhost:8200 vault status"] + interval: 5s + timeout: 5s + retries: 10 consul: restart: always image: hashicorp/consul diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 718aeb42c..b8a5c5786 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -190,15 +190,106 @@ export class DeviceAction { return getResponse.Envelope } - async getPowerCapabilities(): Promise> { - logger.silly(`getPowerCapabilities ${messages.REQUEST}`) + async getBootCapabilities(): Promise> { + logger.silly(`getBootCapabilities ${messages.REQUEST}`) const xmlRequestBody = this.amt.BootCapabilities.Get() const result = await this.ciraHandler.Get(this.ciraSocket, xmlRequestBody) - logger.info(JSON.stringify(result)) - logger.silly(`getPowerCapabilities ${messages.COMPLETE}`) + logger.silly(`getBootCapabilities ${messages.COMPLETE}`) return result.Envelope } + async setRPEEnabled(enabled: boolean): Promise { + logger.silly(`setRPEEnabled ${messages.REQUEST}`) + const bootOptions = await this.getBootOptions() + const current = bootOptions.AMT_BootSettingData + current.PlatformErase = enabled + await this.setBootConfiguration(current) + logger.silly(`setRPEEnabled ${messages.COMPLETE}`) + } + + async sendRemoteErase(eraseMask: number): Promise { + logger.silly(`sendRemoteErase ${messages.REQUEST}`) + + // CSME sentinel bit: 0x10000 maps to ConfigurationDataReset, not a hardware erase target + const CSME_BIT = 0x10000 + const csmeRequested = (eraseMask & CSME_BIT) !== 0 + const hwMask = eraseMask & ~CSME_BIT // strip the CSME bit for hardware TLV + + // Step 1: GET current boot settings and verify RPEEnabled + const bootOptions = await this.getBootOptions() + const current = bootOptions.AMT_BootSettingData + if (!current.RPEEnabled) { + throw new Error('RPE is not enabled on this device') + } + + // Step 1a: Clear boot source override (CSME path only) + if (csmeRequested) { + await this.changeBootOrder() + } + + // Step 1b: Switch firmware to RPE mode BEFORE the PUT + // Required when boot service is in OCR mode (32769); must precede PUT + const xmlRpeMode = this.cim.BootService.RequestStateChange(32770) + const rscResult = await this.ciraHandler.Send(this.ciraSocket, xmlRpeMode) + if (rscResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { + logger.error(`sendRemoteErase RequestStateChange(32770) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) + } + + // Step 2: Build minimal PUT body — only writable fields, no read-only fields. + // Read-only fields (BIOSLastStatus, BootguardStatus, RPEEnabled, SecureBootControlEnabled, + // UEFIHTTPSBootEnabled, UEFILocalPBABootEnabled, WinREBootEnabled, OptionsCleared) + // cause InvalidRepresentation if included. Use 'Uefi' (not 'UEFI') to match AMT XML element names. + const putBody: any = { + ElementName: current.ElementName, + InstanceID: current.InstanceID, + OwningEntity: current.OwningEntity, + BIOSPause: current.BIOSPause, + BIOSSetup: current.BIOSSetup, + BootMediaIndex: current.BootMediaIndex, + ConfigurationDataReset: csmeRequested, + EnforceSecureBoot: current.EnforceSecureBoot, + FirmwareVerbosity: current.FirmwareVerbosity, + ForcedProgressEvents: current.ForcedProgressEvents, + IDERBootDevice: current.IDERBootDevice, + LockKeyboard: current.LockKeyboard, + LockPowerButton: current.LockPowerButton, + LockResetButton: current.LockResetButton, + LockSleepButton: current.LockSleepButton, + PlatformErase: hwMask !== 0, + RSEPassword: current.RSEPassword, + ReflashBIOS: current.ReflashBIOS, + SecureErase: current.SecureErase, + UseIDER: current.UseIDER, + UseSOL: current.UseSOL, + UseSafeMode: current.UseSafeMode, + UserPasswordBypass: current.UserPasswordBypass, + } + + if (hwMask !== 0) { + const buf = Buffer.alloc(12) + buf.writeUInt16LE(0x8086, 0) // Intel vendor prefix + buf.writeUInt16LE(1, 2) // ParameterTypeID = 1 + buf.writeUInt32LE(4, 4) // value length = 4 bytes + buf.writeUInt32LE(hwMask, 8) // device bitmask + putBody.UefiBootParametersArray = buf.toString('base64') + putBody.UefiBootNumberOfParams = 1 + } + + const xmlPut = this.amt.BootSettingData.Put(putBody as AMT.Models.BootSettingData) + const putResult = await this.ciraHandler.Send(this.ciraSocket, xmlPut) + if (putResult?.Envelope?.Body?.Fault) { + throw new Error(`BootSettingData PUT failed: ${JSON.stringify(putResult.Envelope.Body.Fault)}`) + } + + // Step 4: Activate boot configuration + await this.forceBootMode(1) + + // Step 5: Power Cycle Off Hard — S5→S0 required; warm reset keeps ME power rails active + await this.sendPowerAction(5) + + logger.silly(`sendRemoteErase ${messages.COMPLETE}`) + } + async requestUserConsentCode(): Promise> { logger.silly(`requestUserConsentCode ${messages.REQUEST}`) const xmlRequestBody = this.ips.OptInService.StartOptIn() @@ -631,7 +722,7 @@ export class DeviceAction { async getOCRData(): Promise { const bootService = await this.getBootService() const bootSourceSettings = await this.getBootSourceSetting() - const capabilities = await this.getPowerCapabilities() + const capabilities = await this.getBootCapabilities() const bootData = await this.getBootSettingData() return { diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index c1e544cfe..84b0c075b 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -382,12 +382,39 @@ describe('Device Action Tests', () => { expect(result).toEqual(chip.Envelope) }) }) - describe('power capabilities', () => { - it('should get power capabilities', async () => { + describe('boot capabilities and RPE', () => { + it('should get boot capabilities', async () => { getSpy.mockResolvedValueOnce(bootCapabilities) - const result = await device.getPowerCapabilities() + const result = await device.getBootCapabilities() expect(result).toEqual(bootCapabilities.Envelope) }) + it('should set RPE enabled', async () => { + getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: false } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) + await device.setRPEEnabled(true) + expect(getSpy).toHaveBeenCalled() + expect(sendSpy).toHaveBeenCalled() + }) + it('should send remote erase with non-zero mask', async () => { + getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: false, RPEEnabled: true } } } }) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + await device.sendRemoteErase(3) + expect(getSpy).toHaveBeenCalled() + expect(sendSpy).toHaveBeenCalled() + }) + it('should send remote erase with zero mask sets PlatformErase to false', async () => { + getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: true, RPEEnabled: true } } } }) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + await device.sendRemoteErase(0) + expect(getSpy).toHaveBeenCalled() + expect(sendSpy).toHaveBeenCalled() + }) }) describe('alarm occurrences', () => { it('should return null when enumerate call to getAlarmClockOccurrences fails', async () => { diff --git a/src/routes/amt/amtFeatureValidator.ts b/src/routes/amt/amtFeatureValidator.ts index 342edd8e9..f502c3197 100644 --- a/src/routes/amt/amtFeatureValidator.ts +++ b/src/routes/amt/amtFeatureValidator.ts @@ -16,5 +16,6 @@ export const amtFeaturesValidator = (): any => [ check('enableSOL').isBoolean().toBoolean(), check('enableIDER').isBoolean().toBoolean(), check('enableKVM').isBoolean().toBoolean(), - check('ocr').optional().isBoolean().toBoolean() + check('ocr').optional().isBoolean().toBoolean(), + check('platformEraseEnabled').optional().isBoolean().toBoolean() ] diff --git a/src/routes/amt/getAMTFeatures.test.ts b/src/routes/amt/getAMTFeatures.test.ts index 0f0867544..026973636 100644 --- a/src/routes/amt/getAMTFeatures.test.ts +++ b/src/routes/amt/getAMTFeatures.test.ts @@ -141,7 +141,8 @@ describe('get amt features', () => { ForcedProgressEvents: true, IDER: true, InstanceID: 'Intel(r) AMT:BootCapabilities 0', - SOL: true + SOL: true, + PlatformErase: 3 } } }, @@ -156,7 +157,8 @@ describe('get amt features', () => { IDERBootDevice: 0, InstanceID: 'Intel(r) AMT:BootSettingData 0', UseIDER: false, - UseSOL: false + UseSOL: false, + PlatformErase: true } } }) @@ -175,7 +177,9 @@ describe('get amt features', () => { httpsBootSupported: true, winREBootSupported: true, localPBABootSupported: false, - remoteErase: false + rpeEnabled: true, + rpeSupported: true, + rpeCaps: 3 }) expect(mqttSpy).toHaveBeenCalledTimes(2) }) @@ -271,7 +275,9 @@ describe('get amt features', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false + rpeEnabled: false, + rpeSupported: false, + rpeCaps: 0 }) }) }) diff --git a/src/routes/amt/getAMTFeatures.ts b/src/routes/amt/getAMTFeatures.ts index d7cfa37bc..d3a023b91 100644 --- a/src/routes/amt/getAMTFeatures.ts +++ b/src/routes/amt/getAMTFeatures.ts @@ -28,8 +28,12 @@ export async function getAMTFeatures(req: Request, res: Response): Promise const userConsent = Object.keys(UserConsentOptions).find((key) => UserConsentOptions[key] === value) const ocrProcessResult = processOCRData(OCRData) + const rpeCaps = OCRData.capabilities?.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 + const rpeEnabled = !!(OCRData.bootData?.AMT_BootSettingData?.PlatformErase) + const rpeSupported = rpeCaps !== 0 + MqttProvider.publishEvent('success', ['AMT_GetFeatures'], messages.AMT_FEATURES_GET_SUCCESS, guid) - res + res .status(200) .json({ userConsent, @@ -43,7 +47,9 @@ export async function getAMTFeatures(req: Request, res: Response): Promise httpsBootSupported: ocrProcessResult.HTTPSBootSupported, winREBootSupported: ocrProcessResult.WinREBootSupported, localPBABootSupported: ocrProcessResult.LocalPBABootSupported, - remoteErase: false + rpeEnabled, + rpeSupported, + rpeCaps }) .end() } catch (error) { diff --git a/src/routes/amt/getBootCapabilities.test.ts b/src/routes/amt/getBootCapabilities.test.ts new file mode 100644 index 000000000..73d707316 --- /dev/null +++ b/src/routes/amt/getBootCapabilities.test.ts @@ -0,0 +1,65 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { getBootCapabilities } from './getBootCapabilities.js' +import { createSpyObj } from '../../test/helper/jest.js' +import { DeviceAction } from '../../amt/DeviceAction.js' +import { CIRAHandler } from '../../amt/CIRAHandler.js' +import { HttpHandler } from '../../amt/HttpHandler.js' +import { messages } from '../../logging/index.js' +import { type Spied, spyOn } from 'jest-mock' + +describe('Get Boot Capabilities', () => { + let req: any + let resSpy: any + let mqttSpy: Spied + let bootCapsSpy: Spied + let device: DeviceAction + + beforeEach(() => { + const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + device = new DeviceAction(handler, null) + req = { + params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, + deviceAction: device + } + resSpy = createSpyObj('Response', ['status', 'json', 'end', 'send']) + resSpy.status.mockReturnThis() + resSpy.json.mockReturnThis() + resSpy.send.mockReturnThis() + + mqttSpy = spyOn(MqttProvider, 'publishEvent') + bootCapsSpy = spyOn(device, 'getBootCapabilities') + }) + + it('should return boot capabilities', async () => { + const bootCaps = { + IDER: true, + SOL: true, + BIOSSetup: true, + PlatformErase: 3 + } + bootCapsSpy.mockResolvedValue({ + Body: { AMT_BootCapabilities: bootCaps } + }) + + await getBootCapabilities(req, resSpy) + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith(bootCaps) + expect(resSpy.end).toHaveBeenCalled() + expect(mqttSpy).toHaveBeenCalledTimes(2) + }) + + it('should return 500 on error', async () => { + bootCapsSpy.mockRejectedValue(new Error('AMT error')) + + await getBootCapabilities(req, resSpy) + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.POWER_CAPABILITIES_EXCEPTION)) + expect(resSpy.end).toHaveBeenCalled() + }) +}) diff --git a/src/routes/amt/getBootCapabilities.ts b/src/routes/amt/getBootCapabilities.ts new file mode 100644 index 000000000..945228883 --- /dev/null +++ b/src/routes/amt/getBootCapabilities.ts @@ -0,0 +1,27 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type Response, type Request } from 'express' +import { logger, messages } from '../../logging/index.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' + +export async function getBootCapabilities(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + + MqttProvider.publishEvent('request', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_REQUESTED, guid) + + const result = await req.deviceAction.getBootCapabilities() + const capabilities = result.Body?.AMT_BootCapabilities + + MqttProvider.publishEvent('success', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_SUCCESS, guid) + res.status(200).json(capabilities).end() + } catch (error) { + logger.error(`${messages.POWER_CAPABILITIES_EXCEPTION} : ${error}`) + MqttProvider.publishEvent('fail', ['AMT_BootCapabilities'], messages.INTERNAL_SERVICE_ERROR) + res.status(500).json(ErrorResponse(500, messages.POWER_CAPABILITIES_EXCEPTION)).end() + } +} diff --git a/src/routes/amt/index.ts b/src/routes/amt/index.ts index 9e361d0e6..79caed38b 100644 --- a/src/routes/amt/index.ts +++ b/src/routes/amt/index.ts @@ -40,6 +40,9 @@ import { getScreenSettingData } from './kvm/get.js' import { setKVMRedirectionSettingData } from './kvm/set.js' import { setLinkPreference } from './setLinkPreference.js' import { linkPreferenceValidator } from './linkPreferenceValidator.js' +import { getBootCapabilities } from './getBootCapabilities.js' +import { setRPEEnabled } from './setRPEEnabled.js' +import { sendRemoteErase } from './sendRemoteErase.js' const amtRouter: Router = Router() @@ -53,6 +56,9 @@ amtRouter.get('/power/capabilities/:guid', ciraMiddleware, powerCapabilities) amtRouter.get('/power/state/:guid', ciraMiddleware, powerState) amtRouter.get('/features/:guid', ciraMiddleware, getAMTFeatures) amtRouter.post('/features/:guid', amtFeaturesValidator(), validateMiddleware, ciraMiddleware, setAMTFeatures) +amtRouter.get('/boot/capabilities/:guid', ciraMiddleware, getBootCapabilities) +amtRouter.post('/boot/rpe/:guid', ciraMiddleware, setRPEEnabled) +amtRouter.post('/remoteErase/:guid', ciraMiddleware, sendRemoteErase) amtRouter.get('/version/:guid', ciraMiddleware, version) amtRouter.delete('/deactivate/:guid', ciraMiddleware, deactivate) amtRouter.get('/power/bootSources/:guid', ciraMiddleware, bootSources) diff --git a/src/routes/amt/powerCapabilities.test.ts b/src/routes/amt/powerCapabilities.test.ts index c1dffdb58..72793530d 100644 --- a/src/routes/amt/powerCapabilities.test.ts +++ b/src/routes/amt/powerCapabilities.test.ts @@ -68,7 +68,7 @@ describe('Power Capabilities', () => { 'Reset to PXE': 400, 'Power on to PXE': 401 } - spyOn(device, 'getPowerCapabilities').mockResolvedValue(powerCaps) + spyOn(device, 'getBootCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) @@ -96,7 +96,7 @@ describe('Power Capabilities', () => { } versionResponse.CIM_SoftwareIdentity.responses = [ { InstanceID: 'AMT', IsEntity: 'true', VersionString: '9.0.0' }] - spyOn(device, 'getPowerCapabilities').mockResolvedValue(powerCaps) + spyOn(device, 'getBootCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) @@ -130,7 +130,7 @@ describe('Power Capabilities', () => { powerCaps.Body.AMT_BootCapabilities.BIOSSetup = true powerCaps.Body.AMT_BootCapabilities.SecureErase = true powerCaps.Body.AMT_BootCapabilities.ForceDiagnosticBoot = true - spyOn(device, 'getPowerCapabilities').mockResolvedValue(powerCaps) + spyOn(device, 'getBootCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) @@ -140,7 +140,7 @@ describe('Power Capabilities', () => { expect(mqttSpy).toHaveBeenCalled() }) it('Should handle error', async () => { - spyOn(device, 'getPowerCapabilities').mockResolvedValue(null) + spyOn(device, 'getBootCapabilities').mockResolvedValue(null) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) diff --git a/src/routes/amt/powerCapabilities.ts b/src/routes/amt/powerCapabilities.ts index 9f92c8fb0..a5a297026 100644 --- a/src/routes/amt/powerCapabilities.ts +++ b/src/routes/amt/powerCapabilities.ts @@ -15,7 +15,7 @@ export async function powerCapabilities(req: Request, res: Response): Promise { + let req: any + let resSpy: any + let mqttSpy: Spied + let bootCapsSpy: Spied + let sendEraseSpy: Spied + let device: DeviceAction + + beforeEach(() => { + const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + device = new DeviceAction(handler, null) + req = { + params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, + body: { eraseMask: 3 }, + deviceAction: device + } + resSpy = createSpyObj('Response', ['status', 'json', 'end', 'send']) + resSpy.status.mockReturnThis() + resSpy.json.mockReturnThis() + resSpy.send.mockReturnThis() + + mqttSpy = spyOn(MqttProvider, 'publishEvent') + bootCapsSpy = spyOn(device, 'getBootCapabilities') + sendEraseSpy = spyOn(device, 'sendRemoteErase') + }) + + it('should send remote erase when device supports the requested mask', async () => { + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) + sendEraseSpy.mockResolvedValue(undefined) + + await sendRemoteErase(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(3) + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) + }) + + it('should send remote erase with zero mask (no specific capability check)', async () => { + req.body.eraseMask = 0 + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) + sendEraseSpy.mockResolvedValue(undefined) + + await sendRemoteErase(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(0) + expect(resSpy.status).toHaveBeenCalledWith(200) + }) + + it('should return 400 when device does not support platform erase', async () => { + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) + + await sendRemoteErase(req, resSpy) + expect(sendEraseSpy).not.toHaveBeenCalled() + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Device does not support Remote Platform Erase')) + }) + + it('should return 400 when requested mask is not supported by device', async () => { + req.body.eraseMask = 4 + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) + + await sendRemoteErase(req, resSpy) + expect(sendEraseSpy).not.toHaveBeenCalled() + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Requested erase capabilities are not supported by this device')) + }) + + it('should return 400 when CSME is combined with hardware erase bits', async () => { + req.body.eraseMask = 0x10001 // CSME + hardware bit + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x10001 } } }) + + await sendRemoteErase(req, resSpy) + expect(sendEraseSpy).not.toHaveBeenCalled() + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'CSME unconfigure cannot be combined with other erase operations')) + }) + + it('should return 500 on unexpected error', async () => { + bootCapsSpy.mockRejectedValue(new Error('AMT error')) + + await sendRemoteErase(req, resSpy) + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)) + }) +}) diff --git a/src/routes/amt/sendRemoteErase.ts b/src/routes/amt/sendRemoteErase.ts new file mode 100644 index 000000000..2f16bd68e --- /dev/null +++ b/src/routes/amt/sendRemoteErase.ts @@ -0,0 +1,49 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type Response, type Request } from 'express' +import { logger, messages } from '../../logging/index.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { MPSValidationError } from '../../utils/MPSValidationError.js' + +export async function sendRemoteErase(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + const { eraseMask } = req.body + const mask: number = eraseMask ?? 0 + + MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) + + const bootCaps = await req.deviceAction.getBootCapabilities() + const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 + + if (platformEraseCaps === 0) { + throw new MPSValidationError('Device does not support Remote Platform Erase', 400) + } + + if (mask !== 0 && (platformEraseCaps & mask) === 0) { + throw new MPSValidationError('Requested erase capabilities are not supported by this device', 400) + } + + const CSME_BIT = 0x10000 + if ((mask & CSME_BIT) !== 0 && (mask & ~CSME_BIT) !== 0) { + throw new MPSValidationError('CSME unconfigure cannot be combined with other erase operations', 400) + } + + await req.deviceAction.sendRemoteErase(mask) + + MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) + res.status(200).json({ status: 'success' }).end() + } catch (error) { + logger.error(`sendRemoteErase failed: ${error}`) + if (error instanceof MPSValidationError) { + res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)) + } else { + MqttProvider.publishEvent('fail', ['AMT_BootSettingData'], messages.INTERNAL_SERVICE_ERROR) + res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + } + } +} diff --git a/src/routes/amt/setAMTFeatures.ts b/src/routes/amt/setAMTFeatures.ts index 841be8c50..35e9ee2c0 100644 --- a/src/routes/amt/setAMTFeatures.ts +++ b/src/routes/amt/setAMTFeatures.ts @@ -80,16 +80,30 @@ export async function setAMTFeatures(req: Request, res: Response): Promise await setUserConsent(req.deviceAction, optServiceResponse, payload.guid as string) } - // Configure OCR settings - if (payload.ocr !== undefined) { - let requestedState = 0 - if (payload.ocr) { - requestedState = 32769 - } else { - requestedState = 32768 + // Configure Remote Platform Erase (RPE) — PUT must run BEFORE BootServiceStateChange + let rpeDesired: boolean | undefined + if (payload.platformEraseEnabled !== undefined) { + const bootCaps = await req.deviceAction.getBootCapabilities() + const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 + if (platformEraseCaps !== 0) { + rpeDesired = !!payload.platformEraseEnabled + await req.deviceAction.setRPEEnabled(rpeDesired) } + } + // Configure boot service state — combines OCR and RPE + // 32768 = both off, 32769 = OCR only, 32770 = RPE only, 32771 = both + if (payload.ocr !== undefined) { + const ocrOn = !!payload.ocr + const rpeOn = rpeDesired ?? false + let requestedState = 32768 + if (ocrOn && rpeOn) requestedState = 32771 + else if (ocrOn) requestedState = 32769 + else if (rpeOn) requestedState = 32770 await req.deviceAction.BootServiceStateChange(requestedState) + } else if (rpeDesired !== undefined) { + // OCR not in request — set RPE-only state (32770 enabled, 32768 disabled) + await req.deviceAction.BootServiceStateChange(rpeDesired ? 32770 : 32768) } MqttProvider.publishEvent('success', ['AMT_SetFeatures'], messages.AMT_FEATURES_SET_SUCCESS, guid) diff --git a/src/routes/amt/setRPEEnabled.test.ts b/src/routes/amt/setRPEEnabled.test.ts new file mode 100644 index 000000000..2a225c8d8 --- /dev/null +++ b/src/routes/amt/setRPEEnabled.test.ts @@ -0,0 +1,68 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { setRPEEnabled } from './setRPEEnabled.js' +import { createSpyObj } from '../../test/helper/jest.js' +import { DeviceAction } from '../../amt/DeviceAction.js' +import { CIRAHandler } from '../../amt/CIRAHandler.js' +import { HttpHandler } from '../../amt/HttpHandler.js' +import { messages } from '../../logging/index.js' +import { type Spied, spyOn } from 'jest-mock' + +describe('Set RPE Enabled', () => { + let req: any + let resSpy: any + let mqttSpy: Spied + let bootCapsSpy: Spied + let setRPESpy: Spied + let device: DeviceAction + + beforeEach(() => { + const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + device = new DeviceAction(handler, null) + req = { + params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, + body: { enabled: true }, + deviceAction: device + } + resSpy = createSpyObj('Response', ['status', 'json', 'end', 'send']) + resSpy.status.mockReturnThis() + resSpy.json.mockReturnThis() + resSpy.send.mockReturnThis() + + mqttSpy = spyOn(MqttProvider, 'publishEvent') + bootCapsSpy = spyOn(device, 'getBootCapabilities') + setRPESpy = spyOn(device, 'setRPEEnabled') + }) + + it('should enable RPE when device supports platform erase', async () => { + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) + setRPESpy.mockResolvedValue(undefined) + + await setRPEEnabled(req, resSpy) + expect(setRPESpy).toHaveBeenCalledWith(true) + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) + }) + + it('should return 400 when device does not support platform erase', async () => { + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) + + await setRPEEnabled(req, resSpy) + expect(setRPESpy).not.toHaveBeenCalled() + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Device does not support Remote Platform Erase')) + }) + + it('should return 500 on unexpected error', async () => { + bootCapsSpy.mockRejectedValue(new Error('AMT error')) + + await setRPEEnabled(req, resSpy) + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)) + }) +}) diff --git a/src/routes/amt/setRPEEnabled.ts b/src/routes/amt/setRPEEnabled.ts new file mode 100644 index 000000000..fbb58ea9c --- /dev/null +++ b/src/routes/amt/setRPEEnabled.ts @@ -0,0 +1,39 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type Response, type Request } from 'express' +import { logger, messages } from '../../logging/index.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { MPSValidationError } from '../../utils/MPSValidationError.js' + +export async function setRPEEnabled(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + const { enabled } = req.body + + MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) + + const bootCaps = await req.deviceAction.getBootCapabilities() + const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 + + if (platformEraseCaps === 0) { + throw new MPSValidationError('Device does not support Remote Platform Erase', 400) + } + + await req.deviceAction.setRPEEnabled(!!enabled) + + MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) + res.status(200).json({ status: 'success' }).end() + } catch (error) { + logger.error(`setRPEEnabled failed: ${error}`) + if (error instanceof MPSValidationError) { + res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)) + } else { + MqttProvider.publishEvent('fail', ['AMT_BootSettingData'], messages.INTERNAL_SERVICE_ERROR) + res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + } + } +} diff --git a/src/server/webserver.ts b/src/server/webserver.ts index a5e37cb15..a36c157ad 100644 --- a/src/server/webserver.ts +++ b/src/server/webserver.ts @@ -95,7 +95,7 @@ export class WebServer { const pathToCustomMiddleware = path.join(this.__dirname, '../middleware/custom') const middleware: RequestHandler[] = [] const doesExist = existsSync(pathToCustomMiddleware) - const isDirectory = lstatSync(pathToCustomMiddleware).isDirectory() + const isDirectory = doesExist && lstatSync(pathToCustomMiddleware).isDirectory() if (doesExist && isDirectory) { const files = readdirSync(pathToCustomMiddleware) for (const file of files) { diff --git a/src/test/collections/MPS.postman_collection.json b/src/test/collections/MPS.postman_collection.json index 4cca5dcd6..c0254a130 100644 --- a/src/test/collections/MPS.postman_collection.json +++ b/src/test/collections/MPS.postman_collection.json @@ -3166,7 +3166,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"userConsent\": \"none\",\r\n \"enableSOL\": \"false\",\r\n \"enableIDER\": \"false\",\r\n \"enableKVM\": \"false\"\r\n}", + "raw": "{\r\n \"userConsent\": \"none\",\r\n \"enableSOL\": \"false\",\r\n \"enableIDER\": \"false\",\r\n \"enableKVM\": \"false\",\r\n \"platformEraseEnabled\": false\r\n}", "options": { "raw": { "language": "json" @@ -3190,6 +3190,125 @@ }, "response": [] }, + { + "name": "Get Boot Capabilities", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r\n pm.response.to.have.status(404);\r\n});\r\n\r\npm.test(\"Device should not be found\", function () {\r\n var jsonData = pm.response.json();\r\n pm.expect(jsonData.error).to.eq(\"Device not found/connected. Please connect again using CIRA.\")\r\n pm.expect(jsonData.errorDescription).to.eq(\"guid : 1\")\r\n});\r\n" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/capabilities/1", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "boot", + "capabilities", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Set RPE Enabled", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r\n pm.response.to.have.status(404);\r\n});\r\n\r\npm.test(\"Device should not be found\", function () {\r\n var jsonData = pm.response.json();\r\n pm.expect(jsonData.error).to.eq(\"Device not found/connected. Please connect again using CIRA.\")\r\n pm.expect(jsonData.errorDescription).to.eq(\"guid : 1\")\r\n});\r\n" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"enabled\": true\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/rpe/1", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "boot", + "rpe", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Send Remote Erase", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r\n pm.response.to.have.status(404);\r\n});\r\n\r\npm.test(\"Device should not be found\", function () {\r\n var jsonData = pm.response.json();\r\n pm.expect(jsonData.error).to.eq(\"Device not found/connected. Please connect again using CIRA.\")\r\n pm.expect(jsonData.errorDescription).to.eq(\"guid : 1\")\r\n});\r\n" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"eraseMask\": 0\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/remoteErase/1", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "remoteErase", + "1" + ] + } + }, + "response": [] + }, { "name": "Get Version", "event": [ @@ -3663,4 +3782,4 @@ } } ] -} +} \ No newline at end of file From 47eb289a7f5a953ab4d2bf83b9c75ae45b9f0539 Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Mon, 15 Jun 2026 22:39:43 +0000 Subject: [PATCH 02/14] feat: changes to match sample-web-ui --- .mpsrc | 8 +- src/amt/DeviceAction.ts | 20 +-- src/amt/deviceAction.test.ts | 10 +- src/models/models.ts | 8 ++ src/routes/amt/getAMTFeatures.test.ts | 12 +- src/routes/amt/getAMTFeatures.ts | 7 +- src/routes/amt/index.ts | 8 +- ...endRemoteErase.test.ts => sendRPE.test.ts} | 34 ++--- .../amt/{sendRemoteErase.ts => sendRPE.ts} | 15 ++- src/routes/amt/setAMTFeatures.ts | 2 +- .../{setRPEEnabled.test.ts => setRPE.test.ts} | 0 .../amt/{setRPEEnabled.ts => setRPE.ts} | 6 +- .../collections/MPS.postman_collection.json | 6 +- swagger.yaml | 123 +++++++++++++++++- 14 files changed, 194 insertions(+), 65 deletions(-) rename src/routes/amt/{sendRemoteErase.test.ts => sendRPE.test.ts} (79%) rename src/routes/amt/{sendRemoteErase.ts => sendRPE.ts} (80%) rename src/routes/amt/{setRPEEnabled.test.ts => setRPE.test.ts} (100%) rename src/routes/amt/{setRPEEnabled.ts => setRPE.ts} (89%) diff --git a/.mpsrc b/.mpsrc index 941cbe090..d1bd617c0 100644 --- a/.mpsrc +++ b/.mpsrc @@ -1,5 +1,5 @@ { - "common_name": "localhost", + "common_name": "10.72.4.39", "port": 4433, "country": "US", "company": "NoCorp", @@ -7,8 +7,8 @@ "tls_offload": false, "web_port": 3000, "generate_certificates": true, - "web_admin_user": "", - "web_admin_password": "", + "web_admin_user": "standalone", + "web_admin_password": "G@ppm0ym", "web_auth_enabled": true, "vault_address": "http://localhost:8200", "vault_token": "myroot", @@ -18,7 +18,7 @@ "cert_format": "file", "data_path": "../private/data.json", "cert_path": "../private", - "jwt_secret": "", + "jwt_secret": "myjwtsecret", "jwt_issuer": "9EmRJTbIiIb4bIeSsmgcWIjrR6HyETqc", "jwt_expiration": "1440", "cors_origin": "*", diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index b8a5c5786..9faeb8a36 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -198,27 +198,27 @@ export class DeviceAction { return result.Envelope } - async setRPEEnabled(enabled: boolean): Promise { - logger.silly(`setRPEEnabled ${messages.REQUEST}`) + async setRPE(enabled: boolean): Promise { + logger.silly(`setRPE ${messages.REQUEST}`) const bootOptions = await this.getBootOptions() const current = bootOptions.AMT_BootSettingData - current.PlatformErase = enabled + current.RPE = enabled await this.setBootConfiguration(current) - logger.silly(`setRPEEnabled ${messages.COMPLETE}`) + logger.silly(`setRPE ${messages.COMPLETE}`) } - async sendRemoteErase(eraseMask: number): Promise { - logger.silly(`sendRemoteErase ${messages.REQUEST}`) + async sendRPE(eraseMask: number): Promise { + logger.silly(`sendRPE ${messages.REQUEST}`) // CSME sentinel bit: 0x10000 maps to ConfigurationDataReset, not a hardware erase target const CSME_BIT = 0x10000 const csmeRequested = (eraseMask & CSME_BIT) !== 0 const hwMask = eraseMask & ~CSME_BIT // strip the CSME bit for hardware TLV - // Step 1: GET current boot settings and verify RPEEnabled + // Step 1: GET current boot settings and verify RPE const bootOptions = await this.getBootOptions() const current = bootOptions.AMT_BootSettingData - if (!current.RPEEnabled) { + if (!current.RPE) { throw new Error('RPE is not enabled on this device') } @@ -232,7 +232,7 @@ export class DeviceAction { const xmlRpeMode = this.cim.BootService.RequestStateChange(32770) const rscResult = await this.ciraHandler.Send(this.ciraSocket, xmlRpeMode) if (rscResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { - logger.error(`sendRemoteErase RequestStateChange(32770) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) + logger.error(`sendRPE RequestStateChange(32770) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) } // Step 2: Build minimal PUT body — only writable fields, no read-only fields. @@ -287,7 +287,7 @@ export class DeviceAction { // Step 5: Power Cycle Off Hard — S5→S0 required; warm reset keeps ME power rails active await this.sendPowerAction(5) - logger.silly(`sendRemoteErase ${messages.COMPLETE}`) + logger.silly(`sendRPE ${messages.COMPLETE}`) } async requestUserConsentCode(): Promise> { diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index cab44a298..d71522799 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -389,19 +389,19 @@ describe('Device Action Tests', () => { expect(result).toEqual(bootCapabilities.Envelope) }) it('should set RPE enabled', async () => { - getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: false } } } }) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } }) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) - await device.setRPEEnabled(true) + await device.setRPE(true) expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() }) it('should send remote erase with non-zero mask', async () => { - getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: false, RPEEnabled: true } } } }) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } }) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) - await device.sendRemoteErase(3) + await device.sendRPE(3) expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() }) @@ -411,7 +411,7 @@ describe('Device Action Tests', () => { sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) - await device.sendRemoteErase(0) + await device.sendRPE(0) expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() }) diff --git a/src/models/models.ts b/src/models/models.ts index 64477b484..b464b0a74 100644 --- a/src/models/models.ts +++ b/src/models/models.ts @@ -227,3 +227,11 @@ export interface OCRProcessResult { HTTPSBootSupported: boolean OCR: boolean } + +// RPE = Remote Platform Erase +export interface RPECapabilities { + secureEraseAllSSDs: boolean + tpmClear: boolean + restoreBIOSToEOM: boolean + unconfigureCSME: boolean +} diff --git a/src/routes/amt/getAMTFeatures.test.ts b/src/routes/amt/getAMTFeatures.test.ts index df96e7cef..a832b402f 100644 --- a/src/routes/amt/getAMTFeatures.test.ts +++ b/src/routes/amt/getAMTFeatures.test.ts @@ -157,7 +157,7 @@ describe('get amt features', () => { InstanceID: 'Intel(r) AMT:BootSettingData 0', UseIDER: false, UseSOL: false, - PlatformErase: true + RPE: true } } }) @@ -176,9 +176,8 @@ describe('get amt features', () => { httpsBootSupported: true, winREBootSupported: true, localPBABootSupported: false, - rpeEnabled: true, - rpeSupported: true, - rpeCaps: 3 + rpe: true, + rpeSupported: true }) expect(mqttSpy).toHaveBeenCalledTimes(2) }) @@ -274,9 +273,8 @@ describe('get amt features', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - rpeEnabled: false, - rpeSupported: false, - rpeCaps: 0 + rpe: false, + rpeSupported: false }) }) }) diff --git a/src/routes/amt/getAMTFeatures.ts b/src/routes/amt/getAMTFeatures.ts index d3a023b91..1180e78c9 100644 --- a/src/routes/amt/getAMTFeatures.ts +++ b/src/routes/amt/getAMTFeatures.ts @@ -29,7 +29,7 @@ export async function getAMTFeatures(req: Request, res: Response): Promise const ocrProcessResult = processOCRData(OCRData) const rpeCaps = OCRData.capabilities?.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 - const rpeEnabled = !!(OCRData.bootData?.AMT_BootSettingData?.PlatformErase) + const rpe = !!(OCRData.bootData?.AMT_BootSettingData?.PlatformErase) const rpeSupported = rpeCaps !== 0 MqttProvider.publishEvent('success', ['AMT_GetFeatures'], messages.AMT_FEATURES_GET_SUCCESS, guid) @@ -47,9 +47,8 @@ export async function getAMTFeatures(req: Request, res: Response): Promise httpsBootSupported: ocrProcessResult.HTTPSBootSupported, winREBootSupported: ocrProcessResult.WinREBootSupported, localPBABootSupported: ocrProcessResult.LocalPBABootSupported, - rpeEnabled, - rpeSupported, - rpeCaps + rpe, + rpeSupported }) .end() } catch (error) { diff --git a/src/routes/amt/index.ts b/src/routes/amt/index.ts index 79caed38b..12639c61d 100644 --- a/src/routes/amt/index.ts +++ b/src/routes/amt/index.ts @@ -41,8 +41,8 @@ import { setKVMRedirectionSettingData } from './kvm/set.js' import { setLinkPreference } from './setLinkPreference.js' import { linkPreferenceValidator } from './linkPreferenceValidator.js' import { getBootCapabilities } from './getBootCapabilities.js' -import { setRPEEnabled } from './setRPEEnabled.js' -import { sendRemoteErase } from './sendRemoteErase.js' +import { setRPE } from './setRPE.js' +import { sendRPE } from './sendRPE.js' const amtRouter: Router = Router() @@ -57,8 +57,8 @@ amtRouter.get('/power/state/:guid', ciraMiddleware, powerState) amtRouter.get('/features/:guid', ciraMiddleware, getAMTFeatures) amtRouter.post('/features/:guid', amtFeaturesValidator(), validateMiddleware, ciraMiddleware, setAMTFeatures) amtRouter.get('/boot/capabilities/:guid', ciraMiddleware, getBootCapabilities) -amtRouter.post('/boot/rpe/:guid', ciraMiddleware, setRPEEnabled) -amtRouter.post('/remoteErase/:guid', ciraMiddleware, sendRemoteErase) +amtRouter.post('/boot/rpe/:guid', ciraMiddleware, setRPE) +amtRouter.post('/rpe/:guid', ciraMiddleware, sendRPE) amtRouter.get('/version/:guid', ciraMiddleware, version) amtRouter.delete('/deactivate/:guid', ciraMiddleware, deactivate) amtRouter.get('/power/bootSources/:guid', ciraMiddleware, bootSources) diff --git a/src/routes/amt/sendRemoteErase.test.ts b/src/routes/amt/sendRPE.test.ts similarity index 79% rename from src/routes/amt/sendRemoteErase.test.ts rename to src/routes/amt/sendRPE.test.ts index 37ecdcecc..9bbb63f76 100644 --- a/src/routes/amt/sendRemoteErase.test.ts +++ b/src/routes/amt/sendRPE.test.ts @@ -5,7 +5,7 @@ import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' -import { sendRemoteErase } from './sendRemoteErase.js' +import { sendRPE } from './sendRPE.js' import { createSpyObj } from '../../test/helper/jest.js' import { DeviceAction } from '../../amt/DeviceAction.js' import { CIRAHandler } from '../../amt/CIRAHandler.js' @@ -26,7 +26,7 @@ describe('Send Remote Erase', () => { device = new DeviceAction(handler, null) req = { params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, - body: { eraseMask: 3 }, + body: { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: false }, deviceAction: device } resSpy = createSpyObj('Response', ['status', 'json', 'end', 'send']) @@ -36,25 +36,25 @@ describe('Send Remote Erase', () => { mqttSpy = spyOn(MqttProvider, 'publishEvent') bootCapsSpy = spyOn(device, 'getBootCapabilities') - sendEraseSpy = spyOn(device, 'sendRemoteErase') + sendEraseSpy = spyOn(device, 'sendRPE') }) it('should send remote erase when device supports the requested mask', async () => { - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x44 } } }) sendEraseSpy.mockResolvedValue(undefined) - await sendRemoteErase(req, resSpy) - expect(sendEraseSpy).toHaveBeenCalledWith(3) + await sendRPE(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(0x4) expect(resSpy.status).toHaveBeenCalledWith(200) expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) }) it('should send remote erase with zero mask (no specific capability check)', async () => { - req.body.eraseMask = 0 - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) + req.body = { secureEraseAllSSDs: false, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: false } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x44 } } }) sendEraseSpy.mockResolvedValue(undefined) - await sendRemoteErase(req, resSpy) + await sendRPE(req, resSpy) expect(sendEraseSpy).toHaveBeenCalledWith(0) expect(resSpy.status).toHaveBeenCalledWith(200) }) @@ -62,27 +62,27 @@ describe('Send Remote Erase', () => { it('should return 400 when device does not support platform erase', async () => { bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) - await sendRemoteErase(req, resSpy) + await sendRPE(req, resSpy) expect(sendEraseSpy).not.toHaveBeenCalled() expect(resSpy.status).toHaveBeenCalledWith(400) expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Device does not support Remote Platform Erase')) }) it('should return 400 when requested mask is not supported by device', async () => { - req.body.eraseMask = 4 - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) + req.body = { secureEraseAllSSDs: false, tpmClear: true, restoreBIOSToEOM: false, unconfigureCSME: false } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) - await sendRemoteErase(req, resSpy) + await sendRPE(req, resSpy) expect(sendEraseSpy).not.toHaveBeenCalled() expect(resSpy.status).toHaveBeenCalledWith(400) expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Requested erase capabilities are not supported by this device')) }) it('should return 400 when CSME is combined with hardware erase bits', async () => { - req.body.eraseMask = 0x10001 // CSME + hardware bit - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x10001 } } }) + req.body = { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: true } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) - await sendRemoteErase(req, resSpy) + await sendRPE(req, resSpy) expect(sendEraseSpy).not.toHaveBeenCalled() expect(resSpy.status).toHaveBeenCalledWith(400) expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'CSME unconfigure cannot be combined with other erase operations')) @@ -91,7 +91,7 @@ describe('Send Remote Erase', () => { it('should return 500 on unexpected error', async () => { bootCapsSpy.mockRejectedValue(new Error('AMT error')) - await sendRemoteErase(req, resSpy) + await sendRPE(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(500) expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)) }) diff --git a/src/routes/amt/sendRemoteErase.ts b/src/routes/amt/sendRPE.ts similarity index 80% rename from src/routes/amt/sendRemoteErase.ts rename to src/routes/amt/sendRPE.ts index 2f16bd68e..58cecfa29 100644 --- a/src/routes/amt/sendRemoteErase.ts +++ b/src/routes/amt/sendRPE.ts @@ -9,11 +9,16 @@ import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { MPSValidationError } from '../../utils/MPSValidationError.js' -export async function sendRemoteErase(req: Request, res: Response): Promise { +export async function sendRPE(req: Request, res: Response): Promise { try { const guid: string = req.params.guid - const { eraseMask } = req.body - const mask: number = eraseMask ?? 0 + + const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME } = req.body + const mask = + (secureEraseAllSSDs ? 0x4 : 0) | + (tpmClear ? 0x40 : 0) | + (restoreBIOSToEOM ? 0x4000000 : 0) | + (unconfigureCSME ? 0x10000 : 0) MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) @@ -33,12 +38,12 @@ export async function sendRemoteErase(req: Request, res: Response): Promise const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 if (platformEraseCaps !== 0) { rpeDesired = !!payload.platformEraseEnabled - await req.deviceAction.setRPEEnabled(rpeDesired) + await req.deviceAction.setRPE(rpeDesired) } } diff --git a/src/routes/amt/setRPEEnabled.test.ts b/src/routes/amt/setRPE.test.ts similarity index 100% rename from src/routes/amt/setRPEEnabled.test.ts rename to src/routes/amt/setRPE.test.ts diff --git a/src/routes/amt/setRPEEnabled.ts b/src/routes/amt/setRPE.ts similarity index 89% rename from src/routes/amt/setRPEEnabled.ts rename to src/routes/amt/setRPE.ts index fbb58ea9c..8a751e831 100644 --- a/src/routes/amt/setRPEEnabled.ts +++ b/src/routes/amt/setRPE.ts @@ -9,7 +9,7 @@ import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { MPSValidationError } from '../../utils/MPSValidationError.js' -export async function setRPEEnabled(req: Request, res: Response): Promise { +export async function setRPE(req: Request, res: Response): Promise { try { const guid: string = req.params.guid const { enabled } = req.body @@ -23,12 +23,12 @@ export async function setRPEEnabled(req: Request, res: Response): Promise throw new MPSValidationError('Device does not support Remote Platform Erase', 400) } - await req.deviceAction.setRPEEnabled(!!enabled) + await req.deviceAction.setRPE(!!enabled) MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) res.status(200).json({ status: 'success' }).end() } catch (error) { - logger.error(`setRPEEnabled failed: ${error}`) + logger.error(`setRPE failed: ${error}`) if (error instanceof MPSValidationError) { res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)) } else { diff --git a/src/test/collections/MPS.postman_collection.json b/src/test/collections/MPS.postman_collection.json index c0254a130..0cf577e24 100644 --- a/src/test/collections/MPS.postman_collection.json +++ b/src/test/collections/MPS.postman_collection.json @@ -3285,7 +3285,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"eraseMask\": 0\r\n}", + "raw": "{\r\n \"secureEraseAllSSDs\": true,\r\n \"tpmClear\": false,\r\n \"restoreBIOSToEOM\": false,\r\n \"unconfigureCSME\": false\r\n}", "options": { "raw": { "language": "json" @@ -3293,7 +3293,7 @@ } }, "url": { - "raw": "{{protocol}}://{{host}}/api/v1/amt/remoteErase/1", + "raw": "{{protocol}}://{{host}}/api/v1/amt/rpe/1", "protocol": "{{protocol}}", "host": [ "{{host}}" @@ -3302,7 +3302,7 @@ "api", "v1", "amt", - "remoteErase", + "rpe", "1" ] } diff --git a/swagger.yaml b/swagger.yaml index f34b002a1..b43e18f7a 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -299,6 +299,85 @@ paths: application/json: schema: $ref: '#/components/schemas/GetAMTFeaturesResponse' + /api/v1/amt/boot/rpe/{guid}: + post: + summary: Enable or Disable Remote Platform Erase (RPE) + description: | + Enables or disables the Remote Platform Erase feature on the specified AMT device. + RPE must be enabled before calling `POST /api/v1/amt/rpe/{guid}` to trigger an erase. + Returns 400 if the device does not support RPE. + tags: + - AMT + parameters: + - name: guid + in: path + description: GUID of device + example: 123e4567-e89b-12d3-a456-426614174000 + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetRPERequest' + responses: + 200: + description: 'RPE enabled/disabled successfully' + content: + application/json: + schema: + $ref: '#/components/schemas/RPEStatusResponse' + 400: + description: 'Device does not support Remote Platform Erase' + 404: + description: 'Device not found/connected' + 500: + description: 'Internal server error' + /api/v1/amt/rpe/{guid}: + post: + summary: Trigger Remote Platform Erase + description: | + Initiates a Remote Platform Erase on the specified AMT device. + RPE must be enabled on the device first via `POST /api/v1/amt/boot/rpe/{guid}`. + + The `eraseMask` is a bitmask of erase targets to activate: + - `0x0001` — Non-volatile memory + - `0x0002` — Volatile memory + - `0x10000` — CSME unconfigure (cannot be combined with other bits) + - `0` — Platform erase without specific hardware target + + Returns 400 if the device does not support RPE, if the requested mask is not supported, or if CSME is combined with other erase operations. + tags: + - AMT + parameters: + - name: guid + in: path + description: GUID of device + example: 123e4567-e89b-12d3-a456-426614174000 + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendRPERequest' + responses: + 200: + description: 'Remote Platform Erase initiated successfully' + content: + application/json: + schema: + $ref: '#/components/schemas/RPEStatusResponse' + 400: + description: 'Device does not support RPE, requested mask not supported, or invalid mask combination' + 404: + description: 'Device not found/connected' + 500: + description: 'Internal server error' /api/v1/amt/alarmOccurrences/{guid}: post: summary: Set new Alarm Clock Occurence @@ -2434,7 +2513,9 @@ components: type: boolean winREBootSupported: type: boolean - remoteErase: + rpe: + type: boolean + rpeSupported: type: boolean example: userConsent: kvm @@ -2447,7 +2528,8 @@ components: httpsBootSupported: true winREBootSupported: true localPBABootSupported: true - remoteErase: false + rpe: true + rpeSupported: true SetAlarmClockRequest: title: SetAlarmClockRequest @@ -3536,3 +3618,40 @@ components: structuredBiosBootString: type: string example: '' + SetRPERequest: + title: SetRPERequest + required: + - enabled + properties: + enabled: + type: boolean + description: Set to true to enable RPE, false to disable + example: + enabled: true + SendRPERequest: + title: SendRPERequest + properties: + secureEraseAllSSDs: + type: boolean + description: 'Bit 2 (0x04) — Secure erase all SSDs' + tpmClear: + type: boolean + description: 'Bit 6 (0x40) — Clear TPM' + restoreBIOSToEOM: + type: boolean + description: 'Bit 26 (0x4000000) — Reload BIOS golden configuration' + unconfigureCSME: + type: boolean + description: 'CSME unconfigure — sets ConfigurationDataReset; cannot be combined with other erase operations' + example: + secureEraseAllSSDs: true + tpmClear: false + restoreBIOSToEOM: false + unconfigureCSME: false + RPEStatusResponse: + title: RPEStatusResponse + properties: + status: + type: string + example: + status: success From 99b2d24d0e315403075af567d7d71ee7f3a43dbc Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Mon, 15 Jun 2026 22:54:49 +0000 Subject: [PATCH 03/14] fix: tests and lint errors --- src/amt/DeviceAction.ts | 10 +++--- src/amt/deviceAction.test.ts | 12 +++++-- src/routes/amt/getAMTFeatures.test.ts | 2 +- src/routes/amt/getAMTFeatures.ts | 4 +-- src/routes/amt/getBootCapabilities.test.ts | 19 +++++++----- src/routes/amt/kvm/get.test.ts | 3 +- src/routes/amt/powerCapabilities.test.ts | 3 +- src/routes/amt/sendRPE.test.ts | 31 ++++++++++++------- src/routes/amt/sendRPE.ts | 8 ++--- src/routes/amt/setRPE.test.ts | 31 +++++++++++-------- src/routes/index.test.ts | 3 +- .../collections/MPS.postman_collection.json | 2 +- 12 files changed, 78 insertions(+), 50 deletions(-) diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 9faeb8a36..59206b299 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -262,15 +262,15 @@ export class DeviceAction { UseIDER: current.UseIDER, UseSOL: current.UseSOL, UseSafeMode: current.UseSafeMode, - UserPasswordBypass: current.UserPasswordBypass, + UserPasswordBypass: current.UserPasswordBypass } if (hwMask !== 0) { const buf = Buffer.alloc(12) - buf.writeUInt16LE(0x8086, 0) // Intel vendor prefix - buf.writeUInt16LE(1, 2) // ParameterTypeID = 1 - buf.writeUInt32LE(4, 4) // value length = 4 bytes - buf.writeUInt32LE(hwMask, 8) // device bitmask + buf.writeUInt16LE(0x8086, 0) // Intel vendor prefix + buf.writeUInt16LE(1, 2) // ParameterTypeID = 1 + buf.writeUInt32LE(4, 4) // value length = 4 bytes + buf.writeUInt32LE(hwMask, 8) // device bitmask putBody.UefiBootParametersArray = buf.toString('base64') putBody.UefiBootNumberOfParams = 1 } diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index d71522799..76692ca98 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -389,14 +389,18 @@ describe('Device Action Tests', () => { expect(result).toEqual(bootCapabilities.Envelope) }) it('should set RPE enabled', async () => { - getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } }) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } + }) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) await device.setRPE(true) expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() }) it('should send remote erase with non-zero mask', async () => { - getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } }) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) @@ -406,7 +410,9 @@ describe('Device Action Tests', () => { expect(sendSpy).toHaveBeenCalled() }) it('should send remote erase with zero mask sets PlatformErase to false', async () => { - getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: true, RPEEnabled: true } } } }) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: true, RPE: true } } } + }) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) diff --git a/src/routes/amt/getAMTFeatures.test.ts b/src/routes/amt/getAMTFeatures.test.ts index a832b402f..d67928935 100644 --- a/src/routes/amt/getAMTFeatures.test.ts +++ b/src/routes/amt/getAMTFeatures.test.ts @@ -176,7 +176,7 @@ describe('get amt features', () => { httpsBootSupported: true, winREBootSupported: true, localPBABootSupported: false, - rpe: true, + rpe: false, rpeSupported: true }) expect(mqttSpy).toHaveBeenCalledTimes(2) diff --git a/src/routes/amt/getAMTFeatures.ts b/src/routes/amt/getAMTFeatures.ts index 1180e78c9..4d098b779 100644 --- a/src/routes/amt/getAMTFeatures.ts +++ b/src/routes/amt/getAMTFeatures.ts @@ -29,11 +29,11 @@ export async function getAMTFeatures(req: Request, res: Response): Promise const ocrProcessResult = processOCRData(OCRData) const rpeCaps = OCRData.capabilities?.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 - const rpe = !!(OCRData.bootData?.AMT_BootSettingData?.PlatformErase) + const rpe = !!OCRData.bootData?.AMT_BootSettingData?.PlatformErase const rpeSupported = rpeCaps !== 0 MqttProvider.publishEvent('success', ['AMT_GetFeatures'], messages.AMT_FEATURES_GET_SUCCESS, guid) - res + res .status(200) .json({ userConsent, diff --git a/src/routes/amt/getBootCapabilities.test.ts b/src/routes/amt/getBootCapabilities.test.ts index 73d707316..1c76ae11a 100644 --- a/src/routes/amt/getBootCapabilities.test.ts +++ b/src/routes/amt/getBootCapabilities.test.ts @@ -6,18 +6,18 @@ import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { getBootCapabilities } from './getBootCapabilities.js' -import { createSpyObj } from '../../test/helper/jest.js' +import { createSpyObj } from '../../test/helper/vitest.js' import { DeviceAction } from '../../amt/DeviceAction.js' import { CIRAHandler } from '../../amt/CIRAHandler.js' import { HttpHandler } from '../../amt/HttpHandler.js' import { messages } from '../../logging/index.js' -import { type Spied, spyOn } from 'jest-mock' +import { vi, type MockInstance } from 'vitest' describe('Get Boot Capabilities', () => { let req: any let resSpy: any - let mqttSpy: Spied - let bootCapsSpy: Spied + let mqttSpy: MockInstance + let bootCapsSpy: MockInstance let device: DeviceAction beforeEach(() => { @@ -27,13 +27,18 @@ describe('Get Boot Capabilities', () => { params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, deviceAction: device } - resSpy = createSpyObj('Response', ['status', 'json', 'end', 'send']) + resSpy = createSpyObj('Response', [ + 'status', + 'json', + 'end', + 'send' + ]) resSpy.status.mockReturnThis() resSpy.json.mockReturnThis() resSpy.send.mockReturnThis() - mqttSpy = spyOn(MqttProvider, 'publishEvent') - bootCapsSpy = spyOn(device, 'getBootCapabilities') + mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') + bootCapsSpy = vi.spyOn(device, 'getBootCapabilities') }) it('should return boot capabilities', async () => { diff --git a/src/routes/amt/kvm/get.test.ts b/src/routes/amt/kvm/get.test.ts index d243535a0..14477f8c5 100644 --- a/src/routes/amt/kvm/get.test.ts +++ b/src/routes/amt/kvm/get.test.ts @@ -59,7 +59,8 @@ describe('getScreenSettingData', () => { it('should return mapped settings if KVM data exists', async () => { const screenData = { IPS_ScreenSettingDataItems: [ - { IsActive: [true], UpperLeftX: [1], UpperLeftY: [2], ResolutionX: [3], ResolutionY: [4] }] + { IsActive: [true], UpperLeftX: [1], UpperLeftY: [2], ResolutionX: [3], ResolutionY: [4] } + ] } const kvmData = { IPS_KVMRedirectionSettingData: { DefaultScreen: 0 } } req.deviceAction.getScreenSettingData.mockResolvedValue(screenData) diff --git a/src/routes/amt/powerCapabilities.test.ts b/src/routes/amt/powerCapabilities.test.ts index be365a1a7..4d3623237 100644 --- a/src/routes/amt/powerCapabilities.test.ts +++ b/src/routes/amt/powerCapabilities.test.ts @@ -94,7 +94,8 @@ describe('Power Capabilities', () => { 'Power on to PXE': 401 } versionResponse.CIM_SoftwareIdentity.responses = [ - { InstanceID: 'AMT', IsEntity: 'true', VersionString: '9.0.0' }] + { InstanceID: 'AMT', IsEntity: 'true', VersionString: '9.0.0' } + ] vi.spyOn(device, 'getBootCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) diff --git a/src/routes/amt/sendRPE.test.ts b/src/routes/amt/sendRPE.test.ts index 9bbb63f76..035d3e1e2 100644 --- a/src/routes/amt/sendRPE.test.ts +++ b/src/routes/amt/sendRPE.test.ts @@ -6,19 +6,19 @@ import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { sendRPE } from './sendRPE.js' -import { createSpyObj } from '../../test/helper/jest.js' +import { createSpyObj } from '../../test/helper/vitest.js' import { DeviceAction } from '../../amt/DeviceAction.js' import { CIRAHandler } from '../../amt/CIRAHandler.js' import { HttpHandler } from '../../amt/HttpHandler.js' import { messages } from '../../logging/index.js' -import { type Spied, spyOn } from 'jest-mock' +import { vi, type MockInstance } from 'vitest' describe('Send Remote Erase', () => { let req: any let resSpy: any - let mqttSpy: Spied - let bootCapsSpy: Spied - let sendEraseSpy: Spied + let mqttSpy: MockInstance + let bootCapsSpy: MockInstance + let sendEraseSpy: MockInstance let device: DeviceAction beforeEach(() => { @@ -29,14 +29,19 @@ describe('Send Remote Erase', () => { body: { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: false }, deviceAction: device } - resSpy = createSpyObj('Response', ['status', 'json', 'end', 'send']) + resSpy = createSpyObj('Response', [ + 'status', + 'json', + 'end', + 'send' + ]) resSpy.status.mockReturnThis() resSpy.json.mockReturnThis() resSpy.send.mockReturnThis() - mqttSpy = spyOn(MqttProvider, 'publishEvent') - bootCapsSpy = spyOn(device, 'getBootCapabilities') - sendEraseSpy = spyOn(device, 'sendRPE') + mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') + bootCapsSpy = vi.spyOn(device, 'getBootCapabilities') + sendEraseSpy = vi.spyOn(device, 'sendRPE') }) it('should send remote erase when device supports the requested mask', async () => { @@ -75,7 +80,9 @@ describe('Send Remote Erase', () => { await sendRPE(req, resSpy) expect(sendEraseSpy).not.toHaveBeenCalled() expect(resSpy.status).toHaveBeenCalledWith(400) - expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Requested erase capabilities are not supported by this device')) + expect(resSpy.json).toHaveBeenCalledWith( + ErrorResponse(400, 'Requested erase capabilities are not supported by this device') + ) }) it('should return 400 when CSME is combined with hardware erase bits', async () => { @@ -85,7 +92,9 @@ describe('Send Remote Erase', () => { await sendRPE(req, resSpy) expect(sendEraseSpy).not.toHaveBeenCalled() expect(resSpy.status).toHaveBeenCalledWith(400) - expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'CSME unconfigure cannot be combined with other erase operations')) + expect(resSpy.json).toHaveBeenCalledWith( + ErrorResponse(400, 'CSME unconfigure cannot be combined with other erase operations') + ) }) it('should return 500 on unexpected error', async () => { diff --git a/src/routes/amt/sendRPE.ts b/src/routes/amt/sendRPE.ts index 58cecfa29..9ff7fd177 100644 --- a/src/routes/amt/sendRPE.ts +++ b/src/routes/amt/sendRPE.ts @@ -15,10 +15,10 @@ export async function sendRPE(req: Request, res: Response): Promise { const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME } = req.body const mask = - (secureEraseAllSSDs ? 0x4 : 0) | - (tpmClear ? 0x40 : 0) | - (restoreBIOSToEOM ? 0x4000000 : 0) | - (unconfigureCSME ? 0x10000 : 0) + (secureEraseAllSSDs ? 0x4 : 0) | + (tpmClear ? 0x40 : 0) | + (restoreBIOSToEOM ? 0x4000000 : 0) | + (unconfigureCSME ? 0x10000 : 0) MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) diff --git a/src/routes/amt/setRPE.test.ts b/src/routes/amt/setRPE.test.ts index 2a225c8d8..1952e3eaf 100644 --- a/src/routes/amt/setRPE.test.ts +++ b/src/routes/amt/setRPE.test.ts @@ -5,20 +5,20 @@ import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' -import { setRPEEnabled } from './setRPEEnabled.js' -import { createSpyObj } from '../../test/helper/jest.js' +import { setRPE } from './setRPE.js' +import { createSpyObj } from '../../test/helper/vitest.js' import { DeviceAction } from '../../amt/DeviceAction.js' import { CIRAHandler } from '../../amt/CIRAHandler.js' import { HttpHandler } from '../../amt/HttpHandler.js' import { messages } from '../../logging/index.js' -import { type Spied, spyOn } from 'jest-mock' +import { vi, type MockInstance } from 'vitest' describe('Set RPE Enabled', () => { let req: any let resSpy: any - let mqttSpy: Spied - let bootCapsSpy: Spied - let setRPESpy: Spied + let mqttSpy: MockInstance + let bootCapsSpy: MockInstance + let setRPESpy: MockInstance let device: DeviceAction beforeEach(() => { @@ -29,21 +29,26 @@ describe('Set RPE Enabled', () => { body: { enabled: true }, deviceAction: device } - resSpy = createSpyObj('Response', ['status', 'json', 'end', 'send']) + resSpy = createSpyObj('Response', [ + 'status', + 'json', + 'end', + 'send' + ]) resSpy.status.mockReturnThis() resSpy.json.mockReturnThis() resSpy.send.mockReturnThis() - mqttSpy = spyOn(MqttProvider, 'publishEvent') - bootCapsSpy = spyOn(device, 'getBootCapabilities') - setRPESpy = spyOn(device, 'setRPEEnabled') + mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') + bootCapsSpy = vi.spyOn(device, 'getBootCapabilities') + setRPESpy = vi.spyOn(device, 'setRPE') }) it('should enable RPE when device supports platform erase', async () => { bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) setRPESpy.mockResolvedValue(undefined) - await setRPEEnabled(req, resSpy) + await setRPE(req, resSpy) expect(setRPESpy).toHaveBeenCalledWith(true) expect(resSpy.status).toHaveBeenCalledWith(200) expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) @@ -52,7 +57,7 @@ describe('Set RPE Enabled', () => { it('should return 400 when device does not support platform erase', async () => { bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) - await setRPEEnabled(req, resSpy) + await setRPE(req, resSpy) expect(setRPESpy).not.toHaveBeenCalled() expect(resSpy.status).toHaveBeenCalledWith(400) expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Device does not support Remote Platform Erase')) @@ -61,7 +66,7 @@ describe('Set RPE Enabled', () => { it('should return 500 on unexpected error', async () => { bootCapsSpy.mockRejectedValue(new Error('AMT error')) - await setRPEEnabled(req, resSpy) + await setRPE(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(500) expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)) }) diff --git a/src/routes/index.test.ts b/src/routes/index.test.ts index 389f454ea..f8b7aeabe 100644 --- a/src/routes/index.test.ts +++ b/src/routes/index.test.ts @@ -7,7 +7,8 @@ import router from './index.js' describe('Check index from routes', () => { const routes = [ - { path: '/ciracert', method: 'get' }] + { path: '/ciracert', method: 'get' } + ] it('should have routes', () => { routes.forEach((route) => { const match = router.stack.find((s) => s.route?.path === route.path && (s.route as any)?.methods[route.method]) diff --git a/src/test/collections/MPS.postman_collection.json b/src/test/collections/MPS.postman_collection.json index 0cf577e24..971bfbf18 100644 --- a/src/test/collections/MPS.postman_collection.json +++ b/src/test/collections/MPS.postman_collection.json @@ -3782,4 +3782,4 @@ } } ] -} \ No newline at end of file +} From 36f4fcf26db456444e0b278716707294a2d38970 Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Wed, 17 Jun 2026 15:00:35 -0700 Subject: [PATCH 04/14] fix: issues found during testing --- src/amt/DeviceAction.ts | 6 +++--- src/routes/amt/getBootCapabilities.ts | 21 ++++++++++++++++++++- src/routes/amt/index.ts | 2 ++ src/routes/amt/powerAction.ts | 1 + 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 59206b299..971c54ae8 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -198,11 +198,11 @@ export class DeviceAction { return result.Envelope } - async setRPE(enabled: boolean): Promise { + async setRPE(isEnabled: boolean): Promise { logger.silly(`setRPE ${messages.REQUEST}`) const bootOptions = await this.getBootOptions() const current = bootOptions.AMT_BootSettingData - current.RPE = enabled + current.RPEEnabled = isEnabled await this.setBootConfiguration(current) logger.silly(`setRPE ${messages.COMPLETE}`) } @@ -218,7 +218,7 @@ export class DeviceAction { // Step 1: GET current boot settings and verify RPE const bootOptions = await this.getBootOptions() const current = bootOptions.AMT_BootSettingData - if (!current.RPE) { + if (!current.RPEEnabled) { throw new Error('RPE is not enabled on this device') } diff --git a/src/routes/amt/getBootCapabilities.ts b/src/routes/amt/getBootCapabilities.ts index 945228883..6ae4e86f2 100644 --- a/src/routes/amt/getBootCapabilities.ts +++ b/src/routes/amt/getBootCapabilities.ts @@ -8,6 +8,11 @@ import { logger, messages } from '../../logging/index.js' import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' +const PLATFORM_ERASE_SSDS = 0x4 +const PLATFORM_ERASE_TPM_CLEAR = 0x40 +const PLATFORM_ERASE_BIOS_RESTORE = 0x4000000 +const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 + export async function getBootCapabilities(req: Request, res: Response): Promise { try { const guid: string = req.params.guid @@ -15,7 +20,7 @@ export async function getBootCapabilities(req: Request, res: Response): Promise< MqttProvider.publishEvent('request', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_REQUESTED, guid) const result = await req.deviceAction.getBootCapabilities() - const capabilities = result.Body?.AMT_BootCapabilities + const capabilities = parsePlatformEraseCapabilities(result.Body?.AMT_BootCapabilities?.PlatformErase ?? 0) MqttProvider.publishEvent('success', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_SUCCESS, guid) res.status(200).json(capabilities).end() @@ -25,3 +30,17 @@ export async function getBootCapabilities(req: Request, res: Response): Promise< res.status(500).json(ErrorResponse(500, messages.POWER_CAPABILITIES_EXCEPTION)).end() } } + +function parsePlatformEraseCapabilities(platformEraseMask: number): { + secureEraseAllSSDs: boolean + tpmClear: boolean + restoreBIOSToEOM: boolean + unconfigureCSME: boolean +} { + return { + secureEraseAllSSDs: (platformEraseMask & PLATFORM_ERASE_SSDS) !== 0, + tpmClear: (platformEraseMask & PLATFORM_ERASE_TPM_CLEAR) !== 0, + restoreBIOSToEOM: (platformEraseMask & PLATFORM_ERASE_BIOS_RESTORE) !== 0, + unconfigureCSME: (platformEraseMask & PLATFORM_ERASE_CSME_UNCONFIGURE) !== 0 + } +} diff --git a/src/routes/amt/index.ts b/src/routes/amt/index.ts index 12639c61d..acd9ece5f 100644 --- a/src/routes/amt/index.ts +++ b/src/routes/amt/index.ts @@ -59,6 +59,8 @@ amtRouter.post('/features/:guid', amtFeaturesValidator(), validateMiddleware, ci amtRouter.get('/boot/capabilities/:guid', ciraMiddleware, getBootCapabilities) amtRouter.post('/boot/rpe/:guid', ciraMiddleware, setRPE) amtRouter.post('/rpe/:guid', ciraMiddleware, sendRPE) +amtRouter.get('/boot/remoteErase/:guid', ciraMiddleware, getBootCapabilities) +amtRouter.post('/boot/remoteErase/:guid', ciraMiddleware, sendRPE) amtRouter.get('/version/:guid', ciraMiddleware, version) amtRouter.delete('/deactivate/:guid', ciraMiddleware, deactivate) amtRouter.get('/power/bootSources/:guid', ciraMiddleware, bootSources) diff --git a/src/routes/amt/powerAction.ts b/src/routes/amt/powerAction.ts index 3203a4437..7855d6b62 100644 --- a/src/routes/amt/powerAction.ts +++ b/src/routes/amt/powerAction.ts @@ -187,6 +187,7 @@ export function setBootData( r.UseSafeMode = false r.UserPasswordBypass = false r.SecureErase = false + r.RPEEnabled = false // if (r.SecureErase) { // r.SecureErase = action === 104 && amtPowerBootCapabilities.SecureErase === true // } From 488c45a1e0e0dcb8f081acad236d558abeee178b Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Fri, 26 Jun 2026 16:01:59 -0700 Subject: [PATCH 05/14] feat: add power state to sendRPE API for ability to turn on, when RPE is done in off state --- src/amt/DeviceAction.ts | 7 +++++-- src/routes/amt/sendRPE.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 971c54ae8..f82d22838 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -207,7 +207,7 @@ export class DeviceAction { logger.silly(`setRPE ${messages.COMPLETE}`) } - async sendRPE(eraseMask: number): Promise { + async sendRPE(eraseMask: number, powerType: number): Promise { logger.silly(`sendRPE ${messages.REQUEST}`) // CSME sentinel bit: 0x10000 maps to ConfigurationDataReset, not a hardware erase target @@ -285,7 +285,10 @@ export class DeviceAction { await this.forceBootMode(1) // Step 5: Power Cycle Off Hard — S5→S0 required; warm reset keeps ME power rails active - await this.sendPowerAction(5) + const powerStateResult = await this.getPowerState() + const currentState = powerStateResult?.PullResponse?.Items?.CIM_AssociatedPowerManagementService?.PowerState + const action = currentState === "8" ? 2 : 5 + await this.sendPowerAction(action as CIM.Types.PowerManagementService.PowerState) logger.silly(`sendRPE ${messages.COMPLETE}`) } diff --git a/src/routes/amt/sendRPE.ts b/src/routes/amt/sendRPE.ts index 9ff7fd177..08736c1e7 100644 --- a/src/routes/amt/sendRPE.ts +++ b/src/routes/amt/sendRPE.ts @@ -13,7 +13,7 @@ export async function sendRPE(req: Request, res: Response): Promise { try { const guid: string = req.params.guid - const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME } = req.body + const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME, powerType } = req.body const mask = (secureEraseAllSSDs ? 0x4 : 0) | (tpmClear ? 0x40 : 0) | @@ -38,7 +38,7 @@ export async function sendRPE(req: Request, res: Response): Promise { throw new MPSValidationError('CSME unconfigure cannot be combined with other erase operations', 400) } - await req.deviceAction.sendRPE(mask) + await req.deviceAction.sendRPE(mask, powerType) MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) res.status(200).json({ status: 'success' }).end() From fb5c408a2a95b85970ee3fb89735971ba14f295d Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Mon, 29 Jun 2026 17:34:56 -0700 Subject: [PATCH 06/14] fix: issue with state tracking of RPE state in UI tabs --- src/amt/DeviceAction.ts | 7 ++++--- src/routes/amt/getAMTFeatures.test.ts | 2 +- src/routes/amt/getAMTFeatures.ts | 3 ++- src/routes/amt/getBootCapabilities.ts | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index f82d22838..c9ffee7af 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -202,7 +202,7 @@ export class DeviceAction { logger.silly(`setRPE ${messages.REQUEST}`) const bootOptions = await this.getBootOptions() const current = bootOptions.AMT_BootSettingData - current.RPEEnabled = isEnabled + ;(current as any).RPE = isEnabled await this.setBootConfiguration(current) logger.silly(`setRPE ${messages.COMPLETE}`) } @@ -218,7 +218,8 @@ export class DeviceAction { // Step 1: GET current boot settings and verify RPE const bootOptions = await this.getBootOptions() const current = bootOptions.AMT_BootSettingData - if (!current.RPEEnabled) { + const rpeEnabled = (current as any).RPE ?? current.RPEEnabled ?? current.PlatformErase + if (!rpeEnabled) { throw new Error('RPE is not enabled on this device') } @@ -287,7 +288,7 @@ export class DeviceAction { // Step 5: Power Cycle Off Hard — S5→S0 required; warm reset keeps ME power rails active const powerStateResult = await this.getPowerState() const currentState = powerStateResult?.PullResponse?.Items?.CIM_AssociatedPowerManagementService?.PowerState - const action = currentState === "8" ? 2 : 5 + const action = currentState === '8' ? 2 : 5 await this.sendPowerAction(action as CIM.Types.PowerManagementService.PowerState) logger.silly(`sendRPE ${messages.COMPLETE}`) diff --git a/src/routes/amt/getAMTFeatures.test.ts b/src/routes/amt/getAMTFeatures.test.ts index d67928935..a832b402f 100644 --- a/src/routes/amt/getAMTFeatures.test.ts +++ b/src/routes/amt/getAMTFeatures.test.ts @@ -176,7 +176,7 @@ describe('get amt features', () => { httpsBootSupported: true, winREBootSupported: true, localPBABootSupported: false, - rpe: false, + rpe: true, rpeSupported: true }) expect(mqttSpy).toHaveBeenCalledTimes(2) diff --git a/src/routes/amt/getAMTFeatures.ts b/src/routes/amt/getAMTFeatures.ts index 4d098b779..5204f5a91 100644 --- a/src/routes/amt/getAMTFeatures.ts +++ b/src/routes/amt/getAMTFeatures.ts @@ -29,7 +29,8 @@ export async function getAMTFeatures(req: Request, res: Response): Promise const ocrProcessResult = processOCRData(OCRData) const rpeCaps = OCRData.capabilities?.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 - const rpe = !!OCRData.bootData?.AMT_BootSettingData?.PlatformErase + const bootData = OCRData.bootData?.AMT_BootSettingData + const rpe = !!(bootData?.RPE ?? bootData?.RPEEnabled ?? bootData?.PlatformErase) const rpeSupported = rpeCaps !== 0 MqttProvider.publishEvent('success', ['AMT_GetFeatures'], messages.AMT_FEATURES_GET_SUCCESS, guid) diff --git a/src/routes/amt/getBootCapabilities.ts b/src/routes/amt/getBootCapabilities.ts index 6ae4e86f2..328411009 100644 --- a/src/routes/amt/getBootCapabilities.ts +++ b/src/routes/amt/getBootCapabilities.ts @@ -20,7 +20,7 @@ export async function getBootCapabilities(req: Request, res: Response): Promise< MqttProvider.publishEvent('request', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_REQUESTED, guid) const result = await req.deviceAction.getBootCapabilities() - const capabilities = parsePlatformEraseCapabilities(result.Body?.AMT_BootCapabilities?.PlatformErase ?? 0) + const capabilities = parsePlatformEraseCapabilities(result.Body?.AMT_BootCapabilities?.PlatformErase ?? 0) MqttProvider.publishEvent('success', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_SUCCESS, guid) res.status(200).json(capabilities).end() From 54d6c5aa63bdf705a199008da20249a1fc45fb9d Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Mon, 29 Jun 2026 19:36:18 -0700 Subject: [PATCH 07/14] fix: merge conflicts --- src/routes/amt/networkSettings/getWirelessProfileSync.ts | 2 +- src/routes/amt/networkSettings/setWirelessProfileSync.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/amt/networkSettings/getWirelessProfileSync.ts b/src/routes/amt/networkSettings/getWirelessProfileSync.ts index 3b07c1aeb..b8297626f 100644 --- a/src/routes/amt/networkSettings/getWirelessProfileSync.ts +++ b/src/routes/amt/networkSettings/getWirelessProfileSync.ts @@ -38,7 +38,7 @@ export async function getWirelessProfileSync(req: Request, res: Response): Promi const configResponse = await req.deviceAction.getWiFiPortConfigurationService() const config = configResponse?.Body?.AMT_WiFiPortConfigurationService ?? null - const capabilities = await req.deviceAction.getPowerCapabilities() + const capabilities = await req.deviceAction.getBootCapabilities() const uefiSupported = Boolean(capabilities?.Body?.AMT_BootCapabilities?.UEFIWiFiCoExistenceAndProfileShare) MqttProvider.publishEvent( diff --git a/src/routes/amt/networkSettings/setWirelessProfileSync.ts b/src/routes/amt/networkSettings/setWirelessProfileSync.ts index 2e93e5cc1..78f7ab9f1 100644 --- a/src/routes/amt/networkSettings/setWirelessProfileSync.ts +++ b/src/routes/amt/networkSettings/setWirelessProfileSync.ts @@ -63,7 +63,7 @@ export async function setWirelessProfileSync(req: Request, res: Response): Promi return } - const capabilities = await req.deviceAction.getPowerCapabilities() + const capabilities = await req.deviceAction.getBootCapabilities() const uefiSupported = Boolean(capabilities?.Body?.AMT_BootCapabilities?.UEFIWiFiCoExistenceAndProfileShare) // Reject the whole request when UEFI profile sync is requested but unsupported. From bbc3caaa8ff6ab594d27adeeed4886e519540e79 Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Mon, 29 Jun 2026 20:15:04 -0700 Subject: [PATCH 08/14] fix: add backwards compatibility for getPowerCapabilities --- src/amt/DeviceAction.ts | 7 ++++++- src/amt/deviceAction.test.ts | 4 ++++ src/routes/amt/getBootCapabilities.test.ts | 9 +++++++-- src/routes/amt/networkSettings/getWirelessProfileSync.ts | 2 +- src/routes/amt/networkSettings/setWirelessProfileSync.ts | 2 +- src/routes/amt/powerCapabilities.ts | 2 +- src/routes/amt/sendRPE.test.ts | 4 ++-- 7 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 73b7296bb..63ed95024 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -198,6 +198,11 @@ export class DeviceAction { return result.Envelope } + // Backward-compatible alias. Prefer getBootCapabilities for new code. + async getPowerCapabilities(): Promise> { + return await this.getBootCapabilities() + } + async setRPE(isEnabled: boolean): Promise { logger.silly(`setRPE ${messages.REQUEST}`) const bootOptions = await this.getBootOptions() @@ -726,7 +731,7 @@ export class DeviceAction { async getOCRData(): Promise { const bootService = await this.getBootService() const bootSourceSettings = await this.getBootSourceSetting() - const capabilities = await this.getBootCapabilities() + const capabilities = await this.getPowerCapabilities() const bootData = await this.getBootSettingData() return { diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index ec43d050b..0b87538e7 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -401,6 +401,8 @@ describe('Device Action Tests', () => { getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) @@ -413,6 +415,8 @@ describe('Device Action Tests', () => { getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: true, RPE: true } } } }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) diff --git a/src/routes/amt/getBootCapabilities.test.ts b/src/routes/amt/getBootCapabilities.test.ts index 1c76ae11a..95c642cb5 100644 --- a/src/routes/amt/getBootCapabilities.test.ts +++ b/src/routes/amt/getBootCapabilities.test.ts @@ -46,7 +46,7 @@ describe('Get Boot Capabilities', () => { IDER: true, SOL: true, BIOSSetup: true, - PlatformErase: 3 + PlatformErase: 0x10044 } bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: bootCaps } @@ -54,7 +54,12 @@ describe('Get Boot Capabilities', () => { await getBootCapabilities(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.json).toHaveBeenCalledWith(bootCaps) + expect(resSpy.json).toHaveBeenCalledWith({ + secureEraseAllSSDs: true, + tpmClear: true, + restoreBIOSToEOM: false, + unconfigureCSME: true + }) expect(resSpy.end).toHaveBeenCalled() expect(mqttSpy).toHaveBeenCalledTimes(2) }) diff --git a/src/routes/amt/networkSettings/getWirelessProfileSync.ts b/src/routes/amt/networkSettings/getWirelessProfileSync.ts index b8297626f..3b07c1aeb 100644 --- a/src/routes/amt/networkSettings/getWirelessProfileSync.ts +++ b/src/routes/amt/networkSettings/getWirelessProfileSync.ts @@ -38,7 +38,7 @@ export async function getWirelessProfileSync(req: Request, res: Response): Promi const configResponse = await req.deviceAction.getWiFiPortConfigurationService() const config = configResponse?.Body?.AMT_WiFiPortConfigurationService ?? null - const capabilities = await req.deviceAction.getBootCapabilities() + const capabilities = await req.deviceAction.getPowerCapabilities() const uefiSupported = Boolean(capabilities?.Body?.AMT_BootCapabilities?.UEFIWiFiCoExistenceAndProfileShare) MqttProvider.publishEvent( diff --git a/src/routes/amt/networkSettings/setWirelessProfileSync.ts b/src/routes/amt/networkSettings/setWirelessProfileSync.ts index 78f7ab9f1..2e93e5cc1 100644 --- a/src/routes/amt/networkSettings/setWirelessProfileSync.ts +++ b/src/routes/amt/networkSettings/setWirelessProfileSync.ts @@ -63,7 +63,7 @@ export async function setWirelessProfileSync(req: Request, res: Response): Promi return } - const capabilities = await req.deviceAction.getBootCapabilities() + const capabilities = await req.deviceAction.getPowerCapabilities() const uefiSupported = Boolean(capabilities?.Body?.AMT_BootCapabilities?.UEFIWiFiCoExistenceAndProfileShare) // Reject the whole request when UEFI profile sync is requested but unsupported. diff --git a/src/routes/amt/powerCapabilities.ts b/src/routes/amt/powerCapabilities.ts index a5a297026..9f92c8fb0 100644 --- a/src/routes/amt/powerCapabilities.ts +++ b/src/routes/amt/powerCapabilities.ts @@ -15,7 +15,7 @@ export async function powerCapabilities(req: Request, res: Response): Promise { sendEraseSpy.mockResolvedValue(undefined) await sendRPE(req, resSpy) - expect(sendEraseSpy).toHaveBeenCalledWith(0x4) + expect(sendEraseSpy).toHaveBeenCalledWith(0x4, undefined) expect(resSpy.status).toHaveBeenCalledWith(200) expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) }) @@ -60,7 +60,7 @@ describe('Send Remote Erase', () => { sendEraseSpy.mockResolvedValue(undefined) await sendRPE(req, resSpy) - expect(sendEraseSpy).toHaveBeenCalledWith(0) + expect(sendEraseSpy).toHaveBeenCalledWith(0, undefined) expect(resSpy.status).toHaveBeenCalledWith(200) }) From b9ac40de0bec245d90c2cc96099ebf3f7c9c9104 Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Tue, 30 Jun 2026 14:34:21 -0700 Subject: [PATCH 09/14] fix: formatting --- src/amt/deviceAction.test.ts | 1 + src/routes/amt/kvm/get.test.ts | 3 +-- src/routes/amt/powerCapabilities.test.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index 0b87538e7..b8bae9eac 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -382,6 +382,7 @@ describe('Device Action Tests', () => { expect(result).toEqual(chip.Envelope) }) }) + describe('boot capabilities and RPE', () => { it('should get boot capabilities', async () => { getSpy.mockResolvedValueOnce(bootCapabilities) diff --git a/src/routes/amt/kvm/get.test.ts b/src/routes/amt/kvm/get.test.ts index 14477f8c5..d243535a0 100644 --- a/src/routes/amt/kvm/get.test.ts +++ b/src/routes/amt/kvm/get.test.ts @@ -59,8 +59,7 @@ describe('getScreenSettingData', () => { it('should return mapped settings if KVM data exists', async () => { const screenData = { IPS_ScreenSettingDataItems: [ - { IsActive: [true], UpperLeftX: [1], UpperLeftY: [2], ResolutionX: [3], ResolutionY: [4] } - ] + { IsActive: [true], UpperLeftX: [1], UpperLeftY: [2], ResolutionX: [3], ResolutionY: [4] }] } const kvmData = { IPS_KVMRedirectionSettingData: { DefaultScreen: 0 } } req.deviceAction.getScreenSettingData.mockResolvedValue(screenData) diff --git a/src/routes/amt/powerCapabilities.test.ts b/src/routes/amt/powerCapabilities.test.ts index 4d3623237..f435d3fd0 100644 --- a/src/routes/amt/powerCapabilities.test.ts +++ b/src/routes/amt/powerCapabilities.test.ts @@ -67,7 +67,7 @@ describe('Power Capabilities', () => { 'Reset to PXE': 400, 'Power on to PXE': 401 } - vi.spyOn(device, 'getBootCapabilities').mockResolvedValue(powerCaps) + vi.spyOn(device, 'getPowerCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) @@ -96,7 +96,7 @@ describe('Power Capabilities', () => { versionResponse.CIM_SoftwareIdentity.responses = [ { InstanceID: 'AMT', IsEntity: 'true', VersionString: '9.0.0' } ] - vi.spyOn(device, 'getBootCapabilities').mockResolvedValue(powerCaps) + vi.spyOn(device, 'getPowerCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) @@ -140,7 +140,7 @@ describe('Power Capabilities', () => { expect(mqttSpy).toHaveBeenCalled() }) it('Should handle error', async () => { - vi.spyOn(device, 'getBootCapabilities').mockResolvedValue(null) + vi.spyOn(device, 'getPowerCapabilities').mockResolvedValue(null) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) From 57aa21c740c5cb3abe2837be29f7c435c9b759fe Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Wed, 5 Aug 2026 15:35:48 -0700 Subject: [PATCH 10/14] fix: allow CMSE clear with hardware clear --- src/amt/DeviceAction.ts | 96 ++++++++++++++++++------------- src/amt/deviceAction.test.ts | 100 +++++++++++++++++++++++++++++++++ src/routes/amt/sendRPE.test.ts | 14 ++++- src/routes/amt/sendRPE.ts | 9 +-- 4 files changed, 171 insertions(+), 48 deletions(-) diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index a48aa0f06..507b85efd 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -217,73 +217,89 @@ export class DeviceAction { // CSME sentinel bit: 0x10000 maps to ConfigurationDataReset, not a hardware erase target const CSME_BIT = 0x10000 - const csmeRequested = (eraseMask & CSME_BIT) !== 0 - const hwMask = eraseMask & ~CSME_BIT // strip the CSME bit for hardware TLV + const SECURE_ERASE_BIT = 0x4 + const wantCSMEReset = (eraseMask & CSME_BIT) !== 0 + const tlvMask = eraseMask & ~CSME_BIT + const wantSecureErase = (tlvMask & SECURE_ERASE_BIT) !== 0 + + const xmlIdleMode = this.cim.BootService.RequestStateChange(32768) + const idleResult = await this.ciraHandler.Send(this.ciraSocket, xmlIdleMode) + if (idleResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { + logger.error(`sendRPE RequestStateChange(32768) failed: ${JSON.stringify(idleResult?.Envelope?.Body)}`) + } - // Step 1: GET current boot settings and verify RPE - const bootOptions = await this.getBootOptions() - const current = bootOptions.AMT_BootSettingData + let bootOptions = await this.getBootOptions() + let current = bootOptions.AMT_BootSettingData const rpeEnabled = (current as any).RPE ?? current.RPEEnabled ?? current.PlatformErase if (!rpeEnabled) { - throw new Error('RPE is not enabled on this device') + await this.setRPE(true) + bootOptions = await this.getBootOptions() + current = bootOptions.AMT_BootSettingData } - // Step 1a: Clear boot source override (CSME path only) - if (csmeRequested) { + if (wantCSMEReset && tlvMask === 0) { await this.changeBootOrder() } - // Step 1b: Switch firmware to RPE mode BEFORE the PUT - // Required when boot service is in OCR mode (32769); must precede PUT const xmlRpeMode = this.cim.BootService.RequestStateChange(32770) const rscResult = await this.ciraHandler.Send(this.ciraSocket, xmlRpeMode) if (rscResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { logger.error(`sendRPE RequestStateChange(32770) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) } - // Step 2: Build minimal PUT body — only writable fields, no read-only fields. - // Read-only fields (BIOSLastStatus, BootguardStatus, RPEEnabled, SecureBootControlEnabled, - // UEFIHTTPSBootEnabled, UEFILocalPBABootEnabled, WinREBootEnabled, OptionsCleared) - // cause InvalidRepresentation if included. Use 'Uefi' (not 'UEFI') to match AMT XML element names. + // Step 2: Build a strict, schema-safe payload with deterministic values. + // Avoid carrying mutable values from GET (for example BIOSSetup=true) that can + // make PUT fail validation on some firmware generations. const putBody: any = { - ElementName: current.ElementName, InstanceID: current.InstanceID, + ElementName: current.ElementName, OwningEntity: current.OwningEntity, - BIOSPause: current.BIOSPause, - BIOSSetup: current.BIOSSetup, - BootMediaIndex: current.BootMediaIndex, - ConfigurationDataReset: csmeRequested, - EnforceSecureBoot: current.EnforceSecureBoot, - FirmwareVerbosity: current.FirmwareVerbosity, - ForcedProgressEvents: current.ForcedProgressEvents, - IDERBootDevice: current.IDERBootDevice, - LockKeyboard: current.LockKeyboard, - LockPowerButton: current.LockPowerButton, - LockResetButton: current.LockResetButton, - LockSleepButton: current.LockSleepButton, - PlatformErase: hwMask !== 0, - RSEPassword: current.RSEPassword, - ReflashBIOS: current.ReflashBIOS, - SecureErase: current.SecureErase, - UseIDER: current.UseIDER, - UseSOL: current.UseSOL, - UseSafeMode: current.UseSafeMode, - UserPasswordBypass: current.UserPasswordBypass + BIOSPause: false, + BIOSSetup: false, + BootMediaIndex: 0, + ConfigurationDataReset: wantCSMEReset, + FirmwareVerbosity: 0, + ForcedProgressEvents: false, + IDERBootDevice: 0, + LockKeyboard: false, + LockPowerButton: false, + LockResetButton: false, + LockSleepButton: false, + PlatformErase: tlvMask !== 0, + ReflashBIOS: false, + SecureErase: false, + UseIDER: false, + UseSOL: false, + UseSafeMode: false, + UserPasswordBypass: false } - if (hwMask !== 0) { + if (tlvMask !== 0) { + // AMT RPE expects an Intel TLV payload for erase targets. + // Parameter format: [vendor:0x8086][type:1][len:4][value:eraseMask] const buf = Buffer.alloc(12) - buf.writeUInt16LE(0x8086, 0) // Intel vendor prefix - buf.writeUInt16LE(1, 2) // ParameterTypeID = 1 - buf.writeUInt32LE(4, 4) // value length = 4 bytes - buf.writeUInt32LE(hwMask, 8) // device bitmask + buf.writeUInt16LE(0x8086, 0) + buf.writeUInt16LE(1, 2) + buf.writeUInt32LE(4, 4) + buf.writeUInt32LE(tlvMask, 8) putBody.UefiBootParametersArray = buf.toString('base64') putBody.UefiBootNumberOfParams = 1 + } else { + delete putBody.UefiBootParametersArray + delete putBody.UefiBootNumberOfParams } + // Remove conflicting schema variants and read-only fields if present. + delete putBody.UEFIBootParametersArray + delete putBody.UEFIBootNumberOfParams + delete putBody.RPEEnabled + delete putBody.OptionsCleared + delete putBody.BIOSLastStatus + const xmlPut = this.amt.BootSettingData.Put(putBody as AMT.Models.BootSettingData) const putResult = await this.ciraHandler.Send(this.ciraSocket, xmlPut) if (putResult?.Envelope?.Body?.Fault) { + logger.error(`sendRPE BootSettingData PUT XML: ${xmlPut}`) throw new Error(`BootSettingData PUT failed: ${JSON.stringify(putResult.Envelope.Body.Fault)}`) } diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index 66e21eee1..c21ddb8d3 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -405,6 +405,7 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) @@ -419,6 +420,7 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) @@ -426,6 +428,104 @@ describe('Device Action Tests', () => { expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() }) + it('should enable RPE before sending remote erase when it is disabled', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // setRPE(true) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + + await device.sendRPE(3) + + expect(sendSpy).toHaveBeenCalledTimes(5) + }) + it('should clear boot order only for CSME-only remote erase requests', async () => { + const changeBootOrderSpy = vi.spyOn(device, 'changeBootOrder').mockResolvedValue({}) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + + await device.sendRPE(0x10000) + + expect(changeBootOrderSpy).toHaveBeenCalledTimes(1) + }) + it('should preserve boot order for combined CSME and hardware remote erase requests', async () => { + const changeBootOrderSpy = vi.spyOn(device, 'changeBootOrder').mockResolvedValue({}) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + + await device.sendRPE(0x10004) + + expect(changeBootOrderSpy).not.toHaveBeenCalled() + }) + it('should serialize hardware erase mask with UEFI boot parameter elements', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + const putXml = sendSpy.mock.calls[2][1] as string + expect(putXml).toContain('UefiBootParametersArray') + expect(putXml).toContain('UefiBootNumberOfParams') + expect(putXml).not.toContain('UEFIBootParametersArray') + expect(putXml).not.toContain('UEFIBootNumberOfParams') + expect(putXml).toContain('hoABAAQAAAAEAAAA') + const paramMatches = putXml.match(//g) ?? [] + expect(paramMatches.length).toBe(1) + expect(putXml).toContain('1') + }) + it('should encode combined hardware erase bits as a single Uefi parameter mask', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + + await device.sendRPE(0x04000040) + + const putXml = sendSpy.mock.calls[2][1] as string + expect(putXml).toContain('hoABAAQAAABAAAAE') + expect(putXml).toContain('1') + expect(putXml).toContain('false') + expect(putXml).toContain('false') + }) }) describe('alarm occurrences', () => { it('should return null when enumerate call to getAlarmClockOccurrences fails', async () => { diff --git a/src/routes/amt/sendRPE.test.ts b/src/routes/amt/sendRPE.test.ts index 174c9188f..8b8268716 100644 --- a/src/routes/amt/sendRPE.test.ts +++ b/src/routes/amt/sendRPE.test.ts @@ -85,7 +85,17 @@ describe('Send Remote Erase', () => { ) }) - it('should return 400 when CSME is combined with hardware erase bits', async () => { + it('should send combined CSME and hardware erase when device supports all requested bits', async () => { + req.body = { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: true } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x10004 } } }) + sendEraseSpy.mockResolvedValue(undefined) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(0x10004, undefined) + expect(resSpy.status).toHaveBeenCalledWith(200) + }) + + it('should return 400 when combined request includes an unsupported capability bit', async () => { req.body = { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: true } bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) @@ -93,7 +103,7 @@ describe('Send Remote Erase', () => { expect(sendEraseSpy).not.toHaveBeenCalled() expect(resSpy.status).toHaveBeenCalledWith(400) expect(resSpy.json).toHaveBeenCalledWith( - ErrorResponse(400, 'CSME unconfigure cannot be combined with other erase operations') + ErrorResponse(400, 'Requested erase capabilities are not supported by this device') ) }) diff --git a/src/routes/amt/sendRPE.ts b/src/routes/amt/sendRPE.ts index 08736c1e7..ea9107bee 100644 --- a/src/routes/amt/sendRPE.ts +++ b/src/routes/amt/sendRPE.ts @@ -9,6 +9,8 @@ import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { MPSValidationError } from '../../utils/MPSValidationError.js' +const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 + export async function sendRPE(req: Request, res: Response): Promise { try { const guid: string = req.params.guid @@ -29,15 +31,10 @@ export async function sendRPE(req: Request, res: Response): Promise { throw new MPSValidationError('Device does not support Remote Platform Erase', 400) } - if (mask !== 0 && (platformEraseCaps & mask) === 0) { + if (mask !== 0 && (platformEraseCaps & mask) !== mask) { throw new MPSValidationError('Requested erase capabilities are not supported by this device', 400) } - const CSME_BIT = 0x10000 - if ((mask & CSME_BIT) !== 0 && (mask & ~CSME_BIT) !== 0) { - throw new MPSValidationError('CSME unconfigure cannot be combined with other erase operations', 400) - } - await req.deviceAction.sendRPE(mask, powerType) MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) From 0b2b7905bdb31cd5b427a5ca93a321d964989b3c Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Thu, 6 Aug 2026 08:50:32 -0700 Subject: [PATCH 11/14] feat: add SSD Password to RPE --- .mpsrc | 8 +- src/amt/DeviceAction.ts | 61 +++++-- src/amt/deviceAction.test.ts | 158 +++++++++++++++--- src/logging/messages.ts | 3 + src/routes/amt/getAMTFeatures.ts | 6 +- src/routes/amt/getBootCapabilities.test.ts | 2 +- src/routes/amt/getBootCapabilities.ts | 23 +-- src/routes/amt/getPowerState.test.ts | 16 +- src/routes/amt/index.ts | 7 +- src/routes/amt/rpeConstants.ts | 17 ++ src/routes/amt/sendRPE.test.ts | 9 +- src/routes/amt/sendRPE.ts | 20 ++- src/routes/amt/sendRPEValidator.test.ts | 133 +++++++++++++++ src/routes/amt/sendRPEValidator.ts | 28 ++++ src/routes/amt/setAMTFeatures.test.ts | 116 +++++++++++++ src/routes/amt/setAMTFeatures.ts | 52 ++++-- src/routes/amt/setRPE.test.ts | 73 -------- src/routes/amt/setRPE.ts | 39 ----- .../collections/MPS.postman_collection.json | 82 +-------- src/test/helper/wsmanResponses.ts | 2 +- swagger.yaml | 81 +++++---- 21 files changed, 612 insertions(+), 324 deletions(-) create mode 100644 src/routes/amt/rpeConstants.ts create mode 100644 src/routes/amt/sendRPEValidator.test.ts create mode 100644 src/routes/amt/sendRPEValidator.ts delete mode 100644 src/routes/amt/setRPE.test.ts delete mode 100644 src/routes/amt/setRPE.ts diff --git a/.mpsrc b/.mpsrc index d1bd617c0..941cbe090 100644 --- a/.mpsrc +++ b/.mpsrc @@ -1,5 +1,5 @@ { - "common_name": "10.72.4.39", + "common_name": "localhost", "port": 4433, "country": "US", "company": "NoCorp", @@ -7,8 +7,8 @@ "tls_offload": false, "web_port": 3000, "generate_certificates": true, - "web_admin_user": "standalone", - "web_admin_password": "G@ppm0ym", + "web_admin_user": "", + "web_admin_password": "", "web_auth_enabled": true, "vault_address": "http://localhost:8200", "vault_token": "myroot", @@ -18,7 +18,7 @@ "cert_format": "file", "data_path": "../private/data.json", "cert_path": "../private", - "jwt_secret": "myjwtsecret", + "jwt_secret": "", "jwt_issuer": "9EmRJTbIiIb4bIeSsmgcWIjrR6HyETqc", "jwt_expiration": "1440", "cors_origin": "*", diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 507b85efd..d62902ba3 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -8,6 +8,10 @@ import { type Selector } from '@device-management-toolkit/wsman-messages/WSMan.j import { logger, messages } from '../logging/index.js' import { Certificates, OCRData, type CIRASocket } from '../models/models.js' import { type CIRAHandler } from './CIRAHandler.js' +import { + BOOT_SERVICE_STATE_BOTH_OFF, + BOOT_SERVICE_STATE_RPE_ONLY +} from '../routes/amt/rpeConstants.js' export class DeviceAction { ciraHandler: CIRAHandler @@ -116,7 +120,7 @@ export class DeviceAction { return result.Envelope.Body } - async forceBootMode(role: CIM.Types.BootService.Role = 1): Promise { + async forceBootMode(role: CIM.Types.BootService.Role = 1): Promise { logger.silly(`forceBootMode ${messages.REQUEST}`) const bootSource = 'Intel(r) AMT: Boot Configuration 0' const xmlRequestBody = this.cim.BootService.SetBootConfigRole(bootSource, role) @@ -207,25 +211,26 @@ export class DeviceAction { logger.silly(`setRPE ${messages.REQUEST}`) const bootOptions = await this.getBootOptions() const current = bootOptions.AMT_BootSettingData - ;(current as any).RPE = isEnabled + // Set all known firmware variants of the RPE enable field for cross-generation compatibility. + if ('RPE' in (current as any)) (current as any).RPE = isEnabled + if ('RPEEnabled' in (current as any)) (current as any).RPEEnabled = isEnabled + if ('PlatformErase' in (current as any)) (current as any).PlatformErase = isEnabled await this.setBootConfiguration(current) logger.silly(`setRPE ${messages.COMPLETE}`) } - async sendRPE(eraseMask: number, powerType: number): Promise { + async sendRPE(eraseMask: number, ssdPassword?: string): Promise { logger.silly(`sendRPE ${messages.REQUEST}`) // CSME sentinel bit: 0x10000 maps to ConfigurationDataReset, not a hardware erase target const CSME_BIT = 0x10000 - const SECURE_ERASE_BIT = 0x4 const wantCSMEReset = (eraseMask & CSME_BIT) !== 0 const tlvMask = eraseMask & ~CSME_BIT - const wantSecureErase = (tlvMask & SECURE_ERASE_BIT) !== 0 - const xmlIdleMode = this.cim.BootService.RequestStateChange(32768) + const xmlIdleMode = this.cim.BootService.RequestStateChange(BOOT_SERVICE_STATE_BOTH_OFF) const idleResult = await this.ciraHandler.Send(this.ciraSocket, xmlIdleMode) if (idleResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { - logger.error(`sendRPE RequestStateChange(32768) failed: ${JSON.stringify(idleResult?.Envelope?.Body)}`) + throw new Error(`sendRPE RequestStateChange(${BOOT_SERVICE_STATE_BOTH_OFF}) failed: ${JSON.stringify(idleResult?.Envelope?.Body)}`) } let bootOptions = await this.getBootOptions() @@ -241,10 +246,10 @@ export class DeviceAction { await this.changeBootOrder() } - const xmlRpeMode = this.cim.BootService.RequestStateChange(32770) + const xmlRpeMode = this.cim.BootService.RequestStateChange(BOOT_SERVICE_STATE_RPE_ONLY) const rscResult = await this.ciraHandler.Send(this.ciraSocket, xmlRpeMode) if (rscResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { - logger.error(`sendRPE RequestStateChange(32770) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) + throw new Error(`sendRPE RequestStateChange(${BOOT_SERVICE_STATE_RPE_ONLY}) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) } // Step 2: Build a strict, schema-safe payload with deterministic values. @@ -284,6 +289,10 @@ export class DeviceAction { buf.writeUInt32LE(tlvMask, 8) putBody.UefiBootParametersArray = buf.toString('base64') putBody.UefiBootNumberOfParams = 1 + + if (ssdPassword != null && ssdPassword !== '') { + putBody.RSEPassword = ssdPassword + } } else { delete putBody.UefiBootParametersArray delete putBody.UefiBootNumberOfParams @@ -297,20 +306,40 @@ export class DeviceAction { delete putBody.BIOSLastStatus const xmlPut = this.amt.BootSettingData.Put(putBody as AMT.Models.BootSettingData) + if (process.env.MPS_RPE_LOG_REDACTED_XML != null && process.env.MPS_RPE_LOG_REDACTED_XML !== '') { + const redactedXml = xmlPut.replace(/()([\s\S]*?)(<\/h:RSEPassword>)/g, '$1***$3') + logger.info(`sendRPE BootSettingData PUT XML (redacted): ${redactedXml}`) + } const putResult = await this.ciraHandler.Send(this.ciraSocket, xmlPut) if (putResult?.Envelope?.Body?.Fault) { - logger.error(`sendRPE BootSettingData PUT XML: ${xmlPut}`) + logger.error(`sendRPE BootSettingData PUT failed: ${JSON.stringify(putResult.Envelope.Body.Fault)}`) throw new Error(`BootSettingData PUT failed: ${JSON.stringify(putResult.Envelope.Body.Fault)}`) } // Step 4: Activate boot configuration - await this.forceBootMode(1) + const forceBootResult = await this.forceBootMode(1) + if (forceBootResult?.Envelope?.Body?.SetBootConfigRole_OUTPUT?.ReturnValue !== 0) { + throw new Error(`sendRPE SetBootConfigRole failed: ${JSON.stringify(forceBootResult?.Envelope?.Body)}`) + } - // Step 5: Power Cycle Off Hard — S5→S0 required; warm reset keeps ME power rails active + // Step 5: Determine the appropriate power action by querying live power state. + // States treated as "off" → Power On (2): + // 6 = Off - Hard + // 7 = Hibernate (Off - Soft, S4) + // 8 = Off - Soft (S5) + // All other states (On=2, sleeping=3/4, unknown/null) → Master Bus Reset (10) + // so that a connected system is reliably rebooted into the erase sequence. + // Note: states 12 (Off-Soft Graceful) and 13 (Off-Hard Graceful) are transitional + // requested states, not reported current states; AMT settles to 6/7/8 once complete. const powerStateResult = await this.getPowerState() const currentState = powerStateResult?.PullResponse?.Items?.CIM_AssociatedPowerManagementService?.PowerState - const action = currentState === '8' ? 2 : 5 - await this.sendPowerAction(action as CIM.Types.PowerManagementService.PowerState) + const OFF_STATES = new Set([6, 7, 8]) + const action: CIM.Types.PowerManagementService.PowerState = OFF_STATES.has(Number(currentState)) ? 2 : 10 + logger.info(`sendRPE: dispatching power action=${action}`) + const powerActionResult = await this.sendPowerAction(action) + if (powerActionResult?.Body?.RequestPowerStateChange_OUTPUT?.ReturnValue !== 0) { + throw new Error(`sendRPE power action ${action} failed: ${JSON.stringify(powerActionResult?.Body)}`) + } logger.silly(`sendRPE ${messages.COMPLETE}`) } @@ -890,9 +919,7 @@ export class DeviceAction { return result?.Envelope ?? null } - async putWiFiPortConfigurationService( - data: AMT.Models.WiFiPortConfigurationService - ): Promise< + async putWiFiPortConfigurationService(data: AMT.Models.WiFiPortConfigurationService): Promise< | (Common.Models.Envelope<{ AMT_WiFiPortConfigurationService: AMT.Models.WiFiPortConfigurationService }> & { statusCode?: number }) diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index c21ddb8d3..1adb7e0c8 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -85,7 +85,7 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValue(serviceAvailableToElement) const result = await device.getPowerState() - expect(result.PullResponse.Items.CIM_AssociatedPowerManagementService.PowerState).toBe('4') + expect(result.PullResponse.Items.CIM_AssociatedPowerManagementService.PowerState).toBe(4) }) it('should send power action', async () => { getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) @@ -405,10 +405,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(3) expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() @@ -420,29 +420,43 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0) expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() }) it('should enable RPE before sending remote erase when it is disabled', async () => { + // Call order: + // Send#1: RequestStateChange(32768) + // Get#1: getBootOptions → RPE: false → triggers setRPE path + // Get#2: getBootOptions inside setRPE + // Send#2: setBootConfiguration inside setRPE + // Get#3: getBootOptions re-fetch after setRPE + // Send#3: RequestStateChange(32770) + // Send#4: BootSettingData.Put + // Send#5: forceBootMode(1) + // Enumerate + Pull: getPowerState + // Get#4: sendPowerAction + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } - }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // setRPE(true) + }) // initial getBootOptions → RPE disabled + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } + }) // getBootOptions inside setRPE + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // setBootConfiguration inside setRPE getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } - }) + }) // getBootOptions re-fetch after setRPE + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // BootSettingData.Put + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) await device.sendRPE(3) @@ -456,10 +470,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0x10000) @@ -473,10 +487,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0x10004) @@ -489,10 +503,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0x4) @@ -513,10 +527,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0x04000040) @@ -526,6 +540,98 @@ describe('Device Action Tests', () => { expect(putXml).toContain('false') expect(putXml).toContain('false') }) + it('should include RSEPassword when provided for encrypted SSD erase', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4, 'mypassword') + + const putXml = sendSpy.mock.calls[2][1] as string + expect(putXml).toContain('mypassword') + }) + it('should query live power state and send Power On (2) when system is off (state 8)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '8' (off) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 8 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(2) + }) + it('should query live power state and send Power On (2) when system is off-hard (state 6)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '6' (off-hard) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 6 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(2) + }) + it('should query live power state and send Master Bus Reset (10) when system is on (state 2)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '2' (on) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 2 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(10) + }) }) describe('alarm occurrences', () => { it('should return null when enumerate call to getAlarmClockOccurrences fails', async () => { diff --git a/src/logging/messages.ts b/src/logging/messages.ts index a1dfffa13..1caca00e0 100644 --- a/src/logging/messages.ts +++ b/src/logging/messages.ts @@ -186,6 +186,9 @@ export enum messages { POWER_CAPABILITIES_REQUESTED = 'Power Capabilities requested', POWER_CAPABILITIES_SUCCESS = 'Power Capabilities received', POWER_CAPABILITIES_EXCEPTION = 'Exception during Power Capabilities request', + BOOT_CAPABILITIES_REQUESTED = 'Boot Capabilities requested', + BOOT_CAPABILITIES_SUCCESS = 'Boot Capabilities received', + BOOT_CAPABILITIES_EXCEPTION = 'Exception during Boot Capabilities request', REDIRECT_FORWARD_DATA_EXCEPTION = 'Exception while forwarding data to client', REDIRECT_CLOSING_WEBSOCKET_EXCEPTION = 'Exception while closing client websocket connection', REDIRECT_OPENING_WEB_SOCKET = 'Opening web socket connection', diff --git a/src/routes/amt/getAMTFeatures.ts b/src/routes/amt/getAMTFeatures.ts index 5204f5a91..28487670b 100644 --- a/src/routes/amt/getAMTFeatures.ts +++ b/src/routes/amt/getAMTFeatures.ts @@ -11,6 +11,10 @@ import { MPSValidationError } from '../../utils/MPSValidationError.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { type AMT, type CIM, type IPS, Common } from '@device-management-toolkit/wsman-messages' import type { BootSettingResult, OCRData, OCRProcessResult } from '../../models/models.js' +import { + BOOT_SERVICE_STATE_OCR_ONLY, + BOOT_SERVICE_STATE_BOTH_ON +} from './rpeConstants.js' export async function getAMTFeatures(req: Request, res: Response): Promise { try { @@ -97,7 +101,7 @@ export function processOCRData(ocrData: OCRData): OCRProcessResult { const bootData = ocrData.bootData?.AMT_BootSettingData const bootSourceSettings = ocrData.bootSourceSettings - const isOCR = EnabledState === 32769 || EnabledState === 32771 + const isOCR = EnabledState === BOOT_SERVICE_STATE_OCR_ONLY || EnabledState === BOOT_SERVICE_STATE_BOTH_ON const bootSettings = findBootSettingInstances(bootSourceSettings) diff --git a/src/routes/amt/getBootCapabilities.test.ts b/src/routes/amt/getBootCapabilities.test.ts index 95c642cb5..453da3038 100644 --- a/src/routes/amt/getBootCapabilities.test.ts +++ b/src/routes/amt/getBootCapabilities.test.ts @@ -69,7 +69,7 @@ describe('Get Boot Capabilities', () => { await getBootCapabilities(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(500) - expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.POWER_CAPABILITIES_EXCEPTION)) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.BOOT_CAPABILITIES_EXCEPTION)) expect(resSpy.end).toHaveBeenCalled() }) }) diff --git a/src/routes/amt/getBootCapabilities.ts b/src/routes/amt/getBootCapabilities.ts index 328411009..e1e24f231 100644 --- a/src/routes/amt/getBootCapabilities.ts +++ b/src/routes/amt/getBootCapabilities.ts @@ -7,27 +7,28 @@ import { type Response, type Request } from 'express' import { logger, messages } from '../../logging/index.js' import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' - -const PLATFORM_ERASE_SSDS = 0x4 -const PLATFORM_ERASE_TPM_CLEAR = 0x40 -const PLATFORM_ERASE_BIOS_RESTORE = 0x4000000 -const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 +import { + PLATFORM_ERASE_ALL_SSDS, + PLATFORM_ERASE_TPM_CLEAR, + PLATFORM_ERASE_CSME_UNCONFIGURE, + PLATFORM_ERASE_BIOS_TO_EOM +} from './rpeConstants.js' export async function getBootCapabilities(req: Request, res: Response): Promise { try { const guid: string = req.params.guid - MqttProvider.publishEvent('request', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_REQUESTED, guid) + MqttProvider.publishEvent('request', ['AMT_BootCapabilities'], messages.BOOT_CAPABILITIES_REQUESTED, guid) const result = await req.deviceAction.getBootCapabilities() const capabilities = parsePlatformEraseCapabilities(result.Body?.AMT_BootCapabilities?.PlatformErase ?? 0) - MqttProvider.publishEvent('success', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_SUCCESS, guid) + MqttProvider.publishEvent('success', ['AMT_BootCapabilities'], messages.BOOT_CAPABILITIES_SUCCESS, guid) res.status(200).json(capabilities).end() } catch (error) { - logger.error(`${messages.POWER_CAPABILITIES_EXCEPTION} : ${error}`) + logger.error(`${messages.BOOT_CAPABILITIES_EXCEPTION} : ${error}`) MqttProvider.publishEvent('fail', ['AMT_BootCapabilities'], messages.INTERNAL_SERVICE_ERROR) - res.status(500).json(ErrorResponse(500, messages.POWER_CAPABILITIES_EXCEPTION)).end() + res.status(500).json(ErrorResponse(500, messages.BOOT_CAPABILITIES_EXCEPTION)).end() } } @@ -38,9 +39,9 @@ function parsePlatformEraseCapabilities(platformEraseMask: number): { unconfigureCSME: boolean } { return { - secureEraseAllSSDs: (platformEraseMask & PLATFORM_ERASE_SSDS) !== 0, + secureEraseAllSSDs: (platformEraseMask & PLATFORM_ERASE_ALL_SSDS) !== 0, tpmClear: (platformEraseMask & PLATFORM_ERASE_TPM_CLEAR) !== 0, - restoreBIOSToEOM: (platformEraseMask & PLATFORM_ERASE_BIOS_RESTORE) !== 0, + restoreBIOSToEOM: (platformEraseMask & PLATFORM_ERASE_BIOS_TO_EOM) !== 0, unconfigureCSME: (platformEraseMask & PLATFORM_ERASE_CSME_UNCONFIGURE) !== 0 } } diff --git a/src/routes/amt/getPowerState.test.ts b/src/routes/amt/getPowerState.test.ts index ab2691ec6..5c60f7f87 100644 --- a/src/routes/amt/getPowerState.test.ts +++ b/src/routes/amt/getPowerState.test.ts @@ -40,10 +40,10 @@ describe('power state', () => { IPS_PowerManagementService: { CreationClassName: 'IPS_PowerManagementService', ElementName: 'Intel(r) AMT Power Management Service', - EnabledState: '5', + EnabledState: 5, Name: 'Intel(r) AMT Power Management Service', - OSPowerSavingState: '3', - RequestedState: '12', + OSPowerSavingState: 3, + RequestedState: 12, SystemCreationClassName: 'CIM_ComputerSystem', SystemName: 'Intel(r) AMT' } @@ -57,21 +57,21 @@ describe('power state', () => { powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: '3' }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 3 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState throws an error', async () => { osPowerStateGetSpy.mockRejectedValueOnce(new Error('OS power saving state error')) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState returns null', async () => { osPowerStateGetSpy.mockResolvedValueOnce(null) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when OSPowerSavingState is missing in response', async () => { osPowerStateGetSpy.mockResolvedValueOnce({ @@ -86,14 +86,14 @@ describe('power state', () => { powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState times out', async () => { osPowerStateGetSpy.mockRejectedValueOnce(new TimeoutError(TIMEOUT_MESSAGE)) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get an error with status code 400, when get power state is null', async () => { powerStateSpy.mockResolvedValueOnce(null) diff --git a/src/routes/amt/index.ts b/src/routes/amt/index.ts index 6e58fa2bd..f69cddf3c 100644 --- a/src/routes/amt/index.ts +++ b/src/routes/amt/index.ts @@ -41,8 +41,8 @@ import { setKVMRedirectionSettingData } from './kvm/set.js' import { setLinkPreference } from './setLinkPreference.js' import { linkPreferenceValidator } from './linkPreferenceValidator.js' import { getBootCapabilities } from './getBootCapabilities.js' -import { setRPE } from './setRPE.js' import { sendRPE } from './sendRPE.js' +import { sendRPEValidator } from './sendRPEValidator.js' import { getNetworkSettings } from './networkSettings/getNetworkSettings.js' import { getWiredNetworkSettings } from './networkSettings/getWired.js' import { patchWiredNetworkSettings } from './networkSettings/patchWired.js' @@ -71,11 +71,8 @@ amtRouter.get('/power/capabilities/:guid', ciraMiddleware, powerCapabilities) amtRouter.get('/power/state/:guid', ciraMiddleware, powerState) amtRouter.get('/features/:guid', ciraMiddleware, getAMTFeatures) amtRouter.post('/features/:guid', amtFeaturesValidator(), validateMiddleware, ciraMiddleware, setAMTFeatures) -amtRouter.get('/boot/capabilities/:guid', ciraMiddleware, getBootCapabilities) -amtRouter.post('/boot/rpe/:guid', ciraMiddleware, setRPE) -amtRouter.post('/rpe/:guid', ciraMiddleware, sendRPE) amtRouter.get('/boot/remoteErase/:guid', ciraMiddleware, getBootCapabilities) -amtRouter.post('/boot/remoteErase/:guid', ciraMiddleware, sendRPE) +amtRouter.post('/boot/remoteErase/:guid', sendRPEValidator(), validateMiddleware, ciraMiddleware, sendRPE) amtRouter.get('/version/:guid', ciraMiddleware, version) amtRouter.delete('/deactivate/:guid', ciraMiddleware, deactivate) amtRouter.get('/power/bootSources/:guid', ciraMiddleware, bootSources) diff --git a/src/routes/amt/rpeConstants.ts b/src/routes/amt/rpeConstants.ts new file mode 100644 index 000000000..42af088ab --- /dev/null +++ b/src/routes/amt/rpeConstants.ts @@ -0,0 +1,17 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +export const PLATFORM_ERASE_ALL_SSDS = 0x4 +export const PLATFORM_ERASE_TPM_CLEAR = 0x40 +export const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 +export const PLATFORM_ERASE_BIOS_TO_EOM = 0x4000000 + +export const MAX_SSD_PASSWORD_LENGTH = 64 + +// CIM_BootService RequestedState / EnabledState values for OCR + RPE combinations +export const BOOT_SERVICE_STATE_BOTH_OFF = 32768 +export const BOOT_SERVICE_STATE_OCR_ONLY = 32769 +export const BOOT_SERVICE_STATE_RPE_ONLY = 32770 +export const BOOT_SERVICE_STATE_BOTH_ON = 32771 diff --git a/src/routes/amt/sendRPE.test.ts b/src/routes/amt/sendRPE.test.ts index 8b8268716..670a3a545 100644 --- a/src/routes/amt/sendRPE.test.ts +++ b/src/routes/amt/sendRPE.test.ts @@ -13,6 +13,10 @@ import { HttpHandler } from '../../amt/HttpHandler.js' import { messages } from '../../logging/index.js' import { vi, type MockInstance } from 'vitest' +import { + PLATFORM_ERASE_ALL_SSDS +} from './rpeConstants.js' + describe('Send Remote Erase', () => { let req: any let resSpy: any @@ -45,11 +49,12 @@ describe('Send Remote Erase', () => { }) it('should send remote erase when device supports the requested mask', async () => { + req.body.ssdPassword = 'mypassword' bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x44 } } }) sendEraseSpy.mockResolvedValue(undefined) await sendRPE(req, resSpy) - expect(sendEraseSpy).toHaveBeenCalledWith(0x4, undefined) + expect(sendEraseSpy).toHaveBeenCalledWith(PLATFORM_ERASE_ALL_SSDS, 'mypassword') expect(resSpy.status).toHaveBeenCalledWith(200) expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) }) @@ -97,7 +102,7 @@ describe('Send Remote Erase', () => { it('should return 400 when combined request includes an unsupported capability bit', async () => { req.body = { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: true } - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: PLATFORM_ERASE_ALL_SSDS } } }) await sendRPE(req, resSpy) expect(sendEraseSpy).not.toHaveBeenCalled() diff --git a/src/routes/amt/sendRPE.ts b/src/routes/amt/sendRPE.ts index ea9107bee..5a565a833 100644 --- a/src/routes/amt/sendRPE.ts +++ b/src/routes/amt/sendRPE.ts @@ -8,19 +8,23 @@ import { logger, messages } from '../../logging/index.js' import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { MPSValidationError } from '../../utils/MPSValidationError.js' - -const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 +import { + PLATFORM_ERASE_ALL_SSDS, + PLATFORM_ERASE_TPM_CLEAR, + PLATFORM_ERASE_CSME_UNCONFIGURE, + PLATFORM_ERASE_BIOS_TO_EOM +} from './rpeConstants.js' export async function sendRPE(req: Request, res: Response): Promise { try { const guid: string = req.params.guid - const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME, powerType } = req.body + const { secureEraseAllSSDs, ssdPassword, tpmClear, restoreBIOSToEOM, unconfigureCSME } = req.body const mask = - (secureEraseAllSSDs ? 0x4 : 0) | - (tpmClear ? 0x40 : 0) | - (restoreBIOSToEOM ? 0x4000000 : 0) | - (unconfigureCSME ? 0x10000 : 0) + (secureEraseAllSSDs ? PLATFORM_ERASE_ALL_SSDS : 0) | + (tpmClear ? PLATFORM_ERASE_TPM_CLEAR : 0) | + (restoreBIOSToEOM ? PLATFORM_ERASE_BIOS_TO_EOM : 0) | + (unconfigureCSME ? PLATFORM_ERASE_CSME_UNCONFIGURE : 0) MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) @@ -35,7 +39,7 @@ export async function sendRPE(req: Request, res: Response): Promise { throw new MPSValidationError('Requested erase capabilities are not supported by this device', 400) } - await req.deviceAction.sendRPE(mask, powerType) + await req.deviceAction.sendRPE(mask, ssdPassword) MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) res.status(200).json({ status: 'success' }).end() diff --git a/src/routes/amt/sendRPEValidator.test.ts b/src/routes/amt/sendRPEValidator.test.ts new file mode 100644 index 000000000..ea170c979 --- /dev/null +++ b/src/routes/amt/sendRPEValidator.test.ts @@ -0,0 +1,133 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { describe, expect, it } from 'vitest' +import { validationResult } from 'express-validator' +import { sendRPEValidator } from './sendRPEValidator.js' +import { MAX_SSD_PASSWORD_LENGTH } from './rpeConstants.js' + +async function getValidationErrors(body: any): Promise { + const req: any = { body, query: {}, params: {} } + const chains = sendRPEValidator() + for (const chain of chains) { + await chain.run(req) + } + return validationResult(req).array() +} + +describe('sendRPE validator', () => { + describe('valid requests', () => { + it('accepts a single boolean erase option', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: true }) + expect(errors).toHaveLength(0) + }) + + it('accepts multiple erase options', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + tpmClear: true, + restoreBIOSToEOM: false, + unconfigureCSME: false + }) + expect(errors).toHaveLength(0) + }) + + it('accepts CSME-only erase', async () => { + const errors = await getValidationErrors({ unconfigureCSME: true }) + expect(errors).toHaveLength(0) + }) + + it('accepts ssdPassword with secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'mypassword' + }) + expect(errors).toHaveLength(0) + }) + + it('accepts ssdPassword at exactly the max byte length', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'a'.repeat(MAX_SSD_PASSWORD_LENGTH) + }) + expect(errors).toHaveLength(0) + }) + + it('accepts omitted ssdPassword', async () => { + const errors = await getValidationErrors({ tpmClear: true }) + expect(errors).toHaveLength(0) + }) + }) + + describe('at-least-one-option requirement', () => { + it('rejects an empty body', async () => { + const errors = await getValidationErrors({}) + expect(errors.length).toBeGreaterThan(0) + expect(errors.some((e) => e.msg === 'At least one erase option must be enabled')).toBe(true) + }) + + it('rejects when all erase options are false', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: false, + tpmClear: false, + restoreBIOSToEOM: false, + unconfigureCSME: false + }) + expect(errors.some((e) => e.msg === 'At least one erase option must be enabled')).toBe(true) + }) + }) + + describe('boolean type enforcement', () => { + it('rejects string "true" for secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: 'true' }) + expect(errors.some((e) => e.path === 'secureEraseAllSSDs')).toBe(true) + }) + + it('rejects string "false" for secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: 'false', tpmClear: true }) + expect(errors.some((e) => e.path === 'secureEraseAllSSDs')).toBe(true) + }) + + it('rejects number 1 for tpmClear', async () => { + const errors = await getValidationErrors({ tpmClear: 1 }) + expect(errors.some((e) => e.path === 'tpmClear')).toBe(true) + }) + + it('rejects string for restoreBIOSToEOM', async () => { + const errors = await getValidationErrors({ restoreBIOSToEOM: 'yes', secureEraseAllSSDs: true }) + expect(errors.some((e) => e.path === 'restoreBIOSToEOM')).toBe(true) + }) + + it('rejects string for unconfigureCSME', async () => { + const errors = await getValidationErrors({ unconfigureCSME: '1', secureEraseAllSSDs: true }) + expect(errors.some((e) => e.path === 'unconfigureCSME')).toBe(true) + }) + }) + + describe('ssdPassword validation', () => { + it('rejects non-string ssdPassword', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: true, ssdPassword: 12345 }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + }) + + it('rejects ssdPassword exceeding max byte length', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'a'.repeat(MAX_SSD_PASSWORD_LENGTH + 1) + }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + expect(errors.some((e) => e.msg.includes(`${MAX_SSD_PASSWORD_LENGTH} bytes`))).toBe(true) + }) + + it('rejects multibyte password that exceeds 64 bytes despite being fewer characters', async () => { + // Each '€' is 3 bytes in UTF-8; 22 × 3 = 66 bytes > 64 + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: '€'.repeat(22) + }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + }) + }) +}) diff --git a/src/routes/amt/sendRPEValidator.ts b/src/routes/amt/sendRPEValidator.ts new file mode 100644 index 000000000..dd9435af7 --- /dev/null +++ b/src/routes/amt/sendRPEValidator.ts @@ -0,0 +1,28 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { body } from 'express-validator' +import { MAX_SSD_PASSWORD_LENGTH } from './rpeConstants.js' + +export const sendRPEValidator = (): any => [ + body('secureEraseAllSSDs').optional().isBoolean({ strict: true }).withMessage('secureEraseAllSSDs must be a boolean'), + body('tpmClear').optional().isBoolean({ strict: true }).withMessage('tpmClear must be a boolean'), + body('restoreBIOSToEOM').optional().isBoolean({ strict: true }).withMessage('restoreBIOSToEOM must be a boolean'), + body('unconfigureCSME').optional().isBoolean({ strict: true }).withMessage('unconfigureCSME must be a boolean'), + body('ssdPassword').optional().isString().withMessage('ssdPassword must be a string'), + body().custom((value) => { + const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME } = value + if (!secureEraseAllSSDs && !tpmClear && !restoreBIOSToEOM && !unconfigureCSME) { + throw new Error('At least one erase option must be enabled') + } + return true + }), + body('ssdPassword').optional().custom((value: string) => { + if (typeof value === 'string' && new TextEncoder().encode(value).length > MAX_SSD_PASSWORD_LENGTH) { + throw new Error(`SSD password must not exceed ${MAX_SSD_PASSWORD_LENGTH} bytes`) + } + return true + }) +] diff --git a/src/routes/amt/setAMTFeatures.test.ts b/src/routes/amt/setAMTFeatures.test.ts index f1905e2dd..a9e6b0825 100644 --- a/src/routes/amt/setAMTFeatures.test.ts +++ b/src/routes/amt/setAMTFeatures.test.ts @@ -22,6 +22,7 @@ describe('set amt features', () => { let putRedirectionServiceSpy: MockInstance let putIpsOptInServiceSpy: MockInstance let bootServiceStateChangeSpy: MockInstance + let getBootOptionsSpy: MockInstance let mqttSpy: MockInstance beforeEach(() => { @@ -58,6 +59,7 @@ describe('set amt features', () => { putRedirectionServiceSpy = vi.spyOn(device, 'putRedirectionService') putIpsOptInServiceSpy = vi.spyOn(device, 'putIpsOptInService') bootServiceStateChangeSpy = vi.spyOn(device, 'BootServiceStateChange') + getBootOptionsSpy = vi.spyOn(device, 'getBootOptions') mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') @@ -76,6 +78,7 @@ describe('set amt features', () => { putIpsOptInServiceSpy.mockResolvedValue({}) putRedirectionServiceSpy.mockResolvedValue({}) bootServiceStateChangeSpy.mockResolvedValue({}) + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) }) it('should set amt features - no change', async () => { @@ -208,4 +211,117 @@ describe('set amt features', () => { expect(resSpy.json).toHaveBeenCalled() expect(mqttSpy).toHaveBeenCalled() }) + + describe('platformEraseEnabled without ocr — preserves current OCR state', () => { + let getBootCapsSpy: MockInstance + let setRPESpy: MockInstance + let getOCRDataSpy: MockInstance + + beforeEach(() => { + // Remove ocr from body so the RPE-only branch is exercised + delete req.body.ocr + getBootCapsSpy = vi.spyOn(req.deviceAction, 'getBootCapabilities') + setRPESpy = vi.spyOn(req.deviceAction, 'setRPE') + getOCRDataSpy = vi.spyOn(req.deviceAction, 'getOCRData') + getBootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) + setRPESpy.mockResolvedValue(undefined) + }) + + it('enables RPE and preserves OCR-on → boot state 32771 (both)', async () => { + req.body.platformEraseEnabled = true + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32769 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(true) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32771) + }) + + it('enables RPE and preserves OCR-off → boot state 32770 (RPE only)', async () => { + req.body.platformEraseEnabled = true + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32768 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(true) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32770) + }) + + it('disables RPE and preserves OCR-on → boot state 32769 (OCR only)', async () => { + req.body.platformEraseEnabled = false + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32771 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(false) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32769) + }) + + it('disables RPE and preserves OCR-off → boot state 32768 (both off)', async () => { + req.body.platformEraseEnabled = false + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32768 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(false) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32768) + }) + + it('returns 400 when device does not support RPE', async () => { + req.body.platformEraseEnabled = true + getBootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(setRPESpy).not.toHaveBeenCalled() + expect(bootServiceStateChangeSpy).not.toHaveBeenCalled() + }) + }) + + describe('ocr without platformEraseEnabled — preserves current RPE state', () => { + it('enables OCR and preserves RPE-on → boot state 32771 (both)', async () => { + req.body.ocr = true + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: true } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32771) + }) + + it('enables OCR and preserves RPE-off → boot state 32769 (OCR only)', async () => { + req.body.ocr = true + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32769) + }) + + it('disables OCR and preserves RPE-on → boot state 32770 (RPE only)', async () => { + req.body.ocr = false + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: true } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32770) + }) + + it('disables OCR and preserves RPE-off → boot state 32768 (both off)', async () => { + req.body.ocr = false + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32768) + }) + }) }) diff --git a/src/routes/amt/setAMTFeatures.ts b/src/routes/amt/setAMTFeatures.ts index 77cafe759..4bad4bcf3 100644 --- a/src/routes/amt/setAMTFeatures.ts +++ b/src/routes/amt/setAMTFeatures.ts @@ -8,8 +8,15 @@ import { logger, messages } from '../../logging/index.js' import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { UserConsentOptions } from '../../utils/constants.js' +import { MPSValidationError } from '../../utils/MPSValidationError.js' import { type AMT, type IPS, Common } from '@device-management-toolkit/wsman-messages' import { type DeviceAction } from '../../amt/DeviceAction.js' +import { + BOOT_SERVICE_STATE_BOTH_OFF, + BOOT_SERVICE_STATE_OCR_ONLY, + BOOT_SERVICE_STATE_RPE_ONLY, + BOOT_SERVICE_STATE_BOTH_ON +} from './rpeConstants.js' export async function setAMTFeatures(req: Request, res: Response): Promise { try { @@ -85,25 +92,42 @@ export async function setAMTFeatures(req: Request, res: Response): Promise if (payload.platformEraseEnabled !== undefined) { const bootCaps = await req.deviceAction.getBootCapabilities() const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 - if (platformEraseCaps !== 0) { - rpeDesired = !!payload.platformEraseEnabled - await req.deviceAction.setRPE(rpeDesired) + if (platformEraseCaps === 0) { + throw new MPSValidationError('Device does not support Remote Platform Erase', 400) } + rpeDesired = !!payload.platformEraseEnabled + await req.deviceAction.setRPE(rpeDesired) } // Configure boot service state — combines OCR and RPE - // 32768 = both off, 32769 = OCR only, 32770 = RPE only, 32771 = both + // BOTH_OFF=32768, OCR_ONLY=32769, RPE_ONLY=32770, BOTH_ON=32771 if (payload.ocr !== undefined) { const ocrOn = !!payload.ocr - const rpeOn = rpeDesired ?? false - let requestedState = 32768 - if (ocrOn && rpeOn) requestedState = 32771 - else if (ocrOn) requestedState = 32769 - else if (rpeOn) requestedState = 32770 + // If platformEraseEnabled was not provided, read the current RPE state from the + // device so an OCR-only update does not inadvertently clear the RPE boot bit. + let rpeOn: boolean + if (rpeDesired !== undefined) { + rpeOn = rpeDesired + } else { + const bootOptions = await req.deviceAction.getBootOptions() + const current = bootOptions.AMT_BootSettingData + rpeOn = !!((current as any).RPE ?? current.RPEEnabled ?? current.PlatformErase) + } + let requestedState = BOOT_SERVICE_STATE_BOTH_OFF + if (ocrOn && rpeOn) requestedState = BOOT_SERVICE_STATE_BOTH_ON + else if (ocrOn) requestedState = BOOT_SERVICE_STATE_OCR_ONLY + else if (rpeOn) requestedState = BOOT_SERVICE_STATE_RPE_ONLY await req.deviceAction.BootServiceStateChange(requestedState) } else if (rpeDesired !== undefined) { - // OCR not in request — set RPE-only state (32770 enabled, 32768 disabled) - await req.deviceAction.BootServiceStateChange(rpeDesired ? 32770 : 32768) + // OCR not in request — read current OCR state so RPE-only update does not clear it. + const ocrData = await req.deviceAction.getOCRData() + const currentBootServiceState = ocrData.bootService?.CIM_BootService?.EnabledState + const ocrOn = currentBootServiceState === BOOT_SERVICE_STATE_OCR_ONLY || currentBootServiceState === BOOT_SERVICE_STATE_BOTH_ON + let requestedState = BOOT_SERVICE_STATE_BOTH_OFF + if (ocrOn && rpeDesired) requestedState = BOOT_SERVICE_STATE_BOTH_ON + else if (ocrOn) requestedState = BOOT_SERVICE_STATE_OCR_ONLY + else if (rpeDesired) requestedState = BOOT_SERVICE_STATE_RPE_ONLY + await req.deviceAction.BootServiceStateChange(requestedState) } MqttProvider.publishEvent('success', ['AMT_SetFeatures'], messages.AMT_FEATURES_SET_SUCCESS, guid) @@ -111,7 +135,11 @@ export async function setAMTFeatures(req: Request, res: Response): Promise } catch (error) { logger.error(`${messages.AMT_FEATURES_SET_EXCEPTION}: ${error}`) MqttProvider.publishEvent('fail', ['AMT_SetFeatures'], messages.INTERNAL_SERVICE_ERROR) - res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + if (error instanceof MPSValidationError) { + res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)).end() + } else { + res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + } } } export async function setRedirectionService( diff --git a/src/routes/amt/setRPE.test.ts b/src/routes/amt/setRPE.test.ts deleted file mode 100644 index 1952e3eaf..000000000 --- a/src/routes/amt/setRPE.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/********************************************************************* - * Copyright (c) Intel Corporation 2022 - * SPDX-License-Identifier: Apache-2.0 - **********************************************************************/ - -import { ErrorResponse } from '../../utils/amtHelper.js' -import { MqttProvider } from '../../utils/MqttProvider.js' -import { setRPE } from './setRPE.js' -import { createSpyObj } from '../../test/helper/vitest.js' -import { DeviceAction } from '../../amt/DeviceAction.js' -import { CIRAHandler } from '../../amt/CIRAHandler.js' -import { HttpHandler } from '../../amt/HttpHandler.js' -import { messages } from '../../logging/index.js' -import { vi, type MockInstance } from 'vitest' - -describe('Set RPE Enabled', () => { - let req: any - let resSpy: any - let mqttSpy: MockInstance - let bootCapsSpy: MockInstance - let setRPESpy: MockInstance - let device: DeviceAction - - beforeEach(() => { - const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') - device = new DeviceAction(handler, null) - req = { - params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, - body: { enabled: true }, - deviceAction: device - } - resSpy = createSpyObj('Response', [ - 'status', - 'json', - 'end', - 'send' - ]) - resSpy.status.mockReturnThis() - resSpy.json.mockReturnThis() - resSpy.send.mockReturnThis() - - mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') - bootCapsSpy = vi.spyOn(device, 'getBootCapabilities') - setRPESpy = vi.spyOn(device, 'setRPE') - }) - - it('should enable RPE when device supports platform erase', async () => { - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) - setRPESpy.mockResolvedValue(undefined) - - await setRPE(req, resSpy) - expect(setRPESpy).toHaveBeenCalledWith(true) - expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) - }) - - it('should return 400 when device does not support platform erase', async () => { - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) - - await setRPE(req, resSpy) - expect(setRPESpy).not.toHaveBeenCalled() - expect(resSpy.status).toHaveBeenCalledWith(400) - expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Device does not support Remote Platform Erase')) - }) - - it('should return 500 on unexpected error', async () => { - bootCapsSpy.mockRejectedValue(new Error('AMT error')) - - await setRPE(req, resSpy) - expect(resSpy.status).toHaveBeenCalledWith(500) - expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)) - }) -}) diff --git a/src/routes/amt/setRPE.ts b/src/routes/amt/setRPE.ts deleted file mode 100644 index 8a751e831..000000000 --- a/src/routes/amt/setRPE.ts +++ /dev/null @@ -1,39 +0,0 @@ -/********************************************************************* - * Copyright (c) Intel Corporation 2022 - * SPDX-License-Identifier: Apache-2.0 - **********************************************************************/ - -import { type Response, type Request } from 'express' -import { logger, messages } from '../../logging/index.js' -import { ErrorResponse } from '../../utils/amtHelper.js' -import { MqttProvider } from '../../utils/MqttProvider.js' -import { MPSValidationError } from '../../utils/MPSValidationError.js' - -export async function setRPE(req: Request, res: Response): Promise { - try { - const guid: string = req.params.guid - const { enabled } = req.body - - MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) - - const bootCaps = await req.deviceAction.getBootCapabilities() - const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 - - if (platformEraseCaps === 0) { - throw new MPSValidationError('Device does not support Remote Platform Erase', 400) - } - - await req.deviceAction.setRPE(!!enabled) - - MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) - res.status(200).json({ status: 'success' }).end() - } catch (error) { - logger.error(`setRPE failed: ${error}`) - if (error instanceof MPSValidationError) { - res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)) - } else { - MqttProvider.publishEvent('fail', ['AMT_BootSettingData'], messages.INTERNAL_SERVICE_ERROR) - res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() - } - } -} diff --git a/src/test/collections/MPS.postman_collection.json b/src/test/collections/MPS.postman_collection.json index a1274d586..84c57698a 100644 --- a/src/test/collections/MPS.postman_collection.json +++ b/src/test/collections/MPS.postman_collection.json @@ -3190,83 +3190,6 @@ }, "response": [] }, - { - "name": "Get Boot Capabilities", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 404\", function () {\r\n pm.response.to.have.status(404);\r\n});\r\n\r\npm.test(\"Device should not be found\", function () {\r\n var jsonData = pm.response.json();\r\n pm.expect(jsonData.error).to.eq(\"Device not found/connected. Please connect again using CIRA.\")\r\n pm.expect(jsonData.errorDescription).to.eq(\"guid : 1\")\r\n});\r\n" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/capabilities/1", - "protocol": "{{protocol}}", - "host": [ - "{{host}}" - ], - "path": [ - "api", - "v1", - "amt", - "boot", - "capabilities", - "1" - ] - } - }, - "response": [] - }, - { - "name": "Set RPE Enabled", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 404\", function () {\r\n pm.response.to.have.status(404);\r\n});\r\n\r\npm.test(\"Device should not be found\", function () {\r\n var jsonData = pm.response.json();\r\n pm.expect(jsonData.error).to.eq(\"Device not found/connected. Please connect again using CIRA.\")\r\n pm.expect(jsonData.errorDescription).to.eq(\"guid : 1\")\r\n});\r\n" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\r\n \"enabled\": true\r\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/rpe/1", - "protocol": "{{protocol}}", - "host": [ - "{{host}}" - ], - "path": [ - "api", - "v1", - "amt", - "boot", - "rpe", - "1" - ] - } - }, - "response": [] - }, { "name": "Send Remote Erase", "event": [ @@ -3293,7 +3216,7 @@ } }, "url": { - "raw": "{{protocol}}://{{host}}/api/v1/amt/rpe/1", + "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/remoteErase/1", "protocol": "{{protocol}}", "host": [ "{{host}}" @@ -3302,7 +3225,8 @@ "api", "v1", "amt", - "rpe", + "boot", + "remoteErase", "1" ] } diff --git a/src/test/helper/wsmanResponses.ts b/src/test/helper/wsmanResponses.ts index bffbbd4bf..1c911df18 100644 --- a/src/test/helper/wsmanResponses.ts +++ b/src/test/helper/wsmanResponses.ts @@ -54,7 +54,7 @@ export const serviceAvailableToElement = { '2', '5' ], - PowerState: '4', + PowerState: 4, ServiceProvided: { Address: 'http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous', ReferenceParameters: { diff --git a/swagger.yaml b/swagger.yaml index df9515146..67ef22739 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -299,13 +299,10 @@ paths: application/json: schema: $ref: '#/components/schemas/GetAMTFeaturesResponse' - /api/v1/amt/boot/rpe/{guid}: - post: - summary: Enable or Disable Remote Platform Erase (RPE) - description: | - Enables or disables the Remote Platform Erase feature on the specified AMT device. - RPE must be enabled before calling `POST /api/v1/amt/rpe/{guid}` to trigger an erase. - Returns 400 if the device does not support RPE. + /api/v1/amt/boot/remoteErase/{guid}: + get: + summary: Get Remote Platform Erase capabilities + description: Returns the Remote Platform Erase capabilities supported by the device as individual boolean flags. tags: - AMT parameters: @@ -316,39 +313,31 @@ paths: required: true schema: type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SetRPERequest' responses: 200: - description: 'RPE enabled/disabled successfully' + description: 'Remote Platform Erase capabilities supported by this device' content: application/json: schema: - $ref: '#/components/schemas/RPEStatusResponse' - 400: - description: 'Device does not support Remote Platform Erase' + $ref: '#/components/schemas/RemoteEraseCapabilitiesResponse' 404: description: 'Device not found/connected' 500: description: 'Internal server error' - /api/v1/amt/rpe/{guid}: post: summary: Trigger Remote Platform Erase description: | Initiates a Remote Platform Erase on the specified AMT device. - RPE must be enabled on the device first via `POST /api/v1/amt/boot/rpe/{guid}`. + RPE must be enabled on the device first via `POST /api/v1/amt/features/{guid}`. - The `eraseMask` is a bitmask of erase targets to activate: - - `0x0001` — Non-volatile memory - - `0x0002` — Volatile memory - - `0x10000` — CSME unconfigure (cannot be combined with other bits) - - `0` — Platform erase without specific hardware target + Supported erase options (select one or more hardware bits, optionally combined with CSME): + - `secureEraseAllSSDs` — Secure erase all SSDs (bit 0x04) + - `tpmClear` — Clear TPM (bit 0x40) + - `restoreBIOSToEOM` — Reload BIOS golden configuration (bit 0x4000000) + - `unconfigureCSME` — CSME unconfigure; can be combined with hardware bits + - `ssdPassword` — Optional password for encrypted SSD erase (max 64 bytes) - Returns 400 if the device does not support RPE, if the requested mask is not supported, or if CSME is combined with other erase operations. + Returns 400 if the device does not support RPE or the requested options are not supported. tags: - AMT parameters: @@ -364,7 +353,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SendRPERequest' + $ref: '#/components/schemas/RemoteEraseRequest' responses: 200: description: 'Remote Platform Erase initiated successfully' @@ -373,7 +362,7 @@ paths: schema: $ref: '#/components/schemas/RPEStatusResponse' 400: - description: 'Device does not support RPE, requested mask not supported, or invalid mask combination' + description: 'Device does not support RPE, requested options not supported, or password too long' 404: description: 'Device not found/connected' 500: @@ -3408,6 +3397,9 @@ components: type: boolean ocr: type: boolean + platformEraseEnabled: + type: boolean + description: Enable or disable Remote Platform Erase (RPE). Only applied when the device reports PlatformErase capability. SetAMTFeaturesResponse: type: string example: @@ -4550,18 +4542,28 @@ components: structuredBiosBootString: type: string example: '' - SetRPERequest: - title: SetRPERequest - required: - - enabled + RemoteEraseCapabilitiesResponse: + title: RemoteEraseCapabilitiesResponse properties: - enabled: + secureEraseAllSSDs: + type: boolean + description: 'Device supports secure erase of all SSDs (bit 0x04)' + tpmClear: + type: boolean + description: 'Device supports TPM clear (bit 0x40)' + restoreBIOSToEOM: type: boolean - description: Set to true to enable RPE, false to disable + description: 'Device supports restoring BIOS to end-of-manufacturing state (bit 0x4000000)' + unconfigureCSME: + type: boolean + description: 'Device supports CSME unconfigure / ConfigurationDataReset (bit 0x10000)' example: - enabled: true - SendRPERequest: - title: SendRPERequest + secureEraseAllSSDs: true + tpmClear: true + restoreBIOSToEOM: false + unconfigureCSME: false + RemoteEraseRequest: + title: RemoteEraseRequest properties: secureEraseAllSSDs: type: boolean @@ -4574,12 +4576,17 @@ components: description: 'Bit 26 (0x4000000) — Reload BIOS golden configuration' unconfigureCSME: type: boolean - description: 'CSME unconfigure — sets ConfigurationDataReset; cannot be combined with other erase operations' + description: 'CSME unconfigure — sets ConfigurationDataReset; can be combined with hardware bits' + ssdPassword: + type: string + description: 'Optional password for encrypted SSD erase (max 64 bytes)' + maxLength: 64 example: secureEraseAllSSDs: true tpmClear: false restoreBIOSToEOM: false unconfigureCSME: false + ssdPassword: '' RPEStatusResponse: title: RPEStatusResponse properties: From eff353acef3bbcdc8c798b3a1c8170702e672735 Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Fri, 7 Aug 2026 10:00:35 -0700 Subject: [PATCH 12/14] fix: AI comment --- src/amt/DeviceAction.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index d62902ba3..98623ba5c 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -323,17 +323,16 @@ export class DeviceAction { } // Step 5: Determine the appropriate power action by querying live power state. - // States treated as "off" → Power On (2): + // Off states (aligned with Console) → Power On (2): // 6 = Off - Hard - // 7 = Hibernate (Off - Soft, S4) // 8 = Off - Soft (S5) - // All other states (On=2, sleeping=3/4, unknown/null) → Master Bus Reset (10) + // 12 = Off - Soft Graceful + // 13 = Off - Hard Graceful + // All other states (On=2, sleeping=3/4/7, unknown/null) → Master Bus Reset (10) // so that a connected system is reliably rebooted into the erase sequence. - // Note: states 12 (Off-Soft Graceful) and 13 (Off-Hard Graceful) are transitional - // requested states, not reported current states; AMT settles to 6/7/8 once complete. const powerStateResult = await this.getPowerState() const currentState = powerStateResult?.PullResponse?.Items?.CIM_AssociatedPowerManagementService?.PowerState - const OFF_STATES = new Set([6, 7, 8]) + const OFF_STATES = new Set([6, 8, 12, 13]) const action: CIM.Types.PowerManagementService.PowerState = OFF_STATES.has(Number(currentState)) ? 2 : 10 logger.info(`sendRPE: dispatching power action=${action}`) const powerActionResult = await this.sendPowerAction(action) From 0c4a10e4ec0d2ad3eb8d2b8f8d7a607877d0e9a5 Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Thu, 6 Aug 2026 08:50:32 -0700 Subject: [PATCH 13/14] feat: add SSD Password to RPE --- .mpsrc | 8 +- src/amt/DeviceAction.ts | 60 +++++-- src/amt/deviceAction.test.ts | 158 +++++++++++++++--- src/logging/messages.ts | 3 + src/routes/amt/getAMTFeatures.ts | 6 +- src/routes/amt/getBootCapabilities.test.ts | 2 +- src/routes/amt/getBootCapabilities.ts | 23 +-- src/routes/amt/getPowerState.test.ts | 16 +- src/routes/amt/index.ts | 7 +- src/routes/amt/rpeConstants.ts | 17 ++ src/routes/amt/sendRPE.test.ts | 9 +- src/routes/amt/sendRPE.ts | 20 ++- src/routes/amt/sendRPEValidator.test.ts | 133 +++++++++++++++ src/routes/amt/sendRPEValidator.ts | 28 ++++ src/routes/amt/setAMTFeatures.test.ts | 116 +++++++++++++ src/routes/amt/setAMTFeatures.ts | 52 ++++-- src/routes/amt/setRPE.test.ts | 73 -------- src/routes/amt/setRPE.ts | 39 ----- .../collections/MPS.postman_collection.json | 82 +-------- src/test/helper/wsmanResponses.ts | 2 +- swagger.yaml | 81 +++++---- 21 files changed, 611 insertions(+), 324 deletions(-) create mode 100644 src/routes/amt/rpeConstants.ts create mode 100644 src/routes/amt/sendRPEValidator.test.ts create mode 100644 src/routes/amt/sendRPEValidator.ts delete mode 100644 src/routes/amt/setRPE.test.ts delete mode 100644 src/routes/amt/setRPE.ts diff --git a/.mpsrc b/.mpsrc index d1bd617c0..941cbe090 100644 --- a/.mpsrc +++ b/.mpsrc @@ -1,5 +1,5 @@ { - "common_name": "10.72.4.39", + "common_name": "localhost", "port": 4433, "country": "US", "company": "NoCorp", @@ -7,8 +7,8 @@ "tls_offload": false, "web_port": 3000, "generate_certificates": true, - "web_admin_user": "standalone", - "web_admin_password": "G@ppm0ym", + "web_admin_user": "", + "web_admin_password": "", "web_auth_enabled": true, "vault_address": "http://localhost:8200", "vault_token": "myroot", @@ -18,7 +18,7 @@ "cert_format": "file", "data_path": "../private/data.json", "cert_path": "../private", - "jwt_secret": "myjwtsecret", + "jwt_secret": "", "jwt_issuer": "9EmRJTbIiIb4bIeSsmgcWIjrR6HyETqc", "jwt_expiration": "1440", "cors_origin": "*", diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 507b85efd..98623ba5c 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -8,6 +8,10 @@ import { type Selector } from '@device-management-toolkit/wsman-messages/WSMan.j import { logger, messages } from '../logging/index.js' import { Certificates, OCRData, type CIRASocket } from '../models/models.js' import { type CIRAHandler } from './CIRAHandler.js' +import { + BOOT_SERVICE_STATE_BOTH_OFF, + BOOT_SERVICE_STATE_RPE_ONLY +} from '../routes/amt/rpeConstants.js' export class DeviceAction { ciraHandler: CIRAHandler @@ -116,7 +120,7 @@ export class DeviceAction { return result.Envelope.Body } - async forceBootMode(role: CIM.Types.BootService.Role = 1): Promise { + async forceBootMode(role: CIM.Types.BootService.Role = 1): Promise { logger.silly(`forceBootMode ${messages.REQUEST}`) const bootSource = 'Intel(r) AMT: Boot Configuration 0' const xmlRequestBody = this.cim.BootService.SetBootConfigRole(bootSource, role) @@ -207,25 +211,26 @@ export class DeviceAction { logger.silly(`setRPE ${messages.REQUEST}`) const bootOptions = await this.getBootOptions() const current = bootOptions.AMT_BootSettingData - ;(current as any).RPE = isEnabled + // Set all known firmware variants of the RPE enable field for cross-generation compatibility. + if ('RPE' in (current as any)) (current as any).RPE = isEnabled + if ('RPEEnabled' in (current as any)) (current as any).RPEEnabled = isEnabled + if ('PlatformErase' in (current as any)) (current as any).PlatformErase = isEnabled await this.setBootConfiguration(current) logger.silly(`setRPE ${messages.COMPLETE}`) } - async sendRPE(eraseMask: number, powerType: number): Promise { + async sendRPE(eraseMask: number, ssdPassword?: string): Promise { logger.silly(`sendRPE ${messages.REQUEST}`) // CSME sentinel bit: 0x10000 maps to ConfigurationDataReset, not a hardware erase target const CSME_BIT = 0x10000 - const SECURE_ERASE_BIT = 0x4 const wantCSMEReset = (eraseMask & CSME_BIT) !== 0 const tlvMask = eraseMask & ~CSME_BIT - const wantSecureErase = (tlvMask & SECURE_ERASE_BIT) !== 0 - const xmlIdleMode = this.cim.BootService.RequestStateChange(32768) + const xmlIdleMode = this.cim.BootService.RequestStateChange(BOOT_SERVICE_STATE_BOTH_OFF) const idleResult = await this.ciraHandler.Send(this.ciraSocket, xmlIdleMode) if (idleResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { - logger.error(`sendRPE RequestStateChange(32768) failed: ${JSON.stringify(idleResult?.Envelope?.Body)}`) + throw new Error(`sendRPE RequestStateChange(${BOOT_SERVICE_STATE_BOTH_OFF}) failed: ${JSON.stringify(idleResult?.Envelope?.Body)}`) } let bootOptions = await this.getBootOptions() @@ -241,10 +246,10 @@ export class DeviceAction { await this.changeBootOrder() } - const xmlRpeMode = this.cim.BootService.RequestStateChange(32770) + const xmlRpeMode = this.cim.BootService.RequestStateChange(BOOT_SERVICE_STATE_RPE_ONLY) const rscResult = await this.ciraHandler.Send(this.ciraSocket, xmlRpeMode) if (rscResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { - logger.error(`sendRPE RequestStateChange(32770) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) + throw new Error(`sendRPE RequestStateChange(${BOOT_SERVICE_STATE_RPE_ONLY}) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) } // Step 2: Build a strict, schema-safe payload with deterministic values. @@ -284,6 +289,10 @@ export class DeviceAction { buf.writeUInt32LE(tlvMask, 8) putBody.UefiBootParametersArray = buf.toString('base64') putBody.UefiBootNumberOfParams = 1 + + if (ssdPassword != null && ssdPassword !== '') { + putBody.RSEPassword = ssdPassword + } } else { delete putBody.UefiBootParametersArray delete putBody.UefiBootNumberOfParams @@ -297,20 +306,39 @@ export class DeviceAction { delete putBody.BIOSLastStatus const xmlPut = this.amt.BootSettingData.Put(putBody as AMT.Models.BootSettingData) + if (process.env.MPS_RPE_LOG_REDACTED_XML != null && process.env.MPS_RPE_LOG_REDACTED_XML !== '') { + const redactedXml = xmlPut.replace(/()([\s\S]*?)(<\/h:RSEPassword>)/g, '$1***$3') + logger.info(`sendRPE BootSettingData PUT XML (redacted): ${redactedXml}`) + } const putResult = await this.ciraHandler.Send(this.ciraSocket, xmlPut) if (putResult?.Envelope?.Body?.Fault) { - logger.error(`sendRPE BootSettingData PUT XML: ${xmlPut}`) + logger.error(`sendRPE BootSettingData PUT failed: ${JSON.stringify(putResult.Envelope.Body.Fault)}`) throw new Error(`BootSettingData PUT failed: ${JSON.stringify(putResult.Envelope.Body.Fault)}`) } // Step 4: Activate boot configuration - await this.forceBootMode(1) + const forceBootResult = await this.forceBootMode(1) + if (forceBootResult?.Envelope?.Body?.SetBootConfigRole_OUTPUT?.ReturnValue !== 0) { + throw new Error(`sendRPE SetBootConfigRole failed: ${JSON.stringify(forceBootResult?.Envelope?.Body)}`) + } - // Step 5: Power Cycle Off Hard — S5→S0 required; warm reset keeps ME power rails active + // Step 5: Determine the appropriate power action by querying live power state. + // Off states (aligned with Console) → Power On (2): + // 6 = Off - Hard + // 8 = Off - Soft (S5) + // 12 = Off - Soft Graceful + // 13 = Off - Hard Graceful + // All other states (On=2, sleeping=3/4/7, unknown/null) → Master Bus Reset (10) + // so that a connected system is reliably rebooted into the erase sequence. const powerStateResult = await this.getPowerState() const currentState = powerStateResult?.PullResponse?.Items?.CIM_AssociatedPowerManagementService?.PowerState - const action = currentState === '8' ? 2 : 5 - await this.sendPowerAction(action as CIM.Types.PowerManagementService.PowerState) + const OFF_STATES = new Set([6, 8, 12, 13]) + const action: CIM.Types.PowerManagementService.PowerState = OFF_STATES.has(Number(currentState)) ? 2 : 10 + logger.info(`sendRPE: dispatching power action=${action}`) + const powerActionResult = await this.sendPowerAction(action) + if (powerActionResult?.Body?.RequestPowerStateChange_OUTPUT?.ReturnValue !== 0) { + throw new Error(`sendRPE power action ${action} failed: ${JSON.stringify(powerActionResult?.Body)}`) + } logger.silly(`sendRPE ${messages.COMPLETE}`) } @@ -890,9 +918,7 @@ export class DeviceAction { return result?.Envelope ?? null } - async putWiFiPortConfigurationService( - data: AMT.Models.WiFiPortConfigurationService - ): Promise< + async putWiFiPortConfigurationService(data: AMT.Models.WiFiPortConfigurationService): Promise< | (Common.Models.Envelope<{ AMT_WiFiPortConfigurationService: AMT.Models.WiFiPortConfigurationService }> & { statusCode?: number }) diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index c21ddb8d3..1adb7e0c8 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -85,7 +85,7 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValue(serviceAvailableToElement) const result = await device.getPowerState() - expect(result.PullResponse.Items.CIM_AssociatedPowerManagementService.PowerState).toBe('4') + expect(result.PullResponse.Items.CIM_AssociatedPowerManagementService.PowerState).toBe(4) }) it('should send power action', async () => { getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) @@ -405,10 +405,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(3) expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() @@ -420,29 +420,43 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0) expect(getSpy).toHaveBeenCalled() expect(sendSpy).toHaveBeenCalled() }) it('should enable RPE before sending remote erase when it is disabled', async () => { + // Call order: + // Send#1: RequestStateChange(32768) + // Get#1: getBootOptions → RPE: false → triggers setRPE path + // Get#2: getBootOptions inside setRPE + // Send#2: setBootConfiguration inside setRPE + // Get#3: getBootOptions re-fetch after setRPE + // Send#3: RequestStateChange(32770) + // Send#4: BootSettingData.Put + // Send#5: forceBootMode(1) + // Enumerate + Pull: getPowerState + // Get#4: sendPowerAction + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } - }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // setRPE(true) + }) // initial getBootOptions → RPE disabled + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } + }) // getBootOptions inside setRPE + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // setBootConfiguration inside setRPE getSpy.mockResolvedValueOnce({ Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } - }) + }) // getBootOptions re-fetch after setRPE + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // BootSettingData.Put + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) await device.sendRPE(3) @@ -456,10 +470,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0x10000) @@ -473,10 +487,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0x10004) @@ -489,10 +503,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0x4) @@ -513,10 +527,10 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValueOnce(serviceAvailableToElement) getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32768) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) - sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // forceBootMode(1) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) await device.sendRPE(0x04000040) @@ -526,6 +540,98 @@ describe('Device Action Tests', () => { expect(putXml).toContain('false') expect(putXml).toContain('false') }) + it('should include RSEPassword when provided for encrypted SSD erase', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4, 'mypassword') + + const putXml = sendSpy.mock.calls[2][1] as string + expect(putXml).toContain('mypassword') + }) + it('should query live power state and send Power On (2) when system is off (state 8)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '8' (off) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 8 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(2) + }) + it('should query live power state and send Power On (2) when system is off-hard (state 6)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '6' (off-hard) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 6 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(2) + }) + it('should query live power state and send Master Bus Reset (10) when system is on (state 2)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '2' (on) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 2 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(10) + }) }) describe('alarm occurrences', () => { it('should return null when enumerate call to getAlarmClockOccurrences fails', async () => { diff --git a/src/logging/messages.ts b/src/logging/messages.ts index a1dfffa13..1caca00e0 100644 --- a/src/logging/messages.ts +++ b/src/logging/messages.ts @@ -186,6 +186,9 @@ export enum messages { POWER_CAPABILITIES_REQUESTED = 'Power Capabilities requested', POWER_CAPABILITIES_SUCCESS = 'Power Capabilities received', POWER_CAPABILITIES_EXCEPTION = 'Exception during Power Capabilities request', + BOOT_CAPABILITIES_REQUESTED = 'Boot Capabilities requested', + BOOT_CAPABILITIES_SUCCESS = 'Boot Capabilities received', + BOOT_CAPABILITIES_EXCEPTION = 'Exception during Boot Capabilities request', REDIRECT_FORWARD_DATA_EXCEPTION = 'Exception while forwarding data to client', REDIRECT_CLOSING_WEBSOCKET_EXCEPTION = 'Exception while closing client websocket connection', REDIRECT_OPENING_WEB_SOCKET = 'Opening web socket connection', diff --git a/src/routes/amt/getAMTFeatures.ts b/src/routes/amt/getAMTFeatures.ts index 5204f5a91..28487670b 100644 --- a/src/routes/amt/getAMTFeatures.ts +++ b/src/routes/amt/getAMTFeatures.ts @@ -11,6 +11,10 @@ import { MPSValidationError } from '../../utils/MPSValidationError.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { type AMT, type CIM, type IPS, Common } from '@device-management-toolkit/wsman-messages' import type { BootSettingResult, OCRData, OCRProcessResult } from '../../models/models.js' +import { + BOOT_SERVICE_STATE_OCR_ONLY, + BOOT_SERVICE_STATE_BOTH_ON +} from './rpeConstants.js' export async function getAMTFeatures(req: Request, res: Response): Promise { try { @@ -97,7 +101,7 @@ export function processOCRData(ocrData: OCRData): OCRProcessResult { const bootData = ocrData.bootData?.AMT_BootSettingData const bootSourceSettings = ocrData.bootSourceSettings - const isOCR = EnabledState === 32769 || EnabledState === 32771 + const isOCR = EnabledState === BOOT_SERVICE_STATE_OCR_ONLY || EnabledState === BOOT_SERVICE_STATE_BOTH_ON const bootSettings = findBootSettingInstances(bootSourceSettings) diff --git a/src/routes/amt/getBootCapabilities.test.ts b/src/routes/amt/getBootCapabilities.test.ts index 95c642cb5..453da3038 100644 --- a/src/routes/amt/getBootCapabilities.test.ts +++ b/src/routes/amt/getBootCapabilities.test.ts @@ -69,7 +69,7 @@ describe('Get Boot Capabilities', () => { await getBootCapabilities(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(500) - expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.POWER_CAPABILITIES_EXCEPTION)) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.BOOT_CAPABILITIES_EXCEPTION)) expect(resSpy.end).toHaveBeenCalled() }) }) diff --git a/src/routes/amt/getBootCapabilities.ts b/src/routes/amt/getBootCapabilities.ts index 328411009..e1e24f231 100644 --- a/src/routes/amt/getBootCapabilities.ts +++ b/src/routes/amt/getBootCapabilities.ts @@ -7,27 +7,28 @@ import { type Response, type Request } from 'express' import { logger, messages } from '../../logging/index.js' import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' - -const PLATFORM_ERASE_SSDS = 0x4 -const PLATFORM_ERASE_TPM_CLEAR = 0x40 -const PLATFORM_ERASE_BIOS_RESTORE = 0x4000000 -const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 +import { + PLATFORM_ERASE_ALL_SSDS, + PLATFORM_ERASE_TPM_CLEAR, + PLATFORM_ERASE_CSME_UNCONFIGURE, + PLATFORM_ERASE_BIOS_TO_EOM +} from './rpeConstants.js' export async function getBootCapabilities(req: Request, res: Response): Promise { try { const guid: string = req.params.guid - MqttProvider.publishEvent('request', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_REQUESTED, guid) + MqttProvider.publishEvent('request', ['AMT_BootCapabilities'], messages.BOOT_CAPABILITIES_REQUESTED, guid) const result = await req.deviceAction.getBootCapabilities() const capabilities = parsePlatformEraseCapabilities(result.Body?.AMT_BootCapabilities?.PlatformErase ?? 0) - MqttProvider.publishEvent('success', ['AMT_BootCapabilities'], messages.POWER_CAPABILITIES_SUCCESS, guid) + MqttProvider.publishEvent('success', ['AMT_BootCapabilities'], messages.BOOT_CAPABILITIES_SUCCESS, guid) res.status(200).json(capabilities).end() } catch (error) { - logger.error(`${messages.POWER_CAPABILITIES_EXCEPTION} : ${error}`) + logger.error(`${messages.BOOT_CAPABILITIES_EXCEPTION} : ${error}`) MqttProvider.publishEvent('fail', ['AMT_BootCapabilities'], messages.INTERNAL_SERVICE_ERROR) - res.status(500).json(ErrorResponse(500, messages.POWER_CAPABILITIES_EXCEPTION)).end() + res.status(500).json(ErrorResponse(500, messages.BOOT_CAPABILITIES_EXCEPTION)).end() } } @@ -38,9 +39,9 @@ function parsePlatformEraseCapabilities(platformEraseMask: number): { unconfigureCSME: boolean } { return { - secureEraseAllSSDs: (platformEraseMask & PLATFORM_ERASE_SSDS) !== 0, + secureEraseAllSSDs: (platformEraseMask & PLATFORM_ERASE_ALL_SSDS) !== 0, tpmClear: (platformEraseMask & PLATFORM_ERASE_TPM_CLEAR) !== 0, - restoreBIOSToEOM: (platformEraseMask & PLATFORM_ERASE_BIOS_RESTORE) !== 0, + restoreBIOSToEOM: (platformEraseMask & PLATFORM_ERASE_BIOS_TO_EOM) !== 0, unconfigureCSME: (platformEraseMask & PLATFORM_ERASE_CSME_UNCONFIGURE) !== 0 } } diff --git a/src/routes/amt/getPowerState.test.ts b/src/routes/amt/getPowerState.test.ts index ab2691ec6..5c60f7f87 100644 --- a/src/routes/amt/getPowerState.test.ts +++ b/src/routes/amt/getPowerState.test.ts @@ -40,10 +40,10 @@ describe('power state', () => { IPS_PowerManagementService: { CreationClassName: 'IPS_PowerManagementService', ElementName: 'Intel(r) AMT Power Management Service', - EnabledState: '5', + EnabledState: 5, Name: 'Intel(r) AMT Power Management Service', - OSPowerSavingState: '3', - RequestedState: '12', + OSPowerSavingState: 3, + RequestedState: 12, SystemCreationClassName: 'CIM_ComputerSystem', SystemName: 'Intel(r) AMT' } @@ -57,21 +57,21 @@ describe('power state', () => { powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: '3' }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 3 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState throws an error', async () => { osPowerStateGetSpy.mockRejectedValueOnce(new Error('OS power saving state error')) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState returns null', async () => { osPowerStateGetSpy.mockResolvedValueOnce(null) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when OSPowerSavingState is missing in response', async () => { osPowerStateGetSpy.mockResolvedValueOnce({ @@ -86,14 +86,14 @@ describe('power state', () => { powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState times out', async () => { osPowerStateGetSpy.mockRejectedValueOnce(new TimeoutError(TIMEOUT_MESSAGE)) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get an error with status code 400, when get power state is null', async () => { powerStateSpy.mockResolvedValueOnce(null) diff --git a/src/routes/amt/index.ts b/src/routes/amt/index.ts index 6e58fa2bd..f69cddf3c 100644 --- a/src/routes/amt/index.ts +++ b/src/routes/amt/index.ts @@ -41,8 +41,8 @@ import { setKVMRedirectionSettingData } from './kvm/set.js' import { setLinkPreference } from './setLinkPreference.js' import { linkPreferenceValidator } from './linkPreferenceValidator.js' import { getBootCapabilities } from './getBootCapabilities.js' -import { setRPE } from './setRPE.js' import { sendRPE } from './sendRPE.js' +import { sendRPEValidator } from './sendRPEValidator.js' import { getNetworkSettings } from './networkSettings/getNetworkSettings.js' import { getWiredNetworkSettings } from './networkSettings/getWired.js' import { patchWiredNetworkSettings } from './networkSettings/patchWired.js' @@ -71,11 +71,8 @@ amtRouter.get('/power/capabilities/:guid', ciraMiddleware, powerCapabilities) amtRouter.get('/power/state/:guid', ciraMiddleware, powerState) amtRouter.get('/features/:guid', ciraMiddleware, getAMTFeatures) amtRouter.post('/features/:guid', amtFeaturesValidator(), validateMiddleware, ciraMiddleware, setAMTFeatures) -amtRouter.get('/boot/capabilities/:guid', ciraMiddleware, getBootCapabilities) -amtRouter.post('/boot/rpe/:guid', ciraMiddleware, setRPE) -amtRouter.post('/rpe/:guid', ciraMiddleware, sendRPE) amtRouter.get('/boot/remoteErase/:guid', ciraMiddleware, getBootCapabilities) -amtRouter.post('/boot/remoteErase/:guid', ciraMiddleware, sendRPE) +amtRouter.post('/boot/remoteErase/:guid', sendRPEValidator(), validateMiddleware, ciraMiddleware, sendRPE) amtRouter.get('/version/:guid', ciraMiddleware, version) amtRouter.delete('/deactivate/:guid', ciraMiddleware, deactivate) amtRouter.get('/power/bootSources/:guid', ciraMiddleware, bootSources) diff --git a/src/routes/amt/rpeConstants.ts b/src/routes/amt/rpeConstants.ts new file mode 100644 index 000000000..42af088ab --- /dev/null +++ b/src/routes/amt/rpeConstants.ts @@ -0,0 +1,17 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +export const PLATFORM_ERASE_ALL_SSDS = 0x4 +export const PLATFORM_ERASE_TPM_CLEAR = 0x40 +export const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 +export const PLATFORM_ERASE_BIOS_TO_EOM = 0x4000000 + +export const MAX_SSD_PASSWORD_LENGTH = 64 + +// CIM_BootService RequestedState / EnabledState values for OCR + RPE combinations +export const BOOT_SERVICE_STATE_BOTH_OFF = 32768 +export const BOOT_SERVICE_STATE_OCR_ONLY = 32769 +export const BOOT_SERVICE_STATE_RPE_ONLY = 32770 +export const BOOT_SERVICE_STATE_BOTH_ON = 32771 diff --git a/src/routes/amt/sendRPE.test.ts b/src/routes/amt/sendRPE.test.ts index 8b8268716..670a3a545 100644 --- a/src/routes/amt/sendRPE.test.ts +++ b/src/routes/amt/sendRPE.test.ts @@ -13,6 +13,10 @@ import { HttpHandler } from '../../amt/HttpHandler.js' import { messages } from '../../logging/index.js' import { vi, type MockInstance } from 'vitest' +import { + PLATFORM_ERASE_ALL_SSDS +} from './rpeConstants.js' + describe('Send Remote Erase', () => { let req: any let resSpy: any @@ -45,11 +49,12 @@ describe('Send Remote Erase', () => { }) it('should send remote erase when device supports the requested mask', async () => { + req.body.ssdPassword = 'mypassword' bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x44 } } }) sendEraseSpy.mockResolvedValue(undefined) await sendRPE(req, resSpy) - expect(sendEraseSpy).toHaveBeenCalledWith(0x4, undefined) + expect(sendEraseSpy).toHaveBeenCalledWith(PLATFORM_ERASE_ALL_SSDS, 'mypassword') expect(resSpy.status).toHaveBeenCalledWith(200) expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) }) @@ -97,7 +102,7 @@ describe('Send Remote Erase', () => { it('should return 400 when combined request includes an unsupported capability bit', async () => { req.body = { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: true } - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: PLATFORM_ERASE_ALL_SSDS } } }) await sendRPE(req, resSpy) expect(sendEraseSpy).not.toHaveBeenCalled() diff --git a/src/routes/amt/sendRPE.ts b/src/routes/amt/sendRPE.ts index ea9107bee..5a565a833 100644 --- a/src/routes/amt/sendRPE.ts +++ b/src/routes/amt/sendRPE.ts @@ -8,19 +8,23 @@ import { logger, messages } from '../../logging/index.js' import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { MPSValidationError } from '../../utils/MPSValidationError.js' - -const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 +import { + PLATFORM_ERASE_ALL_SSDS, + PLATFORM_ERASE_TPM_CLEAR, + PLATFORM_ERASE_CSME_UNCONFIGURE, + PLATFORM_ERASE_BIOS_TO_EOM +} from './rpeConstants.js' export async function sendRPE(req: Request, res: Response): Promise { try { const guid: string = req.params.guid - const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME, powerType } = req.body + const { secureEraseAllSSDs, ssdPassword, tpmClear, restoreBIOSToEOM, unconfigureCSME } = req.body const mask = - (secureEraseAllSSDs ? 0x4 : 0) | - (tpmClear ? 0x40 : 0) | - (restoreBIOSToEOM ? 0x4000000 : 0) | - (unconfigureCSME ? 0x10000 : 0) + (secureEraseAllSSDs ? PLATFORM_ERASE_ALL_SSDS : 0) | + (tpmClear ? PLATFORM_ERASE_TPM_CLEAR : 0) | + (restoreBIOSToEOM ? PLATFORM_ERASE_BIOS_TO_EOM : 0) | + (unconfigureCSME ? PLATFORM_ERASE_CSME_UNCONFIGURE : 0) MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) @@ -35,7 +39,7 @@ export async function sendRPE(req: Request, res: Response): Promise { throw new MPSValidationError('Requested erase capabilities are not supported by this device', 400) } - await req.deviceAction.sendRPE(mask, powerType) + await req.deviceAction.sendRPE(mask, ssdPassword) MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) res.status(200).json({ status: 'success' }).end() diff --git a/src/routes/amt/sendRPEValidator.test.ts b/src/routes/amt/sendRPEValidator.test.ts new file mode 100644 index 000000000..ea170c979 --- /dev/null +++ b/src/routes/amt/sendRPEValidator.test.ts @@ -0,0 +1,133 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { describe, expect, it } from 'vitest' +import { validationResult } from 'express-validator' +import { sendRPEValidator } from './sendRPEValidator.js' +import { MAX_SSD_PASSWORD_LENGTH } from './rpeConstants.js' + +async function getValidationErrors(body: any): Promise { + const req: any = { body, query: {}, params: {} } + const chains = sendRPEValidator() + for (const chain of chains) { + await chain.run(req) + } + return validationResult(req).array() +} + +describe('sendRPE validator', () => { + describe('valid requests', () => { + it('accepts a single boolean erase option', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: true }) + expect(errors).toHaveLength(0) + }) + + it('accepts multiple erase options', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + tpmClear: true, + restoreBIOSToEOM: false, + unconfigureCSME: false + }) + expect(errors).toHaveLength(0) + }) + + it('accepts CSME-only erase', async () => { + const errors = await getValidationErrors({ unconfigureCSME: true }) + expect(errors).toHaveLength(0) + }) + + it('accepts ssdPassword with secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'mypassword' + }) + expect(errors).toHaveLength(0) + }) + + it('accepts ssdPassword at exactly the max byte length', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'a'.repeat(MAX_SSD_PASSWORD_LENGTH) + }) + expect(errors).toHaveLength(0) + }) + + it('accepts omitted ssdPassword', async () => { + const errors = await getValidationErrors({ tpmClear: true }) + expect(errors).toHaveLength(0) + }) + }) + + describe('at-least-one-option requirement', () => { + it('rejects an empty body', async () => { + const errors = await getValidationErrors({}) + expect(errors.length).toBeGreaterThan(0) + expect(errors.some((e) => e.msg === 'At least one erase option must be enabled')).toBe(true) + }) + + it('rejects when all erase options are false', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: false, + tpmClear: false, + restoreBIOSToEOM: false, + unconfigureCSME: false + }) + expect(errors.some((e) => e.msg === 'At least one erase option must be enabled')).toBe(true) + }) + }) + + describe('boolean type enforcement', () => { + it('rejects string "true" for secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: 'true' }) + expect(errors.some((e) => e.path === 'secureEraseAllSSDs')).toBe(true) + }) + + it('rejects string "false" for secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: 'false', tpmClear: true }) + expect(errors.some((e) => e.path === 'secureEraseAllSSDs')).toBe(true) + }) + + it('rejects number 1 for tpmClear', async () => { + const errors = await getValidationErrors({ tpmClear: 1 }) + expect(errors.some((e) => e.path === 'tpmClear')).toBe(true) + }) + + it('rejects string for restoreBIOSToEOM', async () => { + const errors = await getValidationErrors({ restoreBIOSToEOM: 'yes', secureEraseAllSSDs: true }) + expect(errors.some((e) => e.path === 'restoreBIOSToEOM')).toBe(true) + }) + + it('rejects string for unconfigureCSME', async () => { + const errors = await getValidationErrors({ unconfigureCSME: '1', secureEraseAllSSDs: true }) + expect(errors.some((e) => e.path === 'unconfigureCSME')).toBe(true) + }) + }) + + describe('ssdPassword validation', () => { + it('rejects non-string ssdPassword', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: true, ssdPassword: 12345 }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + }) + + it('rejects ssdPassword exceeding max byte length', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'a'.repeat(MAX_SSD_PASSWORD_LENGTH + 1) + }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + expect(errors.some((e) => e.msg.includes(`${MAX_SSD_PASSWORD_LENGTH} bytes`))).toBe(true) + }) + + it('rejects multibyte password that exceeds 64 bytes despite being fewer characters', async () => { + // Each '€' is 3 bytes in UTF-8; 22 × 3 = 66 bytes > 64 + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: '€'.repeat(22) + }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + }) + }) +}) diff --git a/src/routes/amt/sendRPEValidator.ts b/src/routes/amt/sendRPEValidator.ts new file mode 100644 index 000000000..dd9435af7 --- /dev/null +++ b/src/routes/amt/sendRPEValidator.ts @@ -0,0 +1,28 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { body } from 'express-validator' +import { MAX_SSD_PASSWORD_LENGTH } from './rpeConstants.js' + +export const sendRPEValidator = (): any => [ + body('secureEraseAllSSDs').optional().isBoolean({ strict: true }).withMessage('secureEraseAllSSDs must be a boolean'), + body('tpmClear').optional().isBoolean({ strict: true }).withMessage('tpmClear must be a boolean'), + body('restoreBIOSToEOM').optional().isBoolean({ strict: true }).withMessage('restoreBIOSToEOM must be a boolean'), + body('unconfigureCSME').optional().isBoolean({ strict: true }).withMessage('unconfigureCSME must be a boolean'), + body('ssdPassword').optional().isString().withMessage('ssdPassword must be a string'), + body().custom((value) => { + const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME } = value + if (!secureEraseAllSSDs && !tpmClear && !restoreBIOSToEOM && !unconfigureCSME) { + throw new Error('At least one erase option must be enabled') + } + return true + }), + body('ssdPassword').optional().custom((value: string) => { + if (typeof value === 'string' && new TextEncoder().encode(value).length > MAX_SSD_PASSWORD_LENGTH) { + throw new Error(`SSD password must not exceed ${MAX_SSD_PASSWORD_LENGTH} bytes`) + } + return true + }) +] diff --git a/src/routes/amt/setAMTFeatures.test.ts b/src/routes/amt/setAMTFeatures.test.ts index f1905e2dd..a9e6b0825 100644 --- a/src/routes/amt/setAMTFeatures.test.ts +++ b/src/routes/amt/setAMTFeatures.test.ts @@ -22,6 +22,7 @@ describe('set amt features', () => { let putRedirectionServiceSpy: MockInstance let putIpsOptInServiceSpy: MockInstance let bootServiceStateChangeSpy: MockInstance + let getBootOptionsSpy: MockInstance let mqttSpy: MockInstance beforeEach(() => { @@ -58,6 +59,7 @@ describe('set amt features', () => { putRedirectionServiceSpy = vi.spyOn(device, 'putRedirectionService') putIpsOptInServiceSpy = vi.spyOn(device, 'putIpsOptInService') bootServiceStateChangeSpy = vi.spyOn(device, 'BootServiceStateChange') + getBootOptionsSpy = vi.spyOn(device, 'getBootOptions') mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') @@ -76,6 +78,7 @@ describe('set amt features', () => { putIpsOptInServiceSpy.mockResolvedValue({}) putRedirectionServiceSpy.mockResolvedValue({}) bootServiceStateChangeSpy.mockResolvedValue({}) + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) }) it('should set amt features - no change', async () => { @@ -208,4 +211,117 @@ describe('set amt features', () => { expect(resSpy.json).toHaveBeenCalled() expect(mqttSpy).toHaveBeenCalled() }) + + describe('platformEraseEnabled without ocr — preserves current OCR state', () => { + let getBootCapsSpy: MockInstance + let setRPESpy: MockInstance + let getOCRDataSpy: MockInstance + + beforeEach(() => { + // Remove ocr from body so the RPE-only branch is exercised + delete req.body.ocr + getBootCapsSpy = vi.spyOn(req.deviceAction, 'getBootCapabilities') + setRPESpy = vi.spyOn(req.deviceAction, 'setRPE') + getOCRDataSpy = vi.spyOn(req.deviceAction, 'getOCRData') + getBootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) + setRPESpy.mockResolvedValue(undefined) + }) + + it('enables RPE and preserves OCR-on → boot state 32771 (both)', async () => { + req.body.platformEraseEnabled = true + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32769 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(true) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32771) + }) + + it('enables RPE and preserves OCR-off → boot state 32770 (RPE only)', async () => { + req.body.platformEraseEnabled = true + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32768 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(true) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32770) + }) + + it('disables RPE and preserves OCR-on → boot state 32769 (OCR only)', async () => { + req.body.platformEraseEnabled = false + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32771 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(false) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32769) + }) + + it('disables RPE and preserves OCR-off → boot state 32768 (both off)', async () => { + req.body.platformEraseEnabled = false + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32768 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(false) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32768) + }) + + it('returns 400 when device does not support RPE', async () => { + req.body.platformEraseEnabled = true + getBootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(setRPESpy).not.toHaveBeenCalled() + expect(bootServiceStateChangeSpy).not.toHaveBeenCalled() + }) + }) + + describe('ocr without platformEraseEnabled — preserves current RPE state', () => { + it('enables OCR and preserves RPE-on → boot state 32771 (both)', async () => { + req.body.ocr = true + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: true } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32771) + }) + + it('enables OCR and preserves RPE-off → boot state 32769 (OCR only)', async () => { + req.body.ocr = true + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32769) + }) + + it('disables OCR and preserves RPE-on → boot state 32770 (RPE only)', async () => { + req.body.ocr = false + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: true } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32770) + }) + + it('disables OCR and preserves RPE-off → boot state 32768 (both off)', async () => { + req.body.ocr = false + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32768) + }) + }) }) diff --git a/src/routes/amt/setAMTFeatures.ts b/src/routes/amt/setAMTFeatures.ts index 77cafe759..4bad4bcf3 100644 --- a/src/routes/amt/setAMTFeatures.ts +++ b/src/routes/amt/setAMTFeatures.ts @@ -8,8 +8,15 @@ import { logger, messages } from '../../logging/index.js' import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { UserConsentOptions } from '../../utils/constants.js' +import { MPSValidationError } from '../../utils/MPSValidationError.js' import { type AMT, type IPS, Common } from '@device-management-toolkit/wsman-messages' import { type DeviceAction } from '../../amt/DeviceAction.js' +import { + BOOT_SERVICE_STATE_BOTH_OFF, + BOOT_SERVICE_STATE_OCR_ONLY, + BOOT_SERVICE_STATE_RPE_ONLY, + BOOT_SERVICE_STATE_BOTH_ON +} from './rpeConstants.js' export async function setAMTFeatures(req: Request, res: Response): Promise { try { @@ -85,25 +92,42 @@ export async function setAMTFeatures(req: Request, res: Response): Promise if (payload.platformEraseEnabled !== undefined) { const bootCaps = await req.deviceAction.getBootCapabilities() const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 - if (platformEraseCaps !== 0) { - rpeDesired = !!payload.platformEraseEnabled - await req.deviceAction.setRPE(rpeDesired) + if (platformEraseCaps === 0) { + throw new MPSValidationError('Device does not support Remote Platform Erase', 400) } + rpeDesired = !!payload.platformEraseEnabled + await req.deviceAction.setRPE(rpeDesired) } // Configure boot service state — combines OCR and RPE - // 32768 = both off, 32769 = OCR only, 32770 = RPE only, 32771 = both + // BOTH_OFF=32768, OCR_ONLY=32769, RPE_ONLY=32770, BOTH_ON=32771 if (payload.ocr !== undefined) { const ocrOn = !!payload.ocr - const rpeOn = rpeDesired ?? false - let requestedState = 32768 - if (ocrOn && rpeOn) requestedState = 32771 - else if (ocrOn) requestedState = 32769 - else if (rpeOn) requestedState = 32770 + // If platformEraseEnabled was not provided, read the current RPE state from the + // device so an OCR-only update does not inadvertently clear the RPE boot bit. + let rpeOn: boolean + if (rpeDesired !== undefined) { + rpeOn = rpeDesired + } else { + const bootOptions = await req.deviceAction.getBootOptions() + const current = bootOptions.AMT_BootSettingData + rpeOn = !!((current as any).RPE ?? current.RPEEnabled ?? current.PlatformErase) + } + let requestedState = BOOT_SERVICE_STATE_BOTH_OFF + if (ocrOn && rpeOn) requestedState = BOOT_SERVICE_STATE_BOTH_ON + else if (ocrOn) requestedState = BOOT_SERVICE_STATE_OCR_ONLY + else if (rpeOn) requestedState = BOOT_SERVICE_STATE_RPE_ONLY await req.deviceAction.BootServiceStateChange(requestedState) } else if (rpeDesired !== undefined) { - // OCR not in request — set RPE-only state (32770 enabled, 32768 disabled) - await req.deviceAction.BootServiceStateChange(rpeDesired ? 32770 : 32768) + // OCR not in request — read current OCR state so RPE-only update does not clear it. + const ocrData = await req.deviceAction.getOCRData() + const currentBootServiceState = ocrData.bootService?.CIM_BootService?.EnabledState + const ocrOn = currentBootServiceState === BOOT_SERVICE_STATE_OCR_ONLY || currentBootServiceState === BOOT_SERVICE_STATE_BOTH_ON + let requestedState = BOOT_SERVICE_STATE_BOTH_OFF + if (ocrOn && rpeDesired) requestedState = BOOT_SERVICE_STATE_BOTH_ON + else if (ocrOn) requestedState = BOOT_SERVICE_STATE_OCR_ONLY + else if (rpeDesired) requestedState = BOOT_SERVICE_STATE_RPE_ONLY + await req.deviceAction.BootServiceStateChange(requestedState) } MqttProvider.publishEvent('success', ['AMT_SetFeatures'], messages.AMT_FEATURES_SET_SUCCESS, guid) @@ -111,7 +135,11 @@ export async function setAMTFeatures(req: Request, res: Response): Promise } catch (error) { logger.error(`${messages.AMT_FEATURES_SET_EXCEPTION}: ${error}`) MqttProvider.publishEvent('fail', ['AMT_SetFeatures'], messages.INTERNAL_SERVICE_ERROR) - res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + if (error instanceof MPSValidationError) { + res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)).end() + } else { + res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + } } } export async function setRedirectionService( diff --git a/src/routes/amt/setRPE.test.ts b/src/routes/amt/setRPE.test.ts deleted file mode 100644 index 1952e3eaf..000000000 --- a/src/routes/amt/setRPE.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/********************************************************************* - * Copyright (c) Intel Corporation 2022 - * SPDX-License-Identifier: Apache-2.0 - **********************************************************************/ - -import { ErrorResponse } from '../../utils/amtHelper.js' -import { MqttProvider } from '../../utils/MqttProvider.js' -import { setRPE } from './setRPE.js' -import { createSpyObj } from '../../test/helper/vitest.js' -import { DeviceAction } from '../../amt/DeviceAction.js' -import { CIRAHandler } from '../../amt/CIRAHandler.js' -import { HttpHandler } from '../../amt/HttpHandler.js' -import { messages } from '../../logging/index.js' -import { vi, type MockInstance } from 'vitest' - -describe('Set RPE Enabled', () => { - let req: any - let resSpy: any - let mqttSpy: MockInstance - let bootCapsSpy: MockInstance - let setRPESpy: MockInstance - let device: DeviceAction - - beforeEach(() => { - const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') - device = new DeviceAction(handler, null) - req = { - params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, - body: { enabled: true }, - deviceAction: device - } - resSpy = createSpyObj('Response', [ - 'status', - 'json', - 'end', - 'send' - ]) - resSpy.status.mockReturnThis() - resSpy.json.mockReturnThis() - resSpy.send.mockReturnThis() - - mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') - bootCapsSpy = vi.spyOn(device, 'getBootCapabilities') - setRPESpy = vi.spyOn(device, 'setRPE') - }) - - it('should enable RPE when device supports platform erase', async () => { - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 3 } } }) - setRPESpy.mockResolvedValue(undefined) - - await setRPE(req, resSpy) - expect(setRPESpy).toHaveBeenCalledWith(true) - expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) - }) - - it('should return 400 when device does not support platform erase', async () => { - bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) - - await setRPE(req, resSpy) - expect(setRPESpy).not.toHaveBeenCalled() - expect(resSpy.status).toHaveBeenCalledWith(400) - expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Device does not support Remote Platform Erase')) - }) - - it('should return 500 on unexpected error', async () => { - bootCapsSpy.mockRejectedValue(new Error('AMT error')) - - await setRPE(req, resSpy) - expect(resSpy.status).toHaveBeenCalledWith(500) - expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)) - }) -}) diff --git a/src/routes/amt/setRPE.ts b/src/routes/amt/setRPE.ts deleted file mode 100644 index 8a751e831..000000000 --- a/src/routes/amt/setRPE.ts +++ /dev/null @@ -1,39 +0,0 @@ -/********************************************************************* - * Copyright (c) Intel Corporation 2022 - * SPDX-License-Identifier: Apache-2.0 - **********************************************************************/ - -import { type Response, type Request } from 'express' -import { logger, messages } from '../../logging/index.js' -import { ErrorResponse } from '../../utils/amtHelper.js' -import { MqttProvider } from '../../utils/MqttProvider.js' -import { MPSValidationError } from '../../utils/MPSValidationError.js' - -export async function setRPE(req: Request, res: Response): Promise { - try { - const guid: string = req.params.guid - const { enabled } = req.body - - MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) - - const bootCaps = await req.deviceAction.getBootCapabilities() - const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 - - if (platformEraseCaps === 0) { - throw new MPSValidationError('Device does not support Remote Platform Erase', 400) - } - - await req.deviceAction.setRPE(!!enabled) - - MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) - res.status(200).json({ status: 'success' }).end() - } catch (error) { - logger.error(`setRPE failed: ${error}`) - if (error instanceof MPSValidationError) { - res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)) - } else { - MqttProvider.publishEvent('fail', ['AMT_BootSettingData'], messages.INTERNAL_SERVICE_ERROR) - res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() - } - } -} diff --git a/src/test/collections/MPS.postman_collection.json b/src/test/collections/MPS.postman_collection.json index a1274d586..84c57698a 100644 --- a/src/test/collections/MPS.postman_collection.json +++ b/src/test/collections/MPS.postman_collection.json @@ -3190,83 +3190,6 @@ }, "response": [] }, - { - "name": "Get Boot Capabilities", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 404\", function () {\r\n pm.response.to.have.status(404);\r\n});\r\n\r\npm.test(\"Device should not be found\", function () {\r\n var jsonData = pm.response.json();\r\n pm.expect(jsonData.error).to.eq(\"Device not found/connected. Please connect again using CIRA.\")\r\n pm.expect(jsonData.errorDescription).to.eq(\"guid : 1\")\r\n});\r\n" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/capabilities/1", - "protocol": "{{protocol}}", - "host": [ - "{{host}}" - ], - "path": [ - "api", - "v1", - "amt", - "boot", - "capabilities", - "1" - ] - } - }, - "response": [] - }, - { - "name": "Set RPE Enabled", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 404\", function () {\r\n pm.response.to.have.status(404);\r\n});\r\n\r\npm.test(\"Device should not be found\", function () {\r\n var jsonData = pm.response.json();\r\n pm.expect(jsonData.error).to.eq(\"Device not found/connected. Please connect again using CIRA.\")\r\n pm.expect(jsonData.errorDescription).to.eq(\"guid : 1\")\r\n});\r\n" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\r\n \"enabled\": true\r\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/rpe/1", - "protocol": "{{protocol}}", - "host": [ - "{{host}}" - ], - "path": [ - "api", - "v1", - "amt", - "boot", - "rpe", - "1" - ] - } - }, - "response": [] - }, { "name": "Send Remote Erase", "event": [ @@ -3293,7 +3216,7 @@ } }, "url": { - "raw": "{{protocol}}://{{host}}/api/v1/amt/rpe/1", + "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/remoteErase/1", "protocol": "{{protocol}}", "host": [ "{{host}}" @@ -3302,7 +3225,8 @@ "api", "v1", "amt", - "rpe", + "boot", + "remoteErase", "1" ] } diff --git a/src/test/helper/wsmanResponses.ts b/src/test/helper/wsmanResponses.ts index bffbbd4bf..1c911df18 100644 --- a/src/test/helper/wsmanResponses.ts +++ b/src/test/helper/wsmanResponses.ts @@ -54,7 +54,7 @@ export const serviceAvailableToElement = { '2', '5' ], - PowerState: '4', + PowerState: 4, ServiceProvided: { Address: 'http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous', ReferenceParameters: { diff --git a/swagger.yaml b/swagger.yaml index df9515146..67ef22739 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -299,13 +299,10 @@ paths: application/json: schema: $ref: '#/components/schemas/GetAMTFeaturesResponse' - /api/v1/amt/boot/rpe/{guid}: - post: - summary: Enable or Disable Remote Platform Erase (RPE) - description: | - Enables or disables the Remote Platform Erase feature on the specified AMT device. - RPE must be enabled before calling `POST /api/v1/amt/rpe/{guid}` to trigger an erase. - Returns 400 if the device does not support RPE. + /api/v1/amt/boot/remoteErase/{guid}: + get: + summary: Get Remote Platform Erase capabilities + description: Returns the Remote Platform Erase capabilities supported by the device as individual boolean flags. tags: - AMT parameters: @@ -316,39 +313,31 @@ paths: required: true schema: type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SetRPERequest' responses: 200: - description: 'RPE enabled/disabled successfully' + description: 'Remote Platform Erase capabilities supported by this device' content: application/json: schema: - $ref: '#/components/schemas/RPEStatusResponse' - 400: - description: 'Device does not support Remote Platform Erase' + $ref: '#/components/schemas/RemoteEraseCapabilitiesResponse' 404: description: 'Device not found/connected' 500: description: 'Internal server error' - /api/v1/amt/rpe/{guid}: post: summary: Trigger Remote Platform Erase description: | Initiates a Remote Platform Erase on the specified AMT device. - RPE must be enabled on the device first via `POST /api/v1/amt/boot/rpe/{guid}`. + RPE must be enabled on the device first via `POST /api/v1/amt/features/{guid}`. - The `eraseMask` is a bitmask of erase targets to activate: - - `0x0001` — Non-volatile memory - - `0x0002` — Volatile memory - - `0x10000` — CSME unconfigure (cannot be combined with other bits) - - `0` — Platform erase without specific hardware target + Supported erase options (select one or more hardware bits, optionally combined with CSME): + - `secureEraseAllSSDs` — Secure erase all SSDs (bit 0x04) + - `tpmClear` — Clear TPM (bit 0x40) + - `restoreBIOSToEOM` — Reload BIOS golden configuration (bit 0x4000000) + - `unconfigureCSME` — CSME unconfigure; can be combined with hardware bits + - `ssdPassword` — Optional password for encrypted SSD erase (max 64 bytes) - Returns 400 if the device does not support RPE, if the requested mask is not supported, or if CSME is combined with other erase operations. + Returns 400 if the device does not support RPE or the requested options are not supported. tags: - AMT parameters: @@ -364,7 +353,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SendRPERequest' + $ref: '#/components/schemas/RemoteEraseRequest' responses: 200: description: 'Remote Platform Erase initiated successfully' @@ -373,7 +362,7 @@ paths: schema: $ref: '#/components/schemas/RPEStatusResponse' 400: - description: 'Device does not support RPE, requested mask not supported, or invalid mask combination' + description: 'Device does not support RPE, requested options not supported, or password too long' 404: description: 'Device not found/connected' 500: @@ -3408,6 +3397,9 @@ components: type: boolean ocr: type: boolean + platformEraseEnabled: + type: boolean + description: Enable or disable Remote Platform Erase (RPE). Only applied when the device reports PlatformErase capability. SetAMTFeaturesResponse: type: string example: @@ -4550,18 +4542,28 @@ components: structuredBiosBootString: type: string example: '' - SetRPERequest: - title: SetRPERequest - required: - - enabled + RemoteEraseCapabilitiesResponse: + title: RemoteEraseCapabilitiesResponse properties: - enabled: + secureEraseAllSSDs: + type: boolean + description: 'Device supports secure erase of all SSDs (bit 0x04)' + tpmClear: + type: boolean + description: 'Device supports TPM clear (bit 0x40)' + restoreBIOSToEOM: type: boolean - description: Set to true to enable RPE, false to disable + description: 'Device supports restoring BIOS to end-of-manufacturing state (bit 0x4000000)' + unconfigureCSME: + type: boolean + description: 'Device supports CSME unconfigure / ConfigurationDataReset (bit 0x10000)' example: - enabled: true - SendRPERequest: - title: SendRPERequest + secureEraseAllSSDs: true + tpmClear: true + restoreBIOSToEOM: false + unconfigureCSME: false + RemoteEraseRequest: + title: RemoteEraseRequest properties: secureEraseAllSSDs: type: boolean @@ -4574,12 +4576,17 @@ components: description: 'Bit 26 (0x4000000) — Reload BIOS golden configuration' unconfigureCSME: type: boolean - description: 'CSME unconfigure — sets ConfigurationDataReset; cannot be combined with other erase operations' + description: 'CSME unconfigure — sets ConfigurationDataReset; can be combined with hardware bits' + ssdPassword: + type: string + description: 'Optional password for encrypted SSD erase (max 64 bytes)' + maxLength: 64 example: secureEraseAllSSDs: true tpmClear: false restoreBIOSToEOM: false unconfigureCSME: false + ssdPassword: '' RPEStatusResponse: title: RPEStatusResponse properties: From 2e000b7759744ff15a78686b120aec5f3dcfc566 Mon Sep 17 00:00:00 2001 From: Natalie Gaston Date: Fri, 7 Aug 2026 13:40:41 -0700 Subject: [PATCH 14/14] fix: add device lock and remove RPE from powerActions --- src/amt/DeviceAction.ts | 35 +++++++++++++++++++++++-- src/amt/deviceAction.test.ts | 44 ++++++++++++++++++++++++++++++++ src/routes/amt/bootOptions.ts | 32 ++++++++++++----------- src/routes/amt/powerAction.ts | 1 - src/routes/amt/setAMTFeatures.ts | 4 +++ 5 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 98623ba5c..3922f9b11 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -13,6 +13,12 @@ import { BOOT_SERVICE_STATE_RPE_ONLY } from '../routes/amt/rpeConstants.js' +// Per-device mutex registry. Keyed by the CIRASocket instance (one per device +// connection) so that concurrent requests to the same device are serialized for +// boot-configuration sequences that must not interleave (sendRPE, bootOptions, +// setAMTFeatures boot-state changes, and power actions). +const deviceLocks = new Map>() + export class DeviceAction { ciraHandler: CIRAHandler ciraSocket: CIRASocket @@ -207,6 +213,29 @@ export class DeviceAction { return await this.getBootCapabilities() } + /** + * Acquires a per-device mutual-exclusion lock for the duration of `fn`. + * All callers for the same device (same CIRASocket) are serialized into a + * FIFO queue so that multi-step boot-configuration sequences cannot interleave. + */ + async withDeviceLock(fn: () => Promise): Promise { + const key = this.ciraSocket as object + // Each caller chains onto the current tail of the promise queue. + // `gate` is resolved only when this caller calls `release`, which unblocks + // the next waiter in the queue. + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const prev = deviceLocks.get(key) ?? Promise.resolve() + deviceLocks.set(key, prev.then(() => gate)) + // Wait for all previous holders to finish before proceeding. + await prev + try { + return await fn() + } finally { + release() + } + } + async setRPE(isEnabled: boolean): Promise { logger.silly(`setRPE ${messages.REQUEST}`) const bootOptions = await this.getBootOptions() @@ -220,7 +249,8 @@ export class DeviceAction { } async sendRPE(eraseMask: number, ssdPassword?: string): Promise { - logger.silly(`sendRPE ${messages.REQUEST}`) + await this.withDeviceLock(async () => { + logger.silly(`sendRPE ${messages.REQUEST}`) // CSME sentinel bit: 0x10000 maps to ConfigurationDataReset, not a hardware erase target const CSME_BIT = 0x10000 @@ -340,7 +370,8 @@ export class DeviceAction { throw new Error(`sendRPE power action ${action} failed: ${JSON.stringify(powerActionResult?.Body)}`) } - logger.silly(`sendRPE ${messages.COMPLETE}`) + logger.silly(`sendRPE ${messages.COMPLETE}`) + }) // end withDeviceLock } async requestUserConsentCode(): Promise> { diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index 1adb7e0c8..b2b200201 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -1187,4 +1187,48 @@ describe('Device Action Tests', () => { expect(result).toBeNull() }) }) + + describe('withDeviceLock', () => { + it('serializes concurrent callers for the same device', async () => { + const order: number[] = [] + const p1 = device.withDeviceLock(async () => { + await new Promise((r) => setTimeout(r, 20)) + order.push(1) + }) + const p2 = device.withDeviceLock(async () => { + order.push(2) + }) + await Promise.all([p1, p2]) + expect(order).toEqual([1, 2]) + }) + + it('releases the lock when the callback throws', async () => { + await expect( + device.withDeviceLock(async () => { + throw new Error('boom') + }) + ).rejects.toThrow('boom') + + // A subsequent caller must not be blocked forever + const result = await device.withDeviceLock(async () => 'ok') + expect(result).toBe('ok') + }) + + it('allows different devices to run concurrently', async () => { + const handler2 = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + const device2 = new DeviceAction(handler2, {} as any) + + const order: number[] = [] + const p1 = device.withDeviceLock(async () => { + await new Promise((r) => setTimeout(r, 20)) + order.push(1) + }) + const p2 = device2.withDeviceLock(async () => { + order.push(2) + }) + await Promise.all([p1, p2]) + // device2 should not be blocked by device — it runs before device1 finishes + expect(order).toEqual([2, 1]) + }) + }) }) diff --git a/src/routes/amt/bootOptions.ts b/src/routes/amt/bootOptions.ts index 89891cf71..ffcd2bb29 100644 --- a/src/routes/amt/bootOptions.ts +++ b/src/routes/amt/bootOptions.ts @@ -68,29 +68,31 @@ export async function bootOptions(req: Request, res: Response): Promise { const guid = req.params?.guid || '' const bootSource = await getBootSource(guid, payload, device) - const results = await device.getBootOptions() - const bootData = setBootData(payload.action as number, payload.useSOL as boolean, results.AMT_BootSettingData) + await device.withDeviceLock(async () => { + const results = await device.getBootOptions() + const bootData = setBootData(payload.action as number, payload.useSOL as boolean, results.AMT_BootSettingData) - await determineBootDevice(payload, bootData) + await determineBootDevice(payload, bootData) - await device.changeBootOrder(null) + await device.changeBootOrder(null) - await device.setBootConfiguration(bootData) + await device.setBootConfiguration(bootData) - // set boot config role - await device.forceBootMode(1) + // set boot config role + await device.forceBootMode(1) - await device.changeBootOrder(bootSource as unknown as CIM.Types.BootConfigSetting.InstanceID) + await device.changeBootOrder(bootSource as unknown as CIM.Types.BootConfigSetting.InstanceID) - const newAction = determinePowerAction(payload.action as number) + const newAction = determinePowerAction(payload.action as number) - const powerActionResult = await device.sendPowerAction(newAction) - powerActionResult.Body.RequestPowerStateChange_OUTPUT.ReturnValueStr = AMTStatusToString( - powerActionResult.Body.RequestPowerStateChange_OUTPUT.ReturnValue as number - ) - powerActionResult.Body = powerActionResult.Body.RequestPowerStateChange_OUTPUT + const powerActionResult = await device.sendPowerAction(newAction) + powerActionResult.Body.RequestPowerStateChange_OUTPUT.ReturnValueStr = AMTStatusToString( + powerActionResult.Body.RequestPowerStateChange_OUTPUT.ReturnValue as number + ) + powerActionResult.Body = powerActionResult.Body.RequestPowerStateChange_OUTPUT - res.status(200).json(powerActionResult) + res.status(200).json(powerActionResult) + }) } catch (error) { logger.error(`${messages.BOOT_SETTING_EXCEPTION} : ${error}`) MqttProvider.publishEvent('fail', ['AMT_BootSettingData'], messages.INTERNAL_SERVICE_ERROR) diff --git a/src/routes/amt/powerAction.ts b/src/routes/amt/powerAction.ts index 7855d6b62..3203a4437 100644 --- a/src/routes/amt/powerAction.ts +++ b/src/routes/amt/powerAction.ts @@ -187,7 +187,6 @@ export function setBootData( r.UseSafeMode = false r.UserPasswordBypass = false r.SecureErase = false - r.RPEEnabled = false // if (r.SecureErase) { // r.SecureErase = action === 104 && amtPowerBootCapabilities.SecureErase === true // } diff --git a/src/routes/amt/setAMTFeatures.ts b/src/routes/amt/setAMTFeatures.ts index 4bad4bcf3..3467ec78b 100644 --- a/src/routes/amt/setAMTFeatures.ts +++ b/src/routes/amt/setAMTFeatures.ts @@ -87,6 +87,9 @@ export async function setAMTFeatures(req: Request, res: Response): Promise await setUserConsent(req.deviceAction, optServiceResponse, payload.guid as string) } + // Configure Remote Platform Erase (RPE) and boot service state under a device lock + // to prevent concurrent boot-configuration requests from interleaving. + await req.deviceAction.withDeviceLock(async () => { // Configure Remote Platform Erase (RPE) — PUT must run BEFORE BootServiceStateChange let rpeDesired: boolean | undefined if (payload.platformEraseEnabled !== undefined) { @@ -129,6 +132,7 @@ export async function setAMTFeatures(req: Request, res: Response): Promise else if (rpeDesired) requestedState = BOOT_SERVICE_STATE_RPE_ONLY await req.deviceAction.BootServiceStateChange(requestedState) } + }) // end withDeviceLock MqttProvider.publishEvent('success', ['AMT_SetFeatures'], messages.AMT_FEATURES_SET_SUCCESS, guid) res.status(200).json({ status: messages.AMT_FEATURES_SET_SUCCESS }).end()