Skip to content

Commit d94a0d6

Browse files
committed
fix(kms): deny on an empty device allowlist in auth-simple
1 parent 3d86cf8 commit d94a0d6

3 files changed

Lines changed: 83 additions & 8 deletions

File tree

‎dstack/kms/auth-simple/README.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,11 @@ raw NitroTPM evidence by itself.
102102
| `allowedTcbStatuses` | No | Allowed verifier-derived TCB status strings. Defaults to `["UpToDate"]`; non-up-to-date SNP/TDX statuses remain fail-closed unless explicitly allowlisted for testing. |
103103
| `allowedAdvisoryIds` | No | Advisory IDs permitted in `advisoryIds`. Defaults to `[]`, which rejects any advisory. |
104104
| `kms.mrAggregated` | Yes | Allowed KMS early aggregate MR values (boot-mr-done). |
105-
| `kms.devices` | No | Allowed KMS device IDs |
106-
| `kms.allowAnyDevice` | No | If true, skip device ID check for KMS |
105+
| `kms.devices` | No | Allowed KMS device IDs. Enforced unless `kms.allowAnyDevice` is true, so an empty list denies every device. |
106+
| `kms.allowAnyDevice` | No | If true, skip the device ID check for KMS. Defaults to false, which enforces `kms.devices`. |
107107
| `apps.<appId>.composeHashes` | No | Allowed compose hashes for this app |
108-
| `apps.<appId>.devices` | No | Allowed device IDs for this app |
109-
| `apps.<appId>.allowAnyDevice` | No | If true, skip device ID check for this app |
108+
| `apps.<appId>.devices` | No | Allowed device IDs for this app. Enforced unless `allowAnyDevice` is true, so an empty list denies every device. |
109+
| `apps.<appId>.allowAnyDevice` | No | If true, skip the device ID check for this app. Defaults to false, which enforces `devices`. |
110110

111111
For experimental AMD SEV-SNP dry-run authorization, keep the default fail-closed TCB policy unless you intentionally want the auth webhook to accept non-up-to-date verifier-derived SNP `BootInfo`. To exercise the dry-run path without enabling key release, allowlist the recomputed SNP `mrAggregated`, `osImageHash`, app/compose identity, device/chip identity, and any non-default `allowedTcbStatuses`/`allowedAdvisoryIds` values explicitly. KMS still rejects SNP before returning app keys, KMS keys, or app certificates.
112112

@@ -208,7 +208,7 @@ KMS boot authorization.
208208
2. Every `advisoryIds` entry must be listed in `allowedAdvisoryIds` (default: none allowed)
209209
3. `osImageHash` must be in `osImages` array
210210
4. `mrAggregated` must be in the `kms.mrAggregated` early/boot-mr-done allowlist
211-
5. `deviceId` must be in `kms.devices` unless `allowAnyDevice` is true
211+
5. `deviceId` must be in `kms.devices` unless `allowAnyDevice` is true. An empty `kms.devices` with `allowAnyDevice: false` denies every device, matching `DstackApp.isAppAllowed`.
212212

213213
### App Boot Validation
214214

@@ -217,7 +217,7 @@ KMS boot authorization.
217217
3. `osImageHash` must be in `osImages` array
218218
4. `appId` must exist in `apps` object
219219
5. `composeHash` must be in app's `composeHashes` array
220-
6. `deviceId` must be in app's `devices` (unless `allowAnyDevice` is true)
220+
6. `deviceId` must be in app's `devices` unless `allowAnyDevice` is true. An empty `devices` with `allowAnyDevice: false` denies every device, matching `DstackApp.isAppAllowed`.
221221

222222
## Hot Reload
223223

‎dstack/kms/auth-simple/index.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,4 +421,76 @@ describe('auth-simple', () => {
421421
expect(json.isAllowed).toBe(true);
422422
});
423423
});
424+
425+
// `DstackApp.isAppAllowed` denies when `!allowAnyDevice && !allowedDeviceIds[id]`,
426+
// so an empty allowlist denies every device. auth-simple must agree: an
427+
// operator who writes `allowAnyDevice: false` has asked for a restriction,
428+
// and an empty list is the most restrictive one, not the absence of one.
429+
describe('device restriction parity with DstackApp.sol', () => {
430+
const OS_IMAGE = '0x1fbb0cf9cc6cfbf23d6b779776fabad2c5403d643badb9e5e238615e4960a78a';
431+
432+
// The on-chain rule, transcribed from DstackApp.sol:209.
433+
const onChainAllows = (allowAnyDevice: boolean, devices: string[], deviceId: string) =>
434+
allowAnyDevice || devices.map((d) => d.toLowerCase()).includes(deviceId.toLowerCase());
435+
436+
const appDecision = async (allowAnyDevice: boolean, devices: string[]) => {
437+
writeTestConfig({
438+
gatewayAppId: '0xgateway',
439+
osImages: [OS_IMAGE],
440+
apps: {
441+
'0xapp123': { composeHashes: ['0xcompose456'], devices, allowAnyDevice }
442+
}
443+
});
444+
const res = await app.fetch(new Request('http://localhost/bootAuth/app', {
445+
method: 'POST',
446+
headers: { 'Content-Type': 'application/json' },
447+
body: JSON.stringify(baseBootInfo)
448+
}));
449+
return (await res.json()).isAllowed;
450+
};
451+
452+
const kmsDecision = async (allowAnyDevice: boolean, devices: string[]) => {
453+
writeTestConfig({
454+
gatewayAppId: '0xgateway',
455+
osImages: [OS_IMAGE],
456+
kms: { mrAggregated: ['0xabc123'], devices, allowAnyDevice }
457+
});
458+
const res = await app.fetch(new Request('http://localhost/bootAuth/kms', {
459+
method: 'POST',
460+
headers: { 'Content-Type': 'application/json' },
461+
body: JSON.stringify(baseBootInfo)
462+
}));
463+
return (await res.json()).isAllowed;
464+
};
465+
466+
it('denies an app when the device restriction is on and the list is empty', async () => {
467+
expect(await appDecision(false, [])).toBe(false);
468+
});
469+
470+
it('denies the KMS when the device restriction is on and the list is empty', async () => {
471+
expect(await kmsDecision(false, [])).toBe(false);
472+
});
473+
474+
it('matches the on-chain decision for every allowAnyDevice/devices pairing', async () => {
475+
const cases: Array<[boolean, string[]]> = [
476+
[false, []],
477+
[false, ['0xdevice999']],
478+
[false, ['0xotherdevice']],
479+
[true, []],
480+
[true, ['0xotherdevice']]
481+
];
482+
483+
for (const [allowAnyDevice, devices] of cases) {
484+
const expected = onChainAllows(allowAnyDevice, devices, baseBootInfo.deviceId);
485+
expect(
486+
await appDecision(allowAnyDevice, devices),
487+
`app: allowAnyDevice=${allowAnyDevice} devices=${JSON.stringify(devices)}`
488+
).toBe(expected);
489+
expect(
490+
await kmsDecision(allowAnyDevice, devices),
491+
`kms: allowAnyDevice=${allowAnyDevice} devices=${JSON.stringify(devices)}`
492+
).toBe(expected);
493+
}
494+
});
495+
});
424496
});

‎dstack/kms/auth-simple/index.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const BootResponseSchema = z.object({
2828
});
2929

3030
// config schema
31+
// `allowAnyDevice: false` means the device allowlist is enforced, and an empty
32+
// allowlist therefore denies every device — the same rule as
33+
// `DstackApp.isAppAllowed`. Opting out of the restriction is `allowAnyDevice: true`.
3134
const AppConfigSchema = z.object({
3235
composeHashes: z.array(z.string()).default([]),
3336
devices: z.array(z.string()).default([]),
@@ -149,7 +152,7 @@ class ConfigBackend {
149152
// check device ID
150153
if (!config.kms.allowAnyDevice) {
151154
const allowedDevices = config.kms.devices.map(normalizeHex);
152-
if (allowedDevices.length > 0 && !allowedDevices.includes(deviceId)) {
155+
if (!allowedDevices.includes(deviceId)) {
153156
return {
154157
isAllowed: false,
155158
reason: 'KMS is not allowed to boot on this device',
@@ -195,7 +198,7 @@ class ConfigBackend {
195198
// check device ID
196199
if (!appConfig.allowAnyDevice) {
197200
const allowedDevices = appConfig.devices.map(normalizeHex);
198-
if (allowedDevices.length > 0 && !allowedDevices.includes(deviceId)) {
201+
if (!allowedDevices.includes(deviceId)) {
199202
return {
200203
isAllowed: false,
201204
reason: 'app is not allowed to boot on this device',

0 commit comments

Comments
 (0)