Skip to content
Open
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
6 changes: 4 additions & 2 deletions docs/onchain-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ export RPC_URL="https://eth-sepolia.g.alchemy.com/v2/<your-alchemy-key>"

## Configure KMS for On-Chain Auth

The KMS CVM includes an auth-api service that connects to your DstackKms contract. Configure it via environment variables in the KMS CVM:
The KMS CVM includes an auth-api service that connects to your DstackKms contract. The default [KMS Compose](../dstack/kms/dstack-app/docker-compose.yaml) reads Phala L2 through a proof-verifying light client; see the [light-client guide](../dstack/kms/light-client/README.md). Configure it via environment variables in the KMS CVM:

```bash
KMS_CONTRACT_ADDR=<your-dstack-kms-contract-address>
ETH_RPC_URL=<ethereum-rpc-endpoint>
ETH_MAX_BLOCK_AGE_SECONDS=300
```

For other chains, run auth-api with `ETH_RPC_URL` pointing to your RPC endpoint.

The auth-api validates boot requests against the smart contract. See [Deployment Guide](./deployment.md#2-deploy-kms-as-cvm) for complete setup instructions.

## Whitelist OS Image
Expand Down
7 changes: 6 additions & 1 deletion dstack/kms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ certificate. `core.onboard.public_key_handover = true` (the default) keeps

### Deployment
The first two components are deployed as an dstack app on dstack in Local-Key-Provider mode.
The docker compose file would look like [this](dstack-app/docker-compose.yaml).
The default [Docker Compose file](dstack-app/docker-compose.yaml) uses the
[Phala L2 light client](light-client/README.md). Set `KMS_CONTRACT_ADDR` to a
Phala contract compatible with the current auth API, and use a published KMS
image digest; deployment does not build images. Reads use proof-verified state
signed by the L1-registered Phala sequencer, not L1-settled state. See the
light-client guide for deployment requirements.

The solidity contracts are deployed on an ethereum compatible chain.

Expand Down
2 changes: 1 addition & 1 deletion dstack/kms/auth-eth/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/main.test.ts'],
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
collectCoverageFrom: [
'src/**/*.ts',
Expand Down
85 changes: 85 additions & 0 deletions dstack/kms/auth-eth/src/ethereum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
// SPDX-License-Identifier: Apache-2.0

import { ethers } from 'ethers';
import { EthereumBackend } from './ethereum';
import { BootInfo } from './types';

jest.mock('ethers', () => ({
ethers: { ...jest.requireActual('ethers').ethers, Contract: jest.fn() },
}));

const bootInfo: BootInfo = {
appId: '00'.repeat(20), instanceId: '00'.repeat(20),
composeHash: '00'.repeat(32), deviceId: '00'.repeat(32),
mrAggregated: '00'.repeat(32), mrSystem: '00'.repeat(32),
osImageHash: '00'.repeat(32), tcbStatus: 'UpToDate', advisoryIds: [],
};

describe('verified authorization reads', () => {
const contract = {
isAppAllowed: jest.fn(), isKmsAllowed: jest.fn(), gatewayAppId: jest.fn(),
};
const provider = {
getNetwork: jest.fn(), getBlockNumber: jest.fn(), getBlock: jest.fn(),
};
let backend: EthereumBackend;

beforeEach(() => {
jest.resetAllMocks();
(ethers.Contract as unknown as jest.Mock).mockReturnValue(contract);
provider.getNetwork.mockResolvedValue({ chainId: 2035n });
provider.getBlockNumber.mockResolvedValue(100);
provider.getBlock.mockResolvedValue({ number: 98, timestamp: Math.floor(Date.now() / 1000) - 20 });
contract.isAppAllowed.mockResolvedValue([true, '']);
contract.isKmsAllowed.mockResolvedValue([true, '']);
contract.gatewayAppId.mockResolvedValue('gateway');
backend = new EthereumBackend(provider as unknown as ethers.JsonRpcProvider,
ethers.ZeroAddress, { chainId: 2035, blockLag: 2, maxAgeSeconds: 60 });
});

it.each([false, true])('pins all boot reads to the same fresh block (kms=%s)', async (isKms) => {
await expect(backend.checkBoot(bootInfo, isKms)).resolves.toEqual({
isAllowed: true, reason: '', gatewayAppId: 'gateway',
});
expect(provider.getBlock).toHaveBeenCalledWith(98);
expect(isKms ? contract.isKmsAllowed : contract.isAppAllowed)
.toHaveBeenCalledWith(expect.any(Object), { blockTag: 98 });
expect(contract.gatewayAppId).toHaveBeenCalledWith({ blockTag: 98 });
});

it('rejects a different chain before contract execution', async () => {
provider.getNetwork.mockResolvedValue({ chainId: 1n });
await expect(backend.checkBoot(bootInfo, false)).rejects.toThrow('chain ID mismatch');
expect(contract.isAppAllowed).not.toHaveBeenCalled();
});

it.each([null, { number: 98, timestamp: 0 }, { number: 98, timestamp: 9999999999 }])(
'rejects unavailable, stale, or future state: %j', async (block) => {
provider.getBlock.mockResolvedValue(block);
await expect(backend.checkBoot(bootInfo, false)).rejects.toThrow('not fresh');
expect(contract.isAppAllowed).not.toHaveBeenCalled();
},
);

it('rejects state that expires while contract calls are in flight', async () => {
const started = Date.now();
const clock = jest.spyOn(Date, 'now').mockReturnValue(started);
contract.gatewayAppId.mockImplementation(async () => {
clock.mockReturnValue(started + 61_000);
return 'gateway';
});
try {
await expect(backend.checkBoot(bootInfo, true)).rejects.toThrow('not fresh');
} finally {
clock.mockRestore();
}
});

it('does not fallback when proof execution fails', async () => {
contract.isAppAllowed.mockRejectedValue(new Error('invalid proof'));
await expect(backend.checkBoot(bootInfo, false)).rejects.toThrow('invalid proof');
expect(contract.isAppAllowed).toHaveBeenCalledTimes(1);
expect(contract.gatewayAppId).not.toHaveBeenCalled();
});
});
38 changes: 33 additions & 5 deletions dstack/kms/auth-eth/src/ethereum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ const DSTACK_KMS_ABI = [
"function appImplementation() view returns (address)"
];

export interface VerifiedReadPolicy {
chainId: number;
blockLag: number;
maxAgeSeconds: number;
}

export class EthereumBackend {
private provider: ethers.JsonRpcProvider;
private kmsContract: ethers.Contract;

constructor(provider: ethers.JsonRpcProvider, kmsContractAddr: string) {
constructor(provider: ethers.JsonRpcProvider, kmsContractAddr: string, private readPolicy?: VerifiedReadPolicy) {
this.provider = provider;
this.kmsContract = new ethers.Contract(
ethers.getAddress(kmsContractAddr),
Expand All @@ -26,6 +32,28 @@ export class EthereumBackend {
);
}

private async authorizationBlock(): Promise<{ blockTag: ethers.BlockTag; timestamp?: number }> {
if (!this.readPolicy) return { blockTag: await this.provider.getBlockNumber() };
const { chainId, blockLag } = this.readPolicy;
if (Number((await this.provider.getNetwork()).chainId) !== chainId) {
throw new Error('authorization chain ID mismatch');
}
const head = await this.provider.getBlockNumber();
if (head < blockLag) throw new Error('authorization head unavailable');
const block = await this.provider.getBlock(head - blockLag);
if (!block) throw new Error('authorization block is not fresh');
this.checkFreshness(block.timestamp);
return { blockTag: block.number, timestamp: block.timestamp };
}

private checkFreshness(timestamp?: number): void {
if (!this.readPolicy || timestamp === undefined) return;
const now = Math.floor(Date.now() / 1000);
if (now - timestamp > this.readPolicy.maxAgeSeconds || timestamp > now + 5) {
throw new Error('authorization block is not fresh');
}
}

private decodeHex(hex: string, sz: number = 32): string {
// Remove '0x' prefix if present
hex = hex.startsWith('0x') ? hex.slice(2) : hex;
Expand All @@ -50,8 +78,7 @@ export class EthereumBackend {
tcbStatus: bootInfo.tcbStatus,
advisoryIds: bootInfo.advisoryIds
};
// Read the whole decision from one block.
const blockTag = await this.provider.getBlockNumber();
const { blockTag, timestamp } = await this.authorizationBlock();
let response;
if (isKms) {
response = await this.kmsContract.isKmsAllowed(bootInfoStruct, { blockTag });
Expand All @@ -60,6 +87,7 @@ export class EthereumBackend {
}
const [isAllowed, reason] = response;
const gatewayAppId = await this.kmsContract.gatewayAppId({ blockTag });
this.checkFreshness(timestamp);
return {
isAllowed,
reason,
Expand All @@ -68,7 +96,7 @@ export class EthereumBackend {
}

async getGatewayAppId(): Promise<string> {
return await this.kmsContract.gatewayAppId();
return await this.kmsContract.gatewayAppId({ blockTag: (await this.authorizationBlock()).blockTag });
}

async getChainId(): Promise<number> {
Expand All @@ -77,6 +105,6 @@ export class EthereumBackend {
}

async getAppImplementation(): Promise<string> {
return await this.kmsContract.appImplementation();
return await this.kmsContract.appImplementation({ blockTag: (await this.authorizationBlock()).blockTag });
}
}
22 changes: 22 additions & 0 deletions dstack/kms/auth-eth/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,25 @@ describe('Server', () => {
});
});
});

describe('verified-read configuration', () => {
const settings = ['ETH_CHAIN_ID', 'ETH_BLOCK_LAG', 'ETH_MAX_BLOCK_AGE_SECONDS'];
const saved = settings.map(name => process.env[name]);

afterEach(() => {
settings.forEach((name, index) => {
if (saved[index] === undefined) delete process.env[name];
else process.env[name] = saved[index];
});
});

it.each(['ETH_BLOCK_LAG', 'ETH_MAX_BLOCK_AGE_SECONDS'])(
'requires an explicit %s when verified reads are enabled', async missing => {
Object.assign(process.env, {
ETH_CHAIN_ID: '2035', ETH_BLOCK_LAG: '2', ETH_MAX_BLOCK_AGE_SECONDS: '60',
});
delete process.env[missing];
await expect(build()).rejects.toThrow(`invalid ${missing}`);
},
);
});
17 changes: 16 additions & 1 deletion dstack/kms/auth-eth/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,22 @@ export async function build(): Promise<FastifyInstance> {
const rpcUrl = process.env.ETH_RPC_URL || 'http://localhost:8545';
const kmsContractAddr = process.env.KMS_CONTRACT_ADDR || '0x0000000000000000000000000000000000000000';
const provider = new ethers.JsonRpcProvider(rpcUrl);
server.decorate('ethereum', new EthereumBackend(provider, kmsContractAddr));
const integerSetting = (name: string): number => {
const value = process.env[name] ?? '';
if (!/^(0|[1-9][0-9]*)$/.test(value) || !Number.isSafeInteger(Number(value))) {
throw new Error(`invalid ${name}`);
}
return Number(value);
};
const readPolicy = process.env.ETH_CHAIN_ID === undefined ? undefined : {
chainId: integerSetting('ETH_CHAIN_ID'),
blockLag: integerSetting('ETH_BLOCK_LAG'),
maxAgeSeconds: integerSetting('ETH_MAX_BLOCK_AGE_SECONDS'),
};
if (!readPolicy) {
server.log.warn('ETH_CHAIN_ID is unset: authorization reads skip chain ID and freshness checks');
}
server.decorate('ethereum', new EthereumBackend(provider, kmsContractAddr, readPolicy));

const publicRpcEndpoint = (value: string): string => {
try {
Expand Down
26 changes: 26 additions & 0 deletions dstack/kms/dstack-app/builder/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ RUN rustup target add x86_64-unknown-linux-musl
RUN cd repo/dstack && cargo build --release --locked -p dstack-kms --target x86_64-unknown-linux-musl
RUN echo "${DSTACK_REV}" > /build/.GIT_REV

FROM rust:1.94.1-bookworm@sha256:6ae102bdbf528294bc79ad6e1fae682f6f7c2a6e6621506ba959f9685b308a55 AS helios-builder
# Vendored OpenSSL embeds its build date unless SOURCE_DATE_EPOCH is set.
ARG SOURCE_DATE_EPOCH
WORKDIR /build/helios
RUN git init && git remote add origin https://github.com/Dstack-TEE/helios.git \
&& git fetch --depth 1 origin ff3820aba2f218c75fae6b542b6067580e9f4ec4 \
&& git checkout --detach FETCH_HEAD
RUN cargo test --locked --release -p helios-opstack --lib \
&& cargo build --locked --release -p helios-cli -p helios-opstack --bins \
&& install -D target/release/helios /out/helios \
&& install -D target/release/opstack-server /out/opstack-server \
&& rm -rf target

FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d AS auth-builder
WORKDIR /app
COPY --from=kms-builder /build/repo/dstack/kms/auth-eth/package*.json ./
RUN npm ci --ignore-scripts
COPY --from=kms-builder /build/repo/dstack/kms/auth-eth/tsconfig.json ./
COPY --from=kms-builder /build/repo/dstack/kms/auth-eth/src/ src/
RUN npm run build && npm prune --omit=dev --ignore-scripts

FROM debian:bookworm@sha256:0d8498a0e9e6a60011df39aab78534cfe940785e7c59d19dfae1eb53ea59babe
COPY --from=build-shared /pin-packages.sh /build/
COPY ./shared/pinned-packages.txt /build/
Expand All @@ -41,6 +62,11 @@ RUN ./pin-packages.sh ./pinned-packages.txt && \
&& rm -rf /var/lib/apt/lists/* /var/log/* /var/cache/ldconfig/aux-cache
WORKDIR /
COPY --from=kms-builder /build/repo/dstack/target/x86_64-unknown-linux-musl/release/dstack-kms /usr/local/bin/dstack-kms
COPY --from=helios-builder /out/helios /usr/local/bin/helios
COPY --from=helios-builder /out/opstack-server /usr/local/bin/opstack-server
COPY --from=auth-builder /usr/local/bin/node /usr/local/bin/node
COPY --from=auth-builder /app/dist/ /opt/auth-eth/dist/
COPY --from=auth-builder /app/node_modules/ /opt/auth-eth/node_modules/
COPY --from=kms-builder /build/.GIT_REV /etc/
# The OCI metadata is generated once by build-lib.sh and injected here, as
# the config labels and as the manifest annotations, so the three views of
Expand Down
79 changes: 0 additions & 79 deletions dstack/kms/dstack-app/compose-dev.yaml

This file was deleted.

Loading
Loading