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/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 90fc2b670..4ffd13ebd 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -190,15 +190,115 @@ 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 } + // 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() + const current = bootOptions.AMT_BootSettingData + ;(current as any).RPE = isEnabled + await this.setBootConfiguration(current) + logger.silly(`setRPE ${messages.COMPLETE}`) + } + + async sendRPE(eraseMask: number, powerType: 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 RPE + const bootOptions = await this.getBootOptions() + const 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') + } + + // 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(`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. + 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 + 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}`) + } + async requestUserConsentCode(): Promise> { logger.silly(`requestUserConsentCode ${messages.REQUEST}`) const xmlRequestBody = this.ips.OptInService.StartOptIn() diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index 8200db96e..83f7eb575 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -382,12 +382,50 @@ 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', 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 } } } + }) + 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(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, 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(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/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/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 4ee6e7b8a..a832b402f 100644 --- a/src/routes/amt/getAMTFeatures.test.ts +++ b/src/routes/amt/getAMTFeatures.test.ts @@ -140,7 +140,8 @@ describe('get amt features', () => { ForcedProgressEvents: true, IDER: true, InstanceID: 'Intel(r) AMT:BootCapabilities 0', - SOL: true + SOL: true, + PlatformErase: 3 } } }, @@ -155,7 +156,8 @@ describe('get amt features', () => { IDERBootDevice: 0, InstanceID: 'Intel(r) AMT:BootSettingData 0', UseIDER: false, - UseSOL: false + UseSOL: false, + RPE: true } } }) @@ -174,7 +176,8 @@ describe('get amt features', () => { httpsBootSupported: true, winREBootSupported: true, localPBABootSupported: false, - remoteErase: false + rpe: true, + rpeSupported: true }) expect(mqttSpy).toHaveBeenCalledTimes(2) }) @@ -270,7 +273,8 @@ describe('get amt features', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false + rpe: false, + rpeSupported: false }) }) }) diff --git a/src/routes/amt/getAMTFeatures.ts b/src/routes/amt/getAMTFeatures.ts index d7cfa37bc..5204f5a91 100644 --- a/src/routes/amt/getAMTFeatures.ts +++ b/src/routes/amt/getAMTFeatures.ts @@ -28,6 +28,11 @@ 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 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) res .status(200) @@ -43,7 +48,8 @@ export async function getAMTFeatures(req: Request, res: Response): Promise httpsBootSupported: ocrProcessResult.HTTPSBootSupported, winREBootSupported: ocrProcessResult.WinREBootSupported, localPBABootSupported: ocrProcessResult.LocalPBABootSupported, - remoteErase: false + rpe, + rpeSupported }) .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..95c642cb5 --- /dev/null +++ b/src/routes/amt/getBootCapabilities.test.ts @@ -0,0 +1,75 @@ +/********************************************************************* + * 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/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('Get Boot Capabilities', () => { + let req: any + let resSpy: any + let mqttSpy: MockInstance + let bootCapsSpy: 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' }, + 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') + }) + + it('should return boot capabilities', async () => { + const bootCaps = { + IDER: true, + SOL: true, + BIOSSetup: true, + PlatformErase: 0x10044 + } + bootCapsSpy.mockResolvedValue({ + Body: { AMT_BootCapabilities: bootCaps } + }) + + await getBootCapabilities(req, resSpy) + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith({ + secureEraseAllSSDs: true, + tpmClear: true, + restoreBIOSToEOM: false, + unconfigureCSME: true + }) + 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..328411009 --- /dev/null +++ b/src/routes/amt/getBootCapabilities.ts @@ -0,0 +1,46 @@ +/********************************************************************* + * 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' + +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 + + 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) + + 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() + } +} + +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 6e815663c..2e856824e 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 { setRPE } from './setRPE.js' +import { sendRPE } from './sendRPE.js' import { getNetworkSettings } from './networkSettings/getNetworkSettings.js' import { getWiredNetworkSettings } from './networkSettings/getWired.js' import { patchWiredNetworkSettings } from './networkSettings/patchWired.js' @@ -66,6 +69,11 @@ 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.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 // } diff --git a/src/routes/amt/powerCapabilities.test.ts b/src/routes/amt/powerCapabilities.test.ts index 04c651b07..f435d3fd0 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, 'getPowerCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) @@ -129,7 +130,7 @@ describe('Power Capabilities', () => { powerCaps.Body.AMT_BootCapabilities.BIOSSetup = true powerCaps.Body.AMT_BootCapabilities.SecureErase = true powerCaps.Body.AMT_BootCapabilities.ForceDiagnosticBoot = true - vi.spyOn(device, 'getPowerCapabilities').mockResolvedValue(powerCaps) + vi.spyOn(device, 'getBootCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) diff --git a/src/routes/amt/sendRPE.test.ts b/src/routes/amt/sendRPE.test.ts new file mode 100644 index 000000000..174c9188f --- /dev/null +++ b/src/routes/amt/sendRPE.test.ts @@ -0,0 +1,107 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { sendRPE } from './sendRPE.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('Send Remote Erase', () => { + let req: any + let resSpy: any + let mqttSpy: MockInstance + let bootCapsSpy: MockInstance + let sendEraseSpy: 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: { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: false }, + 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') + sendEraseSpy = vi.spyOn(device, 'sendRPE') + }) + + it('should send remote erase when device supports the requested mask', async () => { + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x44 } } }) + sendEraseSpy.mockResolvedValue(undefined) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(0x4, undefined) + 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 = { secureEraseAllSSDs: false, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: false } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x44 } } }) + sendEraseSpy.mockResolvedValue(undefined) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(0, undefined) + 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 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 = { secureEraseAllSSDs: false, tpmClear: true, restoreBIOSToEOM: false, unconfigureCSME: false } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) + + 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 = { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: true } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) + + 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') + ) + }) + + it('should return 500 on unexpected error', async () => { + bootCapsSpy.mockRejectedValue(new Error('AMT error')) + + 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/sendRPE.ts b/src/routes/amt/sendRPE.ts new file mode 100644 index 000000000..08736c1e7 --- /dev/null +++ b/src/routes/amt/sendRPE.ts @@ -0,0 +1,54 @@ +/********************************************************************* + * 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 sendRPE(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + + const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME, powerType } = 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) + + 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.sendRPE(mask, powerType) + + MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) + res.status(200).json({ status: 'success' }).end() + } catch (error) { + logger.error(`sendRPE 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..77cafe759 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.setRPE(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/setRPE.test.ts b/src/routes/amt/setRPE.test.ts new file mode 100644 index 000000000..1952e3eaf --- /dev/null +++ b/src/routes/amt/setRPE.test.ts @@ -0,0 +1,73 @@ +/********************************************************************* + * 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 new file mode 100644 index 000000000..8a751e831 --- /dev/null +++ b/src/routes/amt/setRPE.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 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/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/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 86f12014e..ed9eb4adb 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 \"secureEraseAllSSDs\": true,\r\n \"tpmClear\": false,\r\n \"restoreBIOSToEOM\": false,\r\n \"unconfigureCSME\": false\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/rpe/1", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "rpe", + "1" + ] + } + }, + "response": [] + }, { "name": "Get Version", "event": [ diff --git a/swagger.yaml b/swagger.yaml index 4b3e26cd1..24629fe2b 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 @@ -3273,7 +3352,9 @@ components: type: boolean winREBootSupported: type: boolean - remoteErase: + rpe: + type: boolean + rpeSupported: type: boolean example: userConsent: kvm @@ -3286,7 +3367,8 @@ components: httpsBootSupported: true winREBootSupported: true localPBABootSupported: true - remoteErase: false + rpe: true + rpeSupported: true SetAlarmClockRequest: title: SetAlarmClockRequest @@ -4375,3 +4457,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