Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deny preload for an image with secure boot enabled #2914

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/commands/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import type {
Release,
} from 'balena-sdk';
import type { Preloader } from 'balena-preload';
import { promisify } from 'util';
import type * as Fs from 'fs';

export default class PreloadCmd extends Command {
public static description = stripIndent`
Expand Down Expand Up @@ -161,6 +163,40 @@ Can be repeated to add multiple certificates.\
);
}

// Verify that image is not enabled for secure boot. First, confirm it
// is a secure boot image with an /opt/*.sig file in the rootA partition.
const { explorePartition, BalenaPartition } = await import(
'../../utils/image-contents'
);
const isSecureBoot = await explorePartition<boolean>(
params.image,
BalenaPartition.ROOTA,
async (fs: typeof Fs): Promise<boolean> => {
try {
const promiseDir = promisify(fs.readdir);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe instead of using promisify here, we can use the native promises api: https://nodejs.org/docs/latest-v20.x/api/fs.html#fspromisesreaddirpath-options to avoid having to do this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rcooke-warwick this is not the fs namespace imported at the top level, but it's the virtual fs "namespace" that balena-image-fs gives us.
That being said, since we recently added support for promises in balena-image-fs, @kb2ma should be able to just use v directly.

Suggested change
const promiseDir = promisify(fs.readdir);
const files = await fs.promises.readdir('/opt');

const files = await promiseDir('/opt');
return files.some((el) => el.endsWith('balenaos-img.sig'));
} catch {
// Typically one of:
// - Error: No such file or directory
// - Error: Unsupported filesystem.
// - ErrnoException: node_ext2fs_open ENOENT (44) args: [5261576,5268064,"r",0]
return false;
}
return false;
},
);
// Next verify that config.json enables secureboot.
if (isSecureBoot) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not relevant to this PR, but maybe elsewhere we can add a check to the CLI that detects if someone has tried to enable secureboot on a non signed image, and warn them that this won't work, to avoid any confusion / chance of them thinking they've deployed a secure device but haven't!

const { read } = await import('balena-config-json');
const config = await read(params.image, '');
if (config.installer?.secureboot === true) {
throw new ExpectedError(
'Cannot preload image with secure boot enabled',
);
}
}

// balena-preload currently does not work with numerical app IDs
// Load app here, and use app slug from hereon
const fleetSlug: string | undefined = options.fleet
Expand Down
122 changes: 122 additions & 0 deletions src/utils/image-contents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2025 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Utilities to explore the contents in a balenaOS image.

import * as imagefs from 'balena-image-fs';
import * as filedisk from 'file-disk';
import type {
GetPartitionsResult,
GPTPartition,
MBRPartition,
} from 'partitioninfo';
import { getPartitions } from 'partitioninfo';
import type * as Fs from 'fs';

/**
* @summary IDs for the standard balenaOS partitions
* @description Values are the base name for a partition on disk
*/
export enum BalenaPartition {
BOOT = 'boot',
ROOTA = 'rootA',
ROOTB = 'rootB',
STATE = 'state',
DATA = 'data',
}

/**
* @summary Allow a provided function to explore the contents of one of the well-known
* partitions of a balenaOS image
* @description Presently assumes partition is rootA
*
* @param {string} imagePath - pathname of image for search
* @param {BalenaPartition} partitionId - partition to find
* @param {(fs) => Promise<T>} - function for exploration
* @returns {T}
*/
export async function explorePartition<T>(
imagePath: string,
partitionId: BalenaPartition,
exploreFn: (fs: typeof Fs) => Promise<T>,
): Promise<T> {
return await filedisk.withOpenFile(imagePath, 'r', async (handle) => {
const disk = new filedisk.FileDisk(handle, true, false, false);
const partitionInfo = await getPartitions(disk, {
includeExtended: false,
getLogical: true,
});
const { partitions } = partitionInfo;

// Some devices like Intel Edison have an empty partition table, which
// we designate by leaving partNumber undefined. The balena-image-fs
// interact() function knows how to handle this case.
let partNumber;
if (partitions?.length) {
const innerPartNumber = await findPartition(partitionInfo, disk, [
`resin-${partitionId}`,
`flash-${partitionId}`,
`balena-${partitionId}`,
]);
if (innerPartNumber === undefined) {
throw new Error("can't find partition");
} else {
partNumber = innerPartNumber;
}
}

return await imagefs.interact<T>(disk, partNumber, exploreFn);
});
}

// Find partition, by partition name on GPT or by filesystem label on MBR.
async function findPartition(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since that's soooo similar to findBootPartitionByName() of balena-config-json, how about avoiding the duplication by creating and exposing a more generic function like the one you did here?
We can then have findBootPartitionByName() use this new findPartition() by wiring in the names of interest for the partition.

partitionInfo: GetPartitionsResult,
fileDisk: filedisk.FileDisk,
names: string[],
): Promise<number | undefined> {
const { partitions } = partitionInfo;
const isGPT = (
partsInfo: GetPartitionsResult,
_parts: Array<GPTPartition | MBRPartition>,
): _parts is GPTPartition[] => partsInfo.type === 'gpt';

if (isGPT(partitionInfo, partitions)) {
const partition = partitions.find((gptPartInfo: GPTPartition) =>
names.includes(gptPartInfo.name),
);
if (partition && typeof partition.index === 'number') {
return partition.index;
}
} else {
// MBR
for (const partition of partitions) {
try {
const label = await imagefs.getFsLabel(fileDisk, partition);
if (names.includes(label) && typeof partition.index === 'number') {
return partition.index;
}
} catch (e) {
// LabelNotFound is expected and not fatal.
if (!(e instanceof imagefs.LabelNotFound)) {
throw e;
}
}
}
}
return undefined;
}
Loading