Skip to content
Merged
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
7 changes: 6 additions & 1 deletion docs/nvx-security-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,12 @@ continuing to keep `nvx` absent from the runtime registry and CLI:
- `src/nvx/cleanup-record.ts` defines the exact durable ownership record used
by future normal and stale cleanup. It binds each cleanup stage to
independently verifiable process, filesystem, namespace, account, cgroup,
and device-ACL identities rather than accepting names or PIDs alone.
and device-ACL identities rather than accepting names or PIDs alone; and
- `src/nvx/run-layout.ts` derives the artifact snapshot, writable run
directory, cleanup record, cgroup, and network namespace from one 32-character
run identity, and provides checked host-to-jail path translation. Preflight
now stages artifacts at that exact identity-bound path, while confinement
rejects artifact and writable directories from different runs.

These modules are internal foundations, not a usable backend. Phase 3b must
still implement and live-test the dedicated account lifecycle, serialized
Expand Down
2 changes: 1 addition & 1 deletion src/nvx/cleanup-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function record() {
inode: '11',
},
networkNamespace: {
name: 'awfnvx-run-1',
name: `awfnvx-${RUN_ID}`,
inode: '4026533000',
},
mountNamespaceInode: '4026533001',
Expand Down
20 changes: 9 additions & 11 deletions src/nvx/cleanup-record.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import * as path from 'path';
import {
NVX_CGROUP_ROOT,
NVX_RUN_DIRECTORY_ROOT,
NVX_TRUSTED_ARTIFACT_ROOT,
} from './paths';
import { NVX_CLEANUP_ROOT } from './paths';
import { createNvxRunLayout } from './run-layout';

export const NVX_CLEANUP_SCHEMA_VERSION = 1;
export const NVX_CLEANUP_ROOT = '/run/awf-nvx/cleanup';
export { NVX_CLEANUP_ROOT };

export interface NvxCleanupFileIdentity {
readonly path: string;
Expand Down Expand Up @@ -79,7 +76,8 @@ export function parseNvxCleanupRecord(
throw new Error(`NVX cleanup record schemaVersion must be ${NVX_CLEANUP_SCHEMA_VERSION}`);
}
const runId = requireRunId(record.runId);
const expectedPath = path.join(NVX_CLEANUP_ROOT, `${runId}.json`);
const layout = createNvxRunLayout(runId);
const expectedPath = layout.cleanupRecordPath;
if (path.resolve(recordPath) !== expectedPath) {
throw new Error(`NVX cleanup record path must be ${expectedPath}`);
}
Expand Down Expand Up @@ -125,7 +123,7 @@ export function parseNvxCleanupRecord(
networkNamespace &&
(
typeof networkNamespace.name !== 'string' ||
!/^awfnvx-[A-Za-z0-9_.-]+$/.test(networkNamespace.name) ||
networkNamespace.name !== layout.networkNamespace ||
typeof networkNamespace.inode !== 'string' ||
!/^\d+$/.test(networkNamespace.inode)
)
Expand Down Expand Up @@ -175,14 +173,14 @@ export function parseNvxCleanupRecord(
artifactSnapshot: fileIdentity(
resources.artifactSnapshot,
'artifactSnapshot',
path.join(NVX_TRUSTED_ARTIFACT_ROOT, `run-${runId}`),
layout.artifactSnapshotDirectory,
),
}),
...(resources.runDirectory === undefined ? {} : {
runDirectory: fileIdentity(
resources.runDirectory,
'runDirectory',
path.join(NVX_RUN_DIRECTORY_ROOT, runId),
layout.runDirectory,
),
}),
...(networkNamespace ? {
Expand All @@ -196,7 +194,7 @@ export function parseNvxCleanupRecord(
cgroup: fileIdentity(
resources.cgroup,
'cgroup',
path.join(NVX_CGROUP_ROOT, runId),
layout.cgroupPath,
),
}),
deviceAcls: deviceAcls as NvxCleanupRecord['resources']['deviceAcls'],
Expand Down
5 changes: 5 additions & 0 deletions src/nvx/confinement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ describe('NVX host confinement', () => {
nvxRoot: `/run/awf-nvx/trusted-artifacts/run-${RUN_ID}`,
runDirectory: '/tmp/awf-nvx',
})).toThrow(/per-run path/);
expect(() => buildNvxConstrainedLaunchCommand({
...baseOptions,
nvxRoot: `/run/awf-nvx/trusted-artifacts/run-${'b'.repeat(32)}`,
runDirectory: `/run/awf-nvx/runs/${RUN_ID}`,
})).toThrow(/share one run ID/);
});

it('computes explicit memory, CPU, and PID limits', () => {
Expand Down
3 changes: 3 additions & 0 deletions src/nvx/confinement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export function buildNvxConstrainedLaunchCommand(options: {
/^[a-f0-9]{32}$/,
'NVX run directory',
);
if (path.basename(nvxRoot) !== `run-${path.basename(runDirectory)}`) {
throw new Error('NVX trusted artifacts and run directory must share one run ID');
}
assertNonOverlappingPaths(nvxRoot, runDirectory);
if (options.systemReadOnlyPaths.length < 1) {
throw new Error('NVX filesystem jail requires explicit read-only system roots');
Expand Down
10 changes: 10 additions & 0 deletions src/nvx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ export type {
NvxLaunchCommand,
NvxLaunchConfinementPolicy,
} from './confinement';
export {
NVX_GUEST_ARTIFACT_ROOT,
NVX_GUEST_RUN_ROOT,
assertNvxRunId,
assertNvxRunLayout,
createNvxRunLayout,
toNvxGuestArtifactPath,
toNvxGuestRunPath,
} from './run-layout';
export type { NvxRunLayout } from './run-layout';
export { runNvxPreflight } from './preflight';
export type {
NvxArtifactPaths,
Expand Down
1 change: 1 addition & 0 deletions src/nvx/paths.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const NVX_TRUSTED_ARTIFACT_ROOT = '/run/awf-nvx/trusted-artifacts';
export const NVX_RUN_DIRECTORY_ROOT = '/run/awf-nvx/runs';
export const NVX_CLEANUP_ROOT = '/run/awf-nvx/cleanup';
export const NVX_CGROUP_ROOT = '/sys/fs/cgroup/awf-nvx';
53 changes: 46 additions & 7 deletions src/nvx/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function manifest() {
}

const options = {
runId: 'a'.repeat(32),
expectedReleaseTag: NVX_ARTIFACT_RELEASE_TAG,
manifestPath: '/trusted/manifest.json',
artifactManifestBundlePath: '/trusted/manifest.sigstore.json',
Expand All @@ -57,13 +58,14 @@ const options = {

function snapshot(): NvxArtifactSnapshot {
return {
directory: '/run/awf-nvx/trusted-artifacts/run-test',
launcher: '/run/awf-nvx/trusted-artifacts/run-test/nvx.py',
openvmm: '/run/awf-nvx/trusted-artifacts/run-test/openvmm',
kernel: '/run/awf-nvx/trusted-artifacts/run-test/vmlinux',
initramfs: '/run/awf-nvx/trusted-artifacts/run-test/initramfs.cpio.gz',
manifestPath: '/run/awf-nvx/trusted-artifacts/run-test/manifest.json',
bundlePath: '/run/awf-nvx/trusted-artifacts/run-test/manifest.sigstore.json',
directory: `/run/awf-nvx/trusted-artifacts/run-${options.runId}`,
launcher: `/run/awf-nvx/trusted-artifacts/run-${options.runId}/nvx.py`,
openvmm: `/run/awf-nvx/trusted-artifacts/run-${options.runId}/openvmm`,
kernel: `/run/awf-nvx/trusted-artifacts/run-${options.runId}/vmlinux`,
initramfs: `/run/awf-nvx/trusted-artifacts/run-${options.runId}/initramfs.cpio.gz`,
manifestPath: `/run/awf-nvx/trusted-artifacts/run-${options.runId}/manifest.json`,
bundlePath:
`/run/awf-nvx/trusted-artifacts/run-${options.runId}/manifest.sigstore.json`,
};
}

Expand Down Expand Up @@ -120,6 +122,13 @@ describe('NVX preflight', () => {
snapshot().manifestPath,
snapshot().bundlePath,
);
expect(deps.createSnapshot).toHaveBeenCalledWith(
options,
expect.objectContaining({
runId: options.runId,
artifactSnapshotDirectory: snapshot().directory,
}),
);
expect(deps.sha256).toHaveBeenCalledTimes(4);
});

Expand Down Expand Up @@ -179,4 +188,34 @@ describe('NVX preflight', () => {
await expect(runNvxPreflight(options, deps)).rejects.toThrow(/digest changed/);
expect(deps.removeSnapshot).toHaveBeenCalledWith(snapshot().directory);
});

it('rejects a snapshot outside the canonical per-run directory', async () => {
const unexpected = {
...snapshot(),
directory: '/run/awf-nvx/trusted-artifacts/run-other',
};
const deps = dependencies({
createSnapshot: jest.fn().mockResolvedValue(unexpected),
});

await expect(runNvxPreflight(options, deps)).rejects.toThrow(
/snapshot directory must be/,
);
expect(deps.removeSnapshot).toHaveBeenCalledWith(snapshot().directory);
});

it('rejects artifact paths outside the canonical snapshot directory', async () => {
const unexpected = {
...snapshot(),
launcher: '/tmp/nvx.py',
};
const deps = dependencies({
createSnapshot: jest.fn().mockResolvedValue(unexpected),
});

await expect(runNvxPreflight(options, deps)).rejects.toThrow(
/snapshot launcher must be/,
);
expect(deps.verifyAttestation).not.toHaveBeenCalled();
});
});
43 changes: 40 additions & 3 deletions src/nvx/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
parseNvxArtifactManifest,
} from './artifact-manifest';
import { NVX_TRUSTED_ARTIFACT_ROOT } from './paths';
import {
assertNvxRunLayout,
createNvxRunLayout,
type NvxRunLayout,
} from './run-layout';

const REQUIRED_CGROUP_CONTROLLERS = ['cpu', 'memory', 'pids'] as const;
const REQUIRED_TOOLS = [
Expand Down Expand Up @@ -41,6 +46,7 @@ export interface NvxArtifactSnapshot extends NvxArtifactPaths {
}

export interface NvxPreflightOptions {
readonly runId: string;
readonly expectedReleaseTag: string;
readonly manifestPath: string;
readonly artifactManifestBundlePath: string;
Expand Down Expand Up @@ -75,6 +81,7 @@ export interface NvxPreflightDependencies {
): Promise<void>;
createSnapshot(
options: NvxPreflightOptions,
layout: NvxRunLayout,
): Promise<NvxArtifactSnapshot>;
removeSnapshot(directory: string): Promise<void>;
}
Expand Down Expand Up @@ -121,6 +128,8 @@ export async function runNvxPreflight(
options: NvxPreflightOptions,
dependencies: NvxPreflightDependencies = defaultDependencies,
): Promise<NvxPreflightResult> {
const layout = createNvxRunLayout(options.runId);
assertNvxRunLayout(layout);
if (dependencies.platform !== 'linux' || dependencies.arch !== 'x64') {
throw new Error('NVX preview requires a Linux x86_64 host');
}
Expand Down Expand Up @@ -182,7 +191,13 @@ export async function runNvxPreflight(
);
}

const snapshot = await dependencies.createSnapshot(options);
const snapshot = await dependencies.createSnapshot(options, layout);
try {
assertSnapshotLayout(snapshot, layout);
} catch (error) {
await dependencies.removeSnapshot(layout.artifactSnapshotDirectory);
throw error;
}
try {
await assertTrustedFile(
snapshot.manifestPath,
Expand Down Expand Up @@ -244,6 +259,26 @@ export async function runNvxPreflight(
}
}

function assertSnapshotLayout(
snapshot: NvxArtifactSnapshot,
layout: NvxRunLayout,
): void {
const expected: NvxArtifactSnapshot = {
directory: layout.artifactSnapshotDirectory,
launcher: path.join(layout.artifactSnapshotDirectory, 'nvx.py'),
openvmm: path.join(layout.artifactSnapshotDirectory, 'openvmm'),
kernel: path.join(layout.artifactSnapshotDirectory, 'vmlinux'),
initramfs: path.join(layout.artifactSnapshotDirectory, 'initramfs.cpio.gz'),
manifestPath: path.join(layout.artifactSnapshotDirectory, 'manifest.json'),
bundlePath: path.join(layout.artifactSnapshotDirectory, 'manifest.sigstore.json'),
};
for (const key of Object.keys(expected) as (keyof NvxArtifactSnapshot)[]) {
if (snapshot[key] !== expected[key]) {
throw new Error(`NVX artifact snapshot ${key} must be ${expected[key]}`);
}
}
}

async function assertTrustedFile(
filePath: string,
label: string,
Expand All @@ -266,10 +301,11 @@ async function assertTrustedFile(

async function createArtifactSnapshot(
options: NvxPreflightOptions,
layout: NvxRunLayout,
): Promise<NvxArtifactSnapshot> {
await fs.mkdir(NVX_TRUSTED_ARTIFACT_ROOT, { recursive: true, mode: 0o711 });
const directory = await fs.mkdtemp(path.join(NVX_TRUSTED_ARTIFACT_ROOT, 'run-'));
await fs.chmod(directory, 0o555);
const directory = layout.artifactSnapshotDirectory;
await fs.mkdir(directory, { mode: 0o700 });
try {
const copied = {} as Record<NvxTrustedArtifactName, string>;
for (const name of Object.keys(options.artifacts) as NvxTrustedArtifactName[]) {
Expand All @@ -288,6 +324,7 @@ async function createArtifactSnapshot(
);
await fs.chmod(manifestPath, 0o444);
await fs.chmod(bundlePath, 0o444);
await fs.chmod(directory, 0o555);
return {
directory,
launcher: copied.launcher,
Expand Down
44 changes: 44 additions & 0 deletions src/nvx/run-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
NVX_GUEST_ARTIFACT_ROOT,
NVX_GUEST_RUN_ROOT,
assertNvxRunLayout,
createNvxRunLayout,
toNvxGuestArtifactPath,
toNvxGuestRunPath,
} from './run-layout';

const RUN_ID = 'a'.repeat(32);

describe('NVX run layout', () => {
it('derives every privileged resource name from one run ID', () => {
expect(createNvxRunLayout(RUN_ID)).toEqual({
runId: RUN_ID,
artifactSnapshotDirectory: `/run/awf-nvx/trusted-artifacts/run-${RUN_ID}`,
runDirectory: `/run/awf-nvx/runs/${RUN_ID}`,
cleanupRecordPath: `/run/awf-nvx/cleanup/${RUN_ID}.json`,
cgroupPath: `/sys/fs/cgroup/awf-nvx/${RUN_ID}`,
networkNamespace: `awfnvx-${RUN_ID}`,
});
});

it('rejects malformed IDs and independently altered paths', () => {
expect(() => createNvxRunLayout('../escape')).toThrow(/run ID/);
expect(() => assertNvxRunLayout({
...createNvxRunLayout(RUN_ID),
cgroupPath: '/sys/fs/cgroup/other',
})).toThrow(/cgroupPath/);
});

it('translates only paths contained by the canonical bind roots', () => {
const layout = createNvxRunLayout(RUN_ID);
expect(toNvxGuestArtifactPath(
layout,
`${layout.artifactSnapshotDirectory}/nvx.py`,
)).toBe(`${NVX_GUEST_ARTIFACT_ROOT}/nvx.py`);
expect(toNvxGuestRunPath(
layout,
`${layout.runDirectory}/scratch.ext4`,
)).toBe(`${NVX_GUEST_RUN_ROOT}/scratch.ext4`);
expect(() => toNvxGuestRunPath(layout, '/etc/shadow')).toThrow(/escapes/);
});
});
Loading
Loading