diff --git a/docs/nvx-security-design.md b/docs/nvx-security-design.md index 24ba06b1d..8896f4641 100644 --- a/docs/nvx-security-design.md +++ b/docs/nvx-security-design.md @@ -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 diff --git a/src/nvx/cleanup-record.test.ts b/src/nvx/cleanup-record.test.ts index c29ab6a1a..33ea92922 100644 --- a/src/nvx/cleanup-record.test.ts +++ b/src/nvx/cleanup-record.test.ts @@ -34,7 +34,7 @@ function record() { inode: '11', }, networkNamespace: { - name: 'awfnvx-run-1', + name: `awfnvx-${RUN_ID}`, inode: '4026533000', }, mountNamespaceInode: '4026533001', diff --git a/src/nvx/cleanup-record.ts b/src/nvx/cleanup-record.ts index 8d73d2b27..14afd395e 100644 --- a/src/nvx/cleanup-record.ts +++ b/src/nvx/cleanup-record.ts @@ -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; @@ -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}`); } @@ -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) ) @@ -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 ? { @@ -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'], diff --git a/src/nvx/confinement.test.ts b/src/nvx/confinement.test.ts index 9114a4502..c2ad6396e 100644 --- a/src/nvx/confinement.test.ts +++ b/src/nvx/confinement.test.ts @@ -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', () => { diff --git a/src/nvx/confinement.ts b/src/nvx/confinement.ts index 0d3dd2107..7eb95631c 100644 --- a/src/nvx/confinement.ts +++ b/src/nvx/confinement.ts @@ -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'); diff --git a/src/nvx/index.ts b/src/nvx/index.ts index 9bd92b94e..9911fd657 100644 --- a/src/nvx/index.ts +++ b/src/nvx/index.ts @@ -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, diff --git a/src/nvx/paths.ts b/src/nvx/paths.ts index 21e6af522..155b5e69c 100644 --- a/src/nvx/paths.ts +++ b/src/nvx/paths.ts @@ -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'; diff --git a/src/nvx/preflight.test.ts b/src/nvx/preflight.test.ts index 2bf7cbc06..c0c35ae50 100644 --- a/src/nvx/preflight.test.ts +++ b/src/nvx/preflight.test.ts @@ -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', @@ -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`, }; } @@ -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); }); @@ -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(); + }); }); diff --git a/src/nvx/preflight.ts b/src/nvx/preflight.ts index 27339fe42..a74579d28 100644 --- a/src/nvx/preflight.ts +++ b/src/nvx/preflight.ts @@ -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 = [ @@ -41,6 +46,7 @@ export interface NvxArtifactSnapshot extends NvxArtifactPaths { } export interface NvxPreflightOptions { + readonly runId: string; readonly expectedReleaseTag: string; readonly manifestPath: string; readonly artifactManifestBundlePath: string; @@ -75,6 +81,7 @@ export interface NvxPreflightDependencies { ): Promise; createSnapshot( options: NvxPreflightOptions, + layout: NvxRunLayout, ): Promise; removeSnapshot(directory: string): Promise; } @@ -121,6 +128,8 @@ export async function runNvxPreflight( options: NvxPreflightOptions, dependencies: NvxPreflightDependencies = defaultDependencies, ): Promise { + 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'); } @@ -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, @@ -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, @@ -266,10 +301,11 @@ async function assertTrustedFile( async function createArtifactSnapshot( options: NvxPreflightOptions, + layout: NvxRunLayout, ): Promise { 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; for (const name of Object.keys(options.artifacts) as NvxTrustedArtifactName[]) { @@ -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, diff --git a/src/nvx/run-layout.test.ts b/src/nvx/run-layout.test.ts new file mode 100644 index 000000000..e50155b70 --- /dev/null +++ b/src/nvx/run-layout.test.ts @@ -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/); + }); +}); diff --git a/src/nvx/run-layout.ts b/src/nvx/run-layout.ts new file mode 100644 index 000000000..531411a6b --- /dev/null +++ b/src/nvx/run-layout.ts @@ -0,0 +1,90 @@ +import * as path from 'path'; +import { + NVX_CGROUP_ROOT, + NVX_CLEANUP_ROOT, + NVX_RUN_DIRECTORY_ROOT, + NVX_TRUSTED_ARTIFACT_ROOT, +} from './paths'; + +export const NVX_GUEST_ARTIFACT_ROOT = '/opt/awf-nvx'; +export const NVX_GUEST_RUN_ROOT = '/run/awf-nvx'; + +export interface NvxRunLayout { + readonly runId: string; + readonly artifactSnapshotDirectory: string; + readonly runDirectory: string; + readonly cleanupRecordPath: string; + readonly cgroupPath: string; + readonly networkNamespace: string; +} + +export function createNvxRunLayout(runId: string): NvxRunLayout { + assertNvxRunId(runId); + return { + runId, + artifactSnapshotDirectory: path.join(NVX_TRUSTED_ARTIFACT_ROOT, `run-${runId}`), + runDirectory: path.join(NVX_RUN_DIRECTORY_ROOT, runId), + cleanupRecordPath: path.join(NVX_CLEANUP_ROOT, `${runId}.json`), + cgroupPath: path.join(NVX_CGROUP_ROOT, runId), + networkNamespace: `awfnvx-${runId}`, + }; +} + +export function assertNvxRunLayout(layout: NvxRunLayout): void { + const expected = createNvxRunLayout(layout.runId); + for (const key of [ + 'artifactSnapshotDirectory', + 'runDirectory', + 'cleanupRecordPath', + 'cgroupPath', + 'networkNamespace', + ] as const) { + if (layout[key] !== expected[key]) { + throw new Error(`NVX run layout ${key} must be ${expected[key]}`); + } + } +} + +export function toNvxGuestArtifactPath( + layout: NvxRunLayout, + hostPath: string, +): string { + assertNvxRunLayout(layout); + return translatePath( + layout.artifactSnapshotDirectory, + NVX_GUEST_ARTIFACT_ROOT, + hostPath, + 'artifact', + ); +} + +export function toNvxGuestRunPath( + layout: NvxRunLayout, + hostPath: string, +): string { + assertNvxRunLayout(layout); + return translatePath(layout.runDirectory, NVX_GUEST_RUN_ROOT, hostPath, 'run'); +} + +export function assertNvxRunId(runId: string): void { + if (!/^[a-f0-9]{32}$/.test(runId)) { + throw new Error('NVX run ID must be 32 lowercase hexadecimal characters'); + } +} + +function translatePath( + hostRoot: string, + guestRoot: string, + hostPath: string, + label: string, +): string { + const relative = path.relative(hostRoot, path.resolve(hostPath)); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`NVX ${label} path escapes its run layout: ${hostPath}`); + } + return relative ? path.posix.join(guestRoot, ...relative.split(path.sep)) : guestRoot; +}