diff --git a/api/src/__test__/core/log.test.ts b/api/src/__test__/core/log.test.ts index 8bc696aa0c..cac5eb3405 100644 --- a/api/src/__test__/core/log.test.ts +++ b/api/src/__test__/core/log.test.ts @@ -1,7 +1,12 @@ +import { once } from 'node:events'; +import { Writable } from 'node:stream'; + +import pino from 'pino'; import pretty from 'pino-pretty'; import { expect, test } from 'vitest'; import { PRETTY_LOG_TIME_FORMAT } from '@app/core/log.constants.js'; +import { LOG_REDACT_PATHS } from '@app/core/log.js'; const padTime = (value: number) => value.toString().padStart(2, '0'); @@ -28,3 +33,45 @@ test('pretty log timestamps keep local minutes instead of using the month token' expect(output).toContain('test message'); expect(output).not.toContain('[16:03:34'); }); + +test('logger redacts encrypted array unlock fields', async () => { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + + const testLogger = pino( + { + redact: { + paths: [...LOG_REDACT_PATHS], + censor: '***REDACTED***', + }, + }, + stream + ); + + testLogger.info({ + commands: { + luksKey: 'secret-passphrase', + luksKeyfile: '/tmp/unraid/keyfile', + decryptionPassword: 'super-secret', + decryptionKeyfile: 'data:application/octet-stream;base64,QUJDRA==', + startState: 'STOPPED', + }, + }); + + stream.end(); + await once(stream, 'finish'); + + const output = chunks.join(''); + + expect(output).toContain('***REDACTED***'); + expect(output).toContain('"startState":"STOPPED"'); + expect(output).not.toContain('secret-passphrase'); + expect(output).not.toContain('/tmp/unraid/keyfile'); + expect(output).not.toContain('super-secret'); + expect(output).not.toContain('data:application/octet-stream;base64,QUJDRA=='); +}); diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 22433ff5b7..7e2edead90 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -19,6 +19,28 @@ const nullDestination = pino.destination({ const LOG_TRANSPORT = process.env.LOG_TRANSPORT ?? 'file'; const useConsole = LOG_TRANSPORT === 'console'; +export const LOG_REDACT_PATHS = [ + '*.password', + '*.pass', + '*.secret', + '*.token', + '*.key', + '*.Password', + '*.Pass', + '*.Secret', + '*.Token', + '*.Key', + '*.apikey', + '*.localApiKey', + '*.accesstoken', + '*.idtoken', + '*.refreshtoken', + '*.luksKey', + '*.luksKeyfile', + '*.decryptionPassword', + '*.decryptionKeyfile', +] as const; + export const logDestination = process.env.SUPPRESS_LOGS === 'true' ? nullDestination @@ -63,23 +85,7 @@ export const logger = pino( bindings: (bindings) => ({ ...bindings, apiVersion: API_VERSION }), }, redact: { - paths: [ - '*.password', - '*.pass', - '*.secret', - '*.token', - '*.key', - '*.Password', - '*.Pass', - '*.Secret', - '*.Token', - '*.Key', - '*.apikey', - '*.localApiKey', - '*.accesstoken', - '*.idtoken', - '*.refreshtoken', - ], + paths: [...LOG_REDACT_PATHS], censor: '***REDACTED***', }, }, diff --git a/api/src/core/utils/clients/emcmd.ts b/api/src/core/utils/clients/emcmd.ts index 0cf2bee1dd..3e8ac14d8b 100644 --- a/api/src/core/utils/clients/emcmd.ts +++ b/api/src/core/utils/clients/emcmd.ts @@ -102,7 +102,7 @@ export const emcmd = async ( const stateToken = getters.emhttp().var?.csrfToken; const csrfToken = await ensureCsrfToken(stateToken, waitForToken); - appLogger.debug(`Executing emcmd with commands: ${JSON.stringify(commands)}`); + appLogger.debug({ commands }, 'Executing emcmd'); try { const params = new URLSearchParams(); diff --git a/api/src/unraid-api/graph/resolvers/array/array.model.ts b/api/src/unraid-api/graph/resolvers/array/array.model.ts index edb850ef44..a25b5f8b6d 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.model.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.model.ts @@ -2,7 +2,7 @@ import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/gra import { Node } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsInt, IsOptional, IsString, MinLength } from 'class-validator'; import { GraphQLBigInt } from 'graphql-scalars'; import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js'; @@ -197,6 +197,25 @@ export class ArrayStateInput { @Field(() => ArrayStateInputState, { description: 'Array state' }) @IsEnum(ArrayStateInputState) desiredState!: ArrayStateInputState; + + @Field(() => String, { + nullable: true, + description: 'Optional password used to unlock encrypted array disks when starting the array', + }) + @IsOptional() + @IsString() + @MinLength(1) + decryptionPassword?: string; + + @Field(() => String, { + nullable: true, + description: + 'Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload.', + }) + @IsOptional() + @IsString() + @MinLength(1) + decryptionKeyfile?: string; } export enum ArrayState { diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts index 0161458e13..0c28cff7b1 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts @@ -1,6 +1,7 @@ import type { TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { mkdir, writeFile } from 'node:fs/promises'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -25,6 +26,11 @@ vi.mock('@app/core/modules/array/get-array-data.js', () => ({ getArrayData: vi.fn(), })); +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + writeFile: vi.fn(), +})); + vi.mock('@app/store/index.js', () => ({ getters: { emhttp: vi.fn(), @@ -41,6 +47,8 @@ describe('ArrayService', () => { let mockGetState: ReturnType; let mockEmcmd: ReturnType; let mockGetArrayDataUtil: ReturnType; + let mockMkdir: ReturnType; + let mockWriteFile: ReturnType; beforeEach(async () => { vi.resetAllMocks(); @@ -51,6 +59,8 @@ describe('ArrayService', () => { mockEmcmd = vi.mocked(emcmd); mockGetArrayDataUtil = vi.mocked(getArrayDataUtil); + mockMkdir = vi.mocked(mkdir); + mockWriteFile = vi.mocked(writeFile); const module: TestingModule = await Test.createTestingModule({ providers: [ArrayService], @@ -61,6 +71,7 @@ describe('ArrayService', () => { mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STOPPED, + luksKeyfile: '/tmp/unraid/keyfile', }, } as any); @@ -93,6 +104,8 @@ describe('ArrayService', () => { }, }; mockGetArrayDataUtil.mockResolvedValue(mockArrayData); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); mockGetState.mockReturnValue({ /* mock state if needed by getArrayDataUtil */ @@ -128,6 +141,45 @@ describe('ArrayService', () => { expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); }); + it('should START a STOPPED encrypted array with a decryption password', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionPassword: 'super-secret', + }; + const expectedArrayData = { ...mockArrayData, state: ArrayState.STARTED }; + mockGetArrayDataUtil.mockResolvedValue(expectedArrayData); + + const result = await service.updateArrayState(input); + + expect(result).toEqual(expectedArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + cmdStart: 'Start', + startState: 'STOPPED', + luksKey: 'c3VwZXItc2VjcmV0', + }); + }); + + it('should START a STOPPED encrypted array with a keyfile payload', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionKeyfile: 'data:application/octet-stream;base64,QUJDRA==', + }; + const expectedArrayData = { ...mockArrayData, state: ArrayState.STARTED }; + mockGetArrayDataUtil.mockResolvedValue(expectedArrayData); + + const result = await service.updateArrayState(input); + + expect(result).toEqual(expectedArrayData); + expect(mockMkdir).toHaveBeenCalledWith('/tmp/unraid', { recursive: true }); + expect(mockWriteFile).toHaveBeenCalledWith('/tmp/unraid/keyfile', Buffer.from('ABCD'), { + mode: 0o600, + }); + expect(mockEmcmd).toHaveBeenCalledWith({ + cmdStart: 'Start', + startState: 'STOPPED', + }); + }); + it('should STOP a STARTED array', async () => { mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); const input: ArrayStateInput = { desiredState: ArrayStateInputState.STOP }; @@ -158,6 +210,79 @@ describe('ArrayService', () => { await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); expect(mockEmcmd).not.toHaveBeenCalled(); }); + + it('should reject a decryption password that does not match webgui ASCII validation', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionPassword: 'pÄssword', + }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + + expect(mockEmcmd).not.toHaveBeenCalled(); + }); + + it('should reject invalid raw decryption keyfile payloads', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionKeyfile: 'not-valid-base64', + }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockEmcmd).not.toHaveBeenCalled(); + }); + + it('should reject invalid data URL decryption keyfile payloads', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionKeyfile: 'data:application/octet-stream;base64,not-valid-base64', + }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockEmcmd).not.toHaveBeenCalled(); + }); + + it('should reject providing both a decryption password and keyfile', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionPassword: 'super-secret', + decryptionKeyfile: 'data:application/octet-stream;base64,QUJDRA==', + }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockEmcmd).not.toHaveBeenCalled(); + }); + + it('should allow a later start after decryption preflight validation fails', async () => { + await expect( + service.updateArrayState({ + desiredState: ArrayStateInputState.START, + decryptionPassword: 'super-secret', + decryptionKeyfile: 'data:application/octet-stream;base64,QUJDRA==', + }) + ).rejects.toThrow(BadRequestException); + + const expectedArrayData = { ...mockArrayData, state: ArrayState.STARTED }; + mockGetArrayDataUtil.mockResolvedValue(expectedArrayData); + + const result = await service.updateArrayState({ + desiredState: ArrayStateInputState.START, + decryptionPassword: 'super-secret', + }); + + expect(result).toEqual(expectedArrayData); + expect(mockEmcmd).toHaveBeenLastCalledWith({ + cmdStart: 'Start', + startState: 'STOPPED', + luksKey: 'c3VwZXItc2VjcmV0', + }); + }); }); describe('addDiskToArray', () => { diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.ts b/api/src/unraid-api/graph/resolvers/array/array.service.ts index 117e15132c..5b6a1a0c7d 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.ts @@ -1,4 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; import { capitalCase, constantCase } from 'change-case'; import { GraphQLError } from 'graphql'; @@ -24,6 +26,85 @@ enum ArrayPendingState { export class ArrayService { private pendingState: ArrayPendingState | null = null; + private invalidDecryptionKeyfileError = () => + new BadRequestException( + new AppError('Decryption keyfile must be a valid data URL or raw base64 payload.') + ); + + private encodeDecryptionPassword = (decryptionPassword: string) => { + const printableAscii = /^[ -~]+$/; + + if (!printableAscii.test(decryptionPassword)) { + throw new BadRequestException( + new AppError( + 'Decryption password must use printable ASCII characters. Use a keyfile for UTF-8 input.' + ) + ); + } + + return Buffer.from(decryptionPassword, 'utf8').toString('base64'); + }; + + private decodeDecryptionKeyfile = (decryptionKeyfile: string): Buffer => { + const source = decryptionKeyfile.trim(); + const dataUrlMatch = /^data:[^,]*,(.+)$/i.exec(source); + + if (dataUrlMatch) { + const meta = source.slice(5, source.indexOf(',')); + const payload = dataUrlMatch[1]; + + try { + return /;base64/i.test(meta) + ? this.decodeStrictBase64(payload) + : Buffer.from(decodeURIComponent(payload), 'utf8'); + } catch { + throw this.invalidDecryptionKeyfileError(); + } + } + + return this.decodeStrictBase64(source); + }; + + private decodeStrictBase64 = (payload: string): Buffer => { + const normalized = payload.replace(/\s+/g, ''); + if ( + normalized.length === 0 || + normalized.length % 4 !== 0 || + !/^[A-Za-z0-9+/=]+$/.test(normalized) + ) { + throw this.invalidDecryptionKeyfileError(); + } + + try { + const decoded = Buffer.from(normalized, 'base64'); + if (!decoded.length) { + throw new Error('empty'); + } + const normalizedInput = normalized.replace(/=+$/g, ''); + const normalizedDecoded = decoded.toString('base64').replace(/=+$/g, ''); + if (normalizedInput !== normalizedDecoded) { + throw new Error('mismatch'); + } + return decoded; + } catch { + throw this.invalidDecryptionKeyfileError(); + } + }; + + private writeDecryptionKeyfile = async (decryptionKeyfile: string) => { + const { getters } = await import('@app/store/index.js'); + const luksKeyfile = getters.emhttp().var?.luksKeyfile; + + if (!luksKeyfile) { + throw new BadRequestException( + new AppError('Array decryption keyfile path is not configured.') + ); + } + + await mkdir(dirname(luksKeyfile), { recursive: true }); + await writeFile(luksKeyfile, this.decodeDecryptionKeyfile(decryptionKeyfile), { mode: 0o600 }); + }; + /** * Is the array running? * @todo Refactor this to include this util in the service directly @@ -45,7 +126,11 @@ export class ArrayService { return getArrayDataUtil(store.getState); } - async updateArrayState({ desiredState }: ArrayStateInput): Promise { + async updateArrayState({ + desiredState, + decryptionPassword, + decryptionKeyfile, + }: ArrayStateInput): Promise { if (this.pendingState) { throw new BadRequestException( new AppError(`Array state is still being updated. Changing to ${this.pendingState}`) @@ -67,13 +152,27 @@ export class ArrayService { throw new BadRequestException(new AppError(`The array is already ${startState}`)); } - // Set lock then start/stop array - this.pendingState = newPendingState; - const command = { + const command: Record = { [`cmd${capitalCase(desiredState)}`]: capitalCase(desiredState), startState: constantCase(startState), }; + if (decryptionPassword && decryptionKeyfile) { + throw new BadRequestException( + new AppError('Provide either a decryption password or a decryption keyfile, not both.') + ); + } + + if (desiredState === ArrayStateInputState.START && decryptionPassword) { + command.luksKey = this.encodeDecryptionPassword(decryptionPassword); + } + + if (desiredState === ArrayStateInputState.START && decryptionKeyfile) { + await this.writeDecryptionKeyfile(decryptionKeyfile); + } + + this.pendingState = newPendingState; + try { await emcmd(command); } finally {