Skip to content

Commit 1270d5d

Browse files
authored
Merge pull request #958 from Dstack-TEE/codex/feat-kms-finalized-ethereum-auth
[STACKED on #957] feat(kms): authorize from finalized Ethereum snapshots
2 parents d041485 + 99c9fbb commit 1270d5d

2 files changed

Lines changed: 205 additions & 7 deletions

File tree

dstack/kms/auth-eth-bun/index.test.ts

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import openApiSpec from './openapi.json';
88
// Mock viem
99
const mockReadContract = vi.fn();
1010
const mockGetChainId = vi.fn();
11+
const mockGetBlockNumber = vi.fn();
1112

1213
vi.mock('viem', () => ({
1314
createPublicClient: vi.fn(() => ({
1415
readContract: mockReadContract,
1516
getChainId: mockGetChainId,
17+
getBlockNumber: mockGetBlockNumber,
1618
})),
1719
http: vi.fn(),
1820
getContract: vi.fn(),
@@ -26,6 +28,8 @@ beforeAll(async () => {
2628
process.env.ETH_RPC_URL = 'http://localhost:8545';
2729
process.env.KMS_CONTRACT_ADDR = '0x1234567890123456789012345678901234567890';
2830
process.env.PORT = '3001';
31+
process.env.ETH_CHAIN_ID = '1337';
32+
process.env.ETH_FINALITY_CONFIRMATIONS = '2';
2933

3034
// Import the app after mocking
3135
const indexModule = await import('./index.ts');
@@ -35,6 +39,8 @@ beforeAll(async () => {
3539
beforeEach(() => {
3640
// Reset mocks before each test
3741
vi.clearAllMocks();
42+
mockGetChainId.mockResolvedValue(1337);
43+
mockGetBlockNumber.mockResolvedValue(100n);
3844
});
3945

4046
describe('API Compatibility Tests', () => {
@@ -317,8 +323,9 @@ describe('API Compatibility Tests', () => {
317323
expect(data.isAllowed).toBe(false);
318324
expect(data.reason).toBe('authorization backend unavailable');
319325

320-
// Verify that console.error was called for real errors
321-
expect(consoleSpy).toHaveBeenCalledWith('error in KMS boot auth:', expect.any(Error));
326+
// Diagnostics identify the failing boundary without retaining backend details.
327+
expect(consoleSpy).toHaveBeenCalledWith('KMS authorization backend failed');
328+
expect(JSON.stringify(consoleSpy.mock.calls)).not.toContain('real error');
322329

323330
consoleSpy.mockRestore();
324331
});
@@ -403,3 +410,158 @@ describe('Hex Decoding Compatibility', () => {
403410
expect(response.status).toBe(200);
404411
});
405412
});
413+
414+
describe('Authorization freshness and domain binding', () => {
415+
const requestBody = {
416+
mrAggregated: '0x' + '11'.repeat(32),
417+
osImageHash: '0x' + '22'.repeat(32),
418+
appId: '0x' + '33'.repeat(20),
419+
composeHash: '0x' + '44'.repeat(32),
420+
instanceId: '0x' + '55'.repeat(20),
421+
deviceId: '0x' + '66'.repeat(32),
422+
};
423+
424+
const postApp = (body = requestBody) => appFetch(new Request(
425+
'http://localhost:3001/bootAuth/app',
426+
{
427+
method: 'POST',
428+
headers: { 'Content-Type': 'application/json' },
429+
body: JSON.stringify(body),
430+
},
431+
));
432+
433+
it('re-evaluates replayed payloads instead of caching an earlier allow', async () => {
434+
let decisions = 0;
435+
mockReadContract.mockImplementation((params) => {
436+
expect(params.address).toBe('0x1234567890123456789012345678901234567890');
437+
if (params.functionName === 'isAppAllowed') {
438+
decisions += 1;
439+
return decisions === 1 ? [true, 'initial allow'] : [false, 'policy changed'];
440+
}
441+
if (params.functionName === 'gatewayAppId') return 'gateway-app';
442+
throw new Error(`unexpected function ${params.functionName}`);
443+
});
444+
445+
const first = await postApp();
446+
const replay = await postApp();
447+
448+
expect(await first.json()).toMatchObject({ isAllowed: true, reason: 'initial allow' });
449+
expect(await replay.json()).toMatchObject({ isAllowed: false, reason: 'policy changed' });
450+
expect(decisions).toBe(2);
451+
});
452+
453+
it('binds changed measurements and identities into distinct contract arguments', async () => {
454+
const calls: unknown[] = [];
455+
mockReadContract.mockImplementation((params) => {
456+
if (params.functionName === 'isAppAllowed') {
457+
calls.push(params.args[0]);
458+
return [true, 'allowed'];
459+
}
460+
if (params.functionName === 'gatewayAppId') return 'gateway-app';
461+
throw new Error(`unexpected function ${params.functionName}`);
462+
});
463+
464+
await postApp();
465+
await postApp({ ...requestBody, composeHash: '0x' + '77'.repeat(32) });
466+
await postApp({ ...requestBody, appId: '0x' + '88'.repeat(20) });
467+
468+
expect(calls).toHaveLength(3);
469+
expect(calls[0]).not.toEqual(calls[1]);
470+
expect(calls[0]).not.toEqual(calls[2]);
471+
});
472+
473+
it('fails closed during backend interruption and succeeds after recovery', async () => {
474+
mockReadContract.mockRejectedValueOnce(new Error('backend unavailable'));
475+
const interrupted = await postApp();
476+
expect(await interrupted.json()).toEqual({
477+
isAllowed: false,
478+
gatewayAppId: '',
479+
reason: 'authorization backend unavailable',
480+
});
481+
482+
mockReadContract.mockImplementation((params) => {
483+
if (params.functionName === 'isAppAllowed') return [true, 'recovered'];
484+
if (params.functionName === 'gatewayAppId') return 'gateway-app';
485+
throw new Error(`unexpected function ${params.functionName}`);
486+
});
487+
const recovered = await postApp();
488+
expect(await recovered.json()).toMatchObject({ isAllowed: true, reason: 'recovered' });
489+
});
490+
});
491+
492+
493+
describe('Ethereum finalized snapshot authorization', () => {
494+
const requestBody = {
495+
mrAggregated: '0x' + '11'.repeat(32),
496+
osImageHash: '0x' + '22'.repeat(32),
497+
appId: '0x' + '33'.repeat(20),
498+
composeHash: '0x' + '44'.repeat(32),
499+
instanceId: '0x' + '55'.repeat(20),
500+
deviceId: '0x' + '66'.repeat(32),
501+
};
502+
503+
const authorize = () => appFetch(new Request('http://localhost:3001/bootAuth/app', {
504+
method: 'POST',
505+
headers: { 'Content-Type': 'application/json' },
506+
body: JSON.stringify(requestBody),
507+
}));
508+
509+
it('reads the decision and gateway identity from one confirmation-depth snapshot', async () => {
510+
mockGetBlockNumber.mockResolvedValue(100n);
511+
mockReadContract.mockImplementation((params) => {
512+
expect(params.blockNumber).toBe(98n);
513+
if (params.functionName === 'isAppAllowed') return [true, 'finalized allow'];
514+
if (params.functionName === 'gatewayAppId') return 'gateway-app';
515+
throw new Error(`unexpected function ${params.functionName}`);
516+
});
517+
518+
const response = await authorize();
519+
expect(await response.json()).toMatchObject({ isAllowed: true, reason: 'finalized allow' });
520+
expect(mockGetBlockNumber).toHaveBeenCalledTimes(1);
521+
});
522+
523+
it('re-evaluates the canonical finalized snapshot after a short reorg', async () => {
524+
mockGetBlockNumber.mockResolvedValueOnce(100n).mockResolvedValueOnce(101n);
525+
let decisions = 0;
526+
const observedBlocks: bigint[] = [];
527+
mockReadContract.mockImplementation((params) => {
528+
if (params.functionName === 'isAppAllowed') {
529+
observedBlocks.push(params.blockNumber);
530+
decisions += 1;
531+
return decisions === 1 ? [true, 'old canonical allow'] : [false, 'new canonical deny'];
532+
}
533+
if (params.functionName === 'gatewayAppId') return 'gateway-app';
534+
throw new Error(`unexpected function ${params.functionName}`);
535+
});
536+
537+
const before = await authorize();
538+
const after = await authorize();
539+
expect(await before.json()).toMatchObject({ isAllowed: true });
540+
expect(await after.json()).toMatchObject({ isAllowed: false, reason: 'new canonical deny' });
541+
expect(observedBlocks).toEqual([98n, 99n]);
542+
});
543+
544+
it.each([
545+
['wrong chain', () => mockGetChainId.mockResolvedValue(1)],
546+
['stale head', () => mockGetBlockNumber.mockResolvedValue(1n)],
547+
['head timeout', () => mockGetBlockNumber.mockRejectedValue(new Error('timeout'))],
548+
])('fails closed for %s and recovers without retained decisions', async (_name, inject) => {
549+
inject();
550+
const failed = await authorize();
551+
expect(await failed.json()).toEqual({
552+
isAllowed: false,
553+
gatewayAppId: '',
554+
reason: 'authorization backend unavailable',
555+
});
556+
557+
mockGetChainId.mockResolvedValue(1337);
558+
mockGetBlockNumber.mockResolvedValue(102n);
559+
mockReadContract.mockImplementation((params) => {
560+
if (params.functionName === 'isAppAllowed') return [true, 'recovered'];
561+
if (params.functionName === 'gatewayAppId') return 'gateway-app';
562+
throw new Error(`unexpected function ${params.functionName}`);
563+
});
564+
const recovered = await authorize();
565+
expect(await recovered.json()).toMatchObject({ isAllowed: true, reason: 'recovered' });
566+
});
567+
});

dstack/kms/auth-eth-bun/index.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,31 @@ const DSTACK_KMS_ABI = [
113113
class EthereumBackend {
114114
private client: ReturnType<typeof createPublicClient>;
115115
private kmsContractAddr: Address;
116+
private expectedChainId?: number;
117+
private finalityConfirmations: bigint;
116118

117-
constructor(client: ReturnType<typeof createPublicClient>, kmsContractAddr: string) {
119+
constructor(
120+
client: ReturnType<typeof createPublicClient>,
121+
kmsContractAddr: string,
122+
expectedChainId: number | undefined,
123+
finalityConfirmations: bigint,
124+
) {
118125
this.client = client;
119126
this.kmsContractAddr = kmsContractAddr as Address;
127+
this.expectedChainId = expectedChainId;
128+
this.finalityConfirmations = finalityConfirmations;
129+
}
130+
131+
private async finalizedBlockNumber(): Promise<bigint> {
132+
const chainId = await this.client.getChainId();
133+
if (this.expectedChainId !== undefined && chainId !== this.expectedChainId) {
134+
throw new Error('authorization backend chain ID mismatch');
135+
}
136+
const head = await this.client.getBlockNumber();
137+
if (head < this.finalityConfirmations) {
138+
throw new Error('authorization backend has not reached configured finality');
139+
}
140+
return head - this.finalityConfirmations;
120141
}
121142

122143
private decodeHex(hex: string, sz: number = 32): Hex {
@@ -142,28 +163,32 @@ class EthereumBackend {
142163
advisoryIds: bootInfo.advisoryIds || []
143164
};
144165

166+
const blockNumber = await this.finalizedBlockNumber();
145167
let response;
146168
if (isKms) {
147169
response = await this.client.readContract({
148170
address: this.kmsContractAddr,
149171
abi: DSTACK_KMS_ABI,
150172
functionName: 'isKmsAllowed',
151-
args: [bootInfoStruct]
173+
args: [bootInfoStruct],
174+
blockNumber
152175
});
153176
} else {
154177
response = await this.client.readContract({
155178
address: this.kmsContractAddr,
156179
abi: DSTACK_KMS_ABI,
157180
functionName: 'isAppAllowed',
158-
args: [bootInfoStruct]
181+
args: [bootInfoStruct],
182+
blockNumber
159183
});
160184
}
161185

162186
const [isAllowed, reason] = response;
163187
const gatewayAppId = await this.client.readContract({
164188
address: this.kmsContractAddr,
165189
abi: DSTACK_KMS_ABI,
166-
functionName: 'gatewayAppId'
190+
functionName: 'gatewayAppId',
191+
blockNumber
167192
});
168193

169194
return {
@@ -203,10 +228,21 @@ const app = new Hono();
203228
// initialize ethereum backend
204229
const rpcUrl = process.env.ETH_RPC_URL || 'http://localhost:8545';
205230
const kmsContractAddr = process.env.KMS_CONTRACT_ADDR || '0x0000000000000000000000000000000000000000';
231+
const parseNonNegativeInteger = (name: string, value: string | undefined): number | undefined => {
232+
if (value === undefined || value === '') return undefined;
233+
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) throw new Error(`${name} must be a non-negative integer`);
234+
const parsed = Number(value);
235+
if (!Number.isSafeInteger(parsed)) throw new Error(`${name} exceeds the safe integer range`);
236+
return parsed;
237+
};
238+
const expectedChainId = parseNonNegativeInteger('ETH_CHAIN_ID', process.env.ETH_CHAIN_ID);
239+
const finalityConfirmations = BigInt(
240+
parseNonNegativeInteger('ETH_FINALITY_CONFIRMATIONS', process.env.ETH_FINALITY_CONFIRMATIONS) ?? 0,
241+
);
206242
const client = createPublicClient({
207243
transport: http(rpcUrl)
208244
});
209-
const ethereum = new EthereumBackend(client, kmsContractAddr);
245+
const ethereum = new EthereumBackend(client, kmsContractAddr, expectedChainId, finalityConfirmations);
210246

211247
const publicRpcEndpoint = (value: string): string => {
212248
try {

0 commit comments

Comments
 (0)