diff --git a/.claude/agents/sdk-sync-checker.md b/.claude/agents/sdk-sync-checker.md index 794123da8..4e4050109 100644 --- a/.claude/agents/sdk-sync-checker.md +++ b/.claude/agents/sdk-sync-checker.md @@ -67,10 +67,10 @@ Details: ## Locations - Protos: `dstack/guest-agent/rpc/proto/*.proto` -- Python: `sdk/python/src/dstack_sdk/dstack_client.py` -- Go: `sdk/go/dstack/client.go` -- Rust: `sdk/rust/types/src/dstack.rs` -- JS: `sdk/js/src/index.ts` +- Python: `sdk/python/src/dstack_sdk/dstack_client_v0.py`, `dstack_client_v1.py` +- Go: `sdk/go/dstack/client_v0.go`, `client_v1.go` +- Rust: `sdk/rust/types/src/dstack_v0.rs`, `dstack_v1.rs` +- JS: `sdk/js/src/client-v0.ts`, `client-v1.ts` - Docs: `sdk/curl/api.md`, `sdk/curl/api-tappd.md` Focus on API surface differences. Provide specific file paths and line numbers. diff --git a/CHANGELOG.md b/CHANGELOG.md index 808b7e61d..0d9104519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - sdk: the Go SDK's v1 `IssueCert` defaults `usage_server_auth` to true, as the Rust, Python and JavaScript v1 clients already did. Go was the odd one out, so the same argument-free call produced a certificate that could serve TLS in three languages and one that could not in the fourth — and a certificate you cannot serve with is useless to most callers. `WithCertUsageServerAuth(false)` opts out. v0's `GetTlsKey` keeps its `false` default deliberately: that is what the released 0.5.x Go SDK sent, and `DstackClientV0` mirrors released behaviour rather than the better choice - sdk: the JavaScript v1 `issueCert` response no longer carries a raw-bytes accessor. `asUint8Array()` is **removed rather than renamed**: it existed to feed the private key into the blockchain adapters, and v1 has no chain-flavoured surface. `IssueCert` returns TLS material, PEM is the form a TLS stack takes, and a caller who genuinely needs DER converts it with a standard library. The Rust, Python and Go v1 clients already returned the PEM string and the chain alone, so all four now agree. v0's `GetTlsKeyResponse.asUint8Array` is untouched — released API, and the viem and solana adapters depend on its truncating behaviour - sdk: the JavaScript v1 GPU evidence bundle's `asUint8Array()` is renamed `decodeEvidence()`, matching Python's and Rust's `decode_evidence` and Go, which hands back the decoded `Evidence` bytes directly. The name now says what the bytes are — the vendor's evidence, hex off the wire and decoded byte-exact, because sha256 over precisely those bytes is what the measured `gpu-attestation` event commits to +- sdk: the v0 modules carry a `_v0` suffix, so the file a reader opens matches the client it holds. Rust's `dstack_sdk::dstack_client` becomes `dstack_sdk::dstack_client_v0` and `dstack_sdk_types::dstack` becomes `dstack_sdk_types::dstack_v0`; Python's `dstack_sdk.dstack_client` becomes `dstack_sdk.dstack_client_v0`; Go's `client.go`/`client_test.go` become `client_v0.go`/`client_v0_test.go`; and the JavaScript `index.ts`, which held both surfaces in one file, splits into `client-v0.ts`, `client-v1.ts` and a `shared.ts`, leaving `index.ts` as a barrel that re-exports exactly the names it always did. Until now the unsuffixed *file* meant v0 while the unsuffixed *class* meant v1, so a reader opening `dstack_client.rs` for the recommended client found the legacy one instead. **There are deliberately no backward-compat module aliases**: 0.6.0 is the loud-break release, and an import of an old module path fails at build time rather than silently binding the frozen surface under a name that now means something else. Package-level exports are untouched in every SDK — `dstack_sdk::DstackClient`, `from dstack_sdk import DstackClientV0` and `@phala/dstack-sdk`'s public surface are exactly what they were; only a deep import of the module path moves. In Go this is file naming alone, since it is all one `package dstack` +- sdk: the v0 clients are deprecated in the way each language's tooling understands, not only in prose. Rust's `DstackClientV0` and `TappdClient` carry `#[deprecated(since = "0.6.0")]`, so a downstream build warns at every mention of the type — the `use`, the constructor, any signature naming it. Method calls on an already-built client stay silent, because Rust does not propagate the attribute to inherent methods. Python's `DstackClientV0` and `AsyncDstackClientV0` emit a `DeprecationWarning` on construction, through the same helper `TappdClient` already used, alongside the `.. deprecated:: 0.6.0` docstring note they already carried. JavaScript's `DstackClientV0` already had its `@deprecated` JSDoc and `TappdClient` gains one. Go's `// Deprecated:` markers were in place but seven sat mid-comment rather than as their own trailing paragraph, which is the only form gopls and pkg.go.dev recognise, and are repaired. + + Nothing is removed and the wire behaviour is unchanged, but Python's marker is a runtime warning rather than a build-time one: a downstream test suite that turns `DeprecationWarning` into an error (`filterwarnings = error`, which is a common setting) will fail on `DstackClientV0()` until it adds a filter. The frozen surface stays reachable under its explicit name; it just says what it is now ### Removed diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client_v0.go similarity index 99% rename from sdk/go/dstack/client.go rename to sdk/go/dstack/client_v0.go index 03f0cb5a2..11f12027b 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client_v0.go @@ -575,12 +575,14 @@ func (c *DstackClientV0) EmitEvent(ctx context.Context, event string, payload [] // Legacy methods for backward compatibility with warnings // DeriveKey is deprecated. Use GetKey instead. +// // Deprecated: Use GetKey instead. func (c *DstackClientV0) DeriveKey(path string, subject string, altNames []string) (*GetTlsKeyResponse, error) { return nil, fmt.Errorf("deriveKey is deprecated, please use GetKey instead") } // TdxQuote is deprecated. Use GetQuote instead. +// // Deprecated: Use GetQuote instead. func (c *DstackClientV0) TdxQuote(ctx context.Context, reportData []byte, hashAlgorithm string) (*GetQuoteResponse, error) { c.logger.Warn("tdxQuote is deprecated, please use GetQuote instead") @@ -601,6 +603,7 @@ type TappdClient struct { } // NewTappdClient creates a new deprecated TappdClient. +// // Deprecated: Use NewDstackClient instead. func NewTappdClient(opts ...DstackClientOption) *TappdClient { // Create a modified option to use TAPPD_SIMULATOR_ENDPOINT @@ -632,6 +635,7 @@ func NewTappdClient(opts ...DstackClientOption) *TappdClient { // Override deprecated methods to use proper tappd RPC paths // DeriveKey is deprecated. Use GetKey instead. +// // Deprecated: Use GetKey instead. func (tc *TappdClient) DeriveKey(ctx context.Context, path string, subject string, altNames []string) (*GetTlsKeyResponse, error) { tc.logger.Warn("deriveKey is deprecated, please use GetKey instead") @@ -661,6 +665,7 @@ func (tc *TappdClient) DeriveKey(ctx context.Context, path string, subject strin } // TdxQuote is deprecated. Use GetQuote instead. +// // Deprecated: Use GetQuote instead. func (tc *TappdClient) TdxQuote(ctx context.Context, reportData []byte, hashAlgorithm string) (*GetQuoteResponse, error) { tc.logger.Warn("tdxQuote is deprecated, please use GetQuote instead") diff --git a/sdk/go/dstack/client_test.go b/sdk/go/dstack/client_v0_test.go similarity index 100% rename from sdk/go/dstack/client_test.go rename to sdk/go/dstack/client_v0_test.go diff --git a/sdk/go/dstack/ethereum.go b/sdk/go/dstack/ethereum.go index 518b2bad3..c13f275ba 100644 --- a/sdk/go/dstack/ethereum.go +++ b/sdk/go/dstack/ethereum.go @@ -23,6 +23,7 @@ type EthereumAccount struct { } // ToEthereumAccount creates an Ethereum account from GetKeyResponse or GetTlsKeyResponse (legacy method). +// // Deprecated: Use ToEthereumAccountSecure instead. This method has security concerns. func ToEthereumAccount(keyResponse interface{}) (*EthereumAccount, error) { switch resp := keyResponse.(type) { diff --git a/sdk/go/dstack/solana.go b/sdk/go/dstack/solana.go index 6ce95d191..1784158cb 100644 --- a/sdk/go/dstack/solana.go +++ b/sdk/go/dstack/solana.go @@ -20,6 +20,7 @@ type SolanaKeypair struct { } // ToSolanaKeypair creates a Solana keypair from GetKeyResponse or GetTlsKeyResponse (legacy method). +// // Deprecated: Use ToSolanaKeypairSecure instead. This method has security concerns. func ToSolanaKeypair(keyResponse interface{}) (*SolanaKeypair, error) { switch resp := keyResponse.(type) { diff --git a/sdk/js/src/client-v0.ts b/sdk/js/src/client-v0.ts new file mode 100644 index 000000000..bb241b571 --- /dev/null +++ b/sdk/js/src/client-v0.ts @@ -0,0 +1,528 @@ +// SPDX-FileCopyrightText: © 2024-2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// The frozen v0 guest-agent surface: `DstackClientV0` and the `TappdClient` +// that predates it, plus the response types they alone return. Nothing here +// changes again -- see `DstackClientV0` for why. + +import fs from 'fs' +import { send_rpc_request } from './send-rpc-request' +import { to_hex, throwOnRpcError, resolveDstackEndpoint, type Hex } from './shared' + +export interface GetTlsKeyResponse { + __name__: Readonly<'GetTlsKeyResponse'> + + key: string + certificate_chain: string[] + + asUint8Array: (max_length?: number) => Uint8Array +} + +export interface GetKeyResponse { + __name__: Readonly<'GetKeyResponse'> + + key: Uint8Array + signature_chain: Uint8Array[] +} + +export interface SignResponse { + __name__: Readonly<'SignResponse'> + + signature: Uint8Array + signature_chain: Uint8Array[] + public_key: Uint8Array +} + +export interface VerifyResponse { + __name__: Readonly<'VerifyResponse'> + + valid: boolean +} + +export type TdxQuoteHashAlgorithms = + 'sha256' | 'sha384' | 'sha512' | 'sha3-256' | 'sha3-384' | 'sha3-512' | + 'keccak256' | 'keccak384' | 'keccak512' | 'raw' + +export interface EventLog { + imr: number + event_type: number + digest: string + event: string + event_payload: string + version?: 1 | 2 + preimage?: string +} + +export interface TcbInfo { + mrtd: string + rtmr0: string + rtmr1: string + rtmr2: string + rtmr3: string + app_compose: string + event_log: EventLog[] +} + +export type TcbInfoV03x = TcbInfo & { + rootfs_hash?: string +} + +export type TcbInfoV05x = TcbInfo & { + mr_aggregated: string + os_image_hash: string + compose_hash: string + device_id: string +} + +export interface InfoResponse { + app_id: string + instance_id: string + app_cert: string + tcb_info: VersionTcbInfo + app_name: string + device_id: string + mr_aggregated?: string + os_image_hash?: string // Optional: empty if OS image is not measured by KMS + key_provider_info: string + compose_hash: string + vm_config?: string + // Cloud provider sys_vendor (e.g. "Google"). Available on dstack OS >= 0.5.7. + cloud_vendor?: string + // Cloud provider product_name (e.g. "Google Compute Engine"). Available on dstack OS >= 0.5.7. + cloud_product?: string +} + +export interface GetQuoteResponse { + quote: Hex + event_log: string + report_data?: Hex + vm_config?: string +} + +export interface AttestResponse { + __name__: Readonly<'AttestResponse'> + + attestation: Hex +} + +export interface VersionResponse { + __name__: Readonly<'VersionResponse'> + + version: string + rev: string +} + +// The v0 byte accessor, and v0's alone: the chain adapters feed it into seed +// derivation, so its truncating and zero-padding behaviour is load-bearing on +// the frozen surface. v1's IssueCert hands back PEM and nothing else. +function x509key_to_uint8array(pem: string, max_length?: number) { + const content = pem.replace(/-----BEGIN PRIVATE KEY-----/, '') + .replace(/-----END PRIVATE KEY-----/, '') + .replace(/\n/g, ''); + const binaryDer = atob(content) + if (!max_length) { + max_length = binaryDer.length + } + const result = new Uint8Array(max_length) + for (let i = 0; i < max_length; i++) { + result[i] = binaryDer.charCodeAt(i) + } + return result +} + +export interface TlsKeyOptions { + subject?: string; + altNames?: string[]; + usageRaTls?: boolean; + usageServerAuth?: boolean; + usageClientAuth?: boolean; + // Certificate validity start (seconds since UNIX epoch). Requires dstack OS >= 0.5.7. + notBefore?: number; + // Certificate validity end (seconds since UNIX epoch). Requires dstack OS >= 0.5.7. + notAfter?: number; + // Embed app info into the certificate. Requires dstack OS >= 0.5.7. + withAppInfo?: boolean; +} + +const SECP256K1_ALGORITHMS = new Set(['secp256k1', 'k256', '']) + +/** + * Client for the frozen v0 guest agent surface, served at `/` and, + * since dstack 0.6.0, equivalently at `/v0/`. + * + * This surface is closed at the dstack 0.5.11 shape and will not change again. + * New capability lands in `DstackClientV1`, which derives *different* key + * material for the same inputs -- the two are separate derivation trees, not + * two spellings of one. + * + * @deprecated Legacy surface, kept for apps that already published v0-derived + * material and therefore cannot move. Use `DstackClientV1`, which the + * unsuffixed `DstackClient` now names, for anything new. + */ +export class DstackClientV0 { + protected endpoint: string + + constructor(endpoint: string | undefined = undefined) { + this.endpoint = resolveDstackEndpoint(endpoint) + } + + private async ensureAlgorithmSupported(algorithm: string): Promise { + if (SECP256K1_ALGORITHMS.has(algorithm)) return + try { + await this.version() + } catch { + throw new Error(`algorithm "${algorithm}" is not supported: OS version too old (Version RPC unavailable)`) + } + } + + private async ensureTlsKeyOptionsSupported(featureNames: string[]): Promise { + try { + await this.version() + } catch { + throw new Error(`TLS key options [${featureNames.join(', ')}] are not supported: OS version too old (Version RPC unavailable)`) + } + } + + async getKey(path: string = '', purpose: string = '', algorithm: string = 'secp256k1'): Promise { + await this.ensureAlgorithmSupported(algorithm) + const payload = JSON.stringify({ + path: path, + purpose: purpose, + algorithm: algorithm + }) + const result = await send_rpc_request<{ key: string, signature_chain: string[] }>(this.endpoint, '/GetKey', payload) + return Object.freeze({ + key: new Uint8Array(Buffer.from(result.key, 'hex')), + signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), + __name__: 'GetKeyResponse', + }) + } + + async getTlsKey(options: TlsKeyOptions = {}): Promise { + const { + subject = '', + altNames = [], + usageRaTls = false, + usageServerAuth = true, + usageClientAuth = false, + notBefore, + notAfter, + withAppInfo, + } = options; + + const newFeatures: string[] = [] + if (notBefore !== undefined) newFeatures.push('notBefore') + if (notAfter !== undefined) newFeatures.push('notAfter') + if (withAppInfo !== undefined) newFeatures.push('withAppInfo') + if (newFeatures.length > 0) { + await this.ensureTlsKeyOptionsSupported(newFeatures) + } + + let raw: Record = { + subject, + usage_ra_tls: usageRaTls, + usage_server_auth: usageServerAuth, + usage_client_auth: usageClientAuth, + } + if (altNames && altNames.length) { + raw['alt_names'] = altNames + } + if (notBefore !== undefined) { + raw['not_before'] = notBefore + } + if (notAfter !== undefined) { + raw['not_after'] = notAfter + } + if (withAppInfo !== undefined) { + raw['with_app_info'] = withAppInfo + } + const payload = JSON.stringify(raw) + const result = await send_rpc_request(this.endpoint, '/GetTlsKey', payload) + const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) + return Object.freeze({ + ...result, + asUint8Array, + __name__: 'GetTlsKeyResponse', + }) + } + + /** + * Request a TDX quote for the given report data. + * + * Needs Intel TDX. Without it the guest agent returns an error and this + * throws, and on GCP Confidential VMs it answers with the TDX quote alone, + * leaving out the vTPM quote GCP's verification also binds. Use `attest()` + * in both cases. + */ + async getQuote(report_data: string | Buffer | Uint8Array): Promise { + let hex = to_hex(report_data) + if (hex.length > 128) { + throw new Error(`Report data is too large, it should be less than 64 bytes.`) + } + const payload = JSON.stringify({ report_data: hex }) + const result = await send_rpc_request(this.endpoint, '/GetQuote', payload) + if ('error' in result) { + const err = result['error'] as string + throw new Error(err) + } + return Object.freeze(result) + } + + /** + * Requests a versioned attestation for the given report data. + * + * GPU evidence is not available here: this surface is frozen at the 0.5.11 + * shape. Use `DstackClientV1.attest` or `DstackClientV1.attestGpu`. + */ + async attest(report_data: string | Buffer | Uint8Array): Promise { + let hex = to_hex(report_data) + if (hex.length > 128) { + throw new Error(`Report data is too large, it should be less than 64 bytes.`) + } + const payload = JSON.stringify({ report_data: hex }) + const result = await send_rpc_request<{ attestation: string }>(this.endpoint, '/Attest', payload) + throwOnRpcError(result) + return Object.freeze({ + __name__: 'AttestResponse', + attestation: result.attestation as Hex, + }) + } + + async info(): Promise> { + const result = await send_rpc_request, 'tcb_info'> & { tcb_info: string }>(this.endpoint, '/Info', '{}') + return Object.freeze({ + ...result, + tcb_info: JSON.parse(result.tcb_info) as T, + }) + } + + /** + * Query the guest-agent version. + * + * Returns the version on OS >= 0.5.7. + * Throws on older OS versions that lack the Version RPC. + */ + async version(): Promise { + const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/Version', '{}') + return Object.freeze({ + ...result, + __name__: 'VersionResponse', + }) + } + + async isReachable(): Promise { + try { + // Use info endpoint to test connectivity with 500ms timeout + await send_rpc_request(this.endpoint, '/Info', '{}', 500) + return true + } catch (error) { + return false + } + } + + /** + * Emit an event. This extends the event to RTMR3 on TDX platform. + * + * Requires dstack OS 0.5.0 or later, and removed in 0.6.0: runtime RTMR3 + * events became system-owned, so a 0.6.0 agent answers every call with an + * error. It stays here because the frozen surface still carries the method, + * and the agent's own explanation is more useful than one invented here. + * + * @param event The event name + * @param payload The event data as string or Buffer or Uint8Array + */ + async emitEvent(event: string, payload: string | Buffer | Uint8Array): Promise { + if (!event) { + throw new Error('Event name cannot be empty') + } + + const hexPayload = to_hex(payload) + const result = await send_rpc_request( + this.endpoint, + '/EmitEvent', + JSON.stringify({ + event: event, + payload: hexPayload + }) + ) + throwOnRpcError(result) + } + + /** + * Signs a payload using a derived key. + * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") + * @param data The data to sign. If algorithm is "secp256k1_prehashed", this must be a 32-byte hash. + * @returns A SignResponse containing the signature, signature chain, and public key. + */ + async sign(algorithm: string, data: string | Buffer | Uint8Array): Promise { + const hexData = to_hex(data); + if (algorithm === 'secp256k1_prehashed' && hexData.length !== 64) { + throw new Error(`Pre-hashed signing requires a 32-byte digest, but received ${hexData.length / 2} bytes`); + } + + const payload = JSON.stringify({ + algorithm: algorithm, + data: hexData + }); + + const result = await send_rpc_request<{ signature: string, signature_chain: string[], public_key: string }>(this.endpoint, '/Sign', payload); + + return Object.freeze({ + signature: new Uint8Array(Buffer.from(result.signature, 'hex')), + signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), + public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), + __name__: 'SignResponse', + }); + } + + /** + * Verifies a payload signature. + * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") + * @param data The data that was signed. + * @param signature The signature to verify. + * @param publicKey The public key to use for verification. + * @returns A VerifyResponse indicating if the signature is valid. + */ + async verify( + algorithm: string, + data: string | Buffer | Uint8Array, + signature: string | Buffer | Uint8Array, + publicKey: string | Buffer | Uint8Array + ): Promise { + const payload = JSON.stringify({ + algorithm: algorithm, + data: to_hex(data), + signature: to_hex(signature), + public_key: to_hex(publicKey) + }); + + const result = await send_rpc_request<{ valid: boolean }>(this.endpoint, '/Verify', payload); + throwOnRpcError(result) + + return Object.freeze({ + ...result, + __name__: 'VerifyResponse', + }); + } + + // + // Legacy methods for backward compatibility with a warning to notify users about migrating to new methods. + // These methods don't mean fully compatible as past, but we keep them here until next major version. + // + + /** + * @deprecated Use getKey instead. + * @param path The path to the key. + * @param subject The subject of the key. + * @param altNames The alternative names of the key. + * @returns The key. + */ + async deriveKey(path?: string, subject?: string, altNames?: string[]): Promise { + throw new Error('deriveKey is deprecated, please use getKey instead.') + } + + /** + * @deprecated Use getQuote instead. + * @param report_data The report data. + * @param hash_algorithm The hash algorithm. + * @returns The quote. + */ + async tdxQuote(report_data: string | Buffer | Uint8Array, hash_algorithm?: TdxQuoteHashAlgorithms): Promise { + console.warn('tdxQuote is deprecated, please use getQuote instead') + if (hash_algorithm !== "raw") { + throw new Error('tdxQuote only supports raw hash algorithm.') + } + return this.getQuote(report_data) + } +} + +/** + * Client for the pre-0.3 tappd service, kept for applications that still call it. + * + * It names `DstackClientV0` rather than the `DstackClient` alias on purpose: the + * alias points at v1 now, and tappd speaks the v0 wire surface. + * + * @deprecated Superseded by `DstackClientV0` in dstack 0.3.0, and by + * `DstackClientV1` for anything new. + */ +export class TappdClient extends DstackClientV0 { + constructor(endpoint: string | undefined = undefined) { + if (endpoint === undefined) { + if (process.env.TAPPD_SIMULATOR_ENDPOINT) { + console.warn(`Using tappd endpoint: ${process.env.TAPPD_SIMULATOR_ENDPOINT}`) + endpoint = process.env.TAPPD_SIMULATOR_ENDPOINT + } else { + // Try paths in order: legacy paths first, then namespaced paths + const socketPaths = [ + '/var/run/tappd.sock', + '/run/tappd.sock', + '/var/run/dstack/tappd.sock', + '/run/dstack/tappd.sock', + ] + endpoint = socketPaths.find(p => fs.existsSync(p)) ?? socketPaths[0] + } + } + console.warn('TappdClient is deprecated, please use DstackClientV0 instead') + super(endpoint) + } + + /** + * @deprecated Use getKey instead. + * @param path The path to the key. + * @param subject The subject of the key. + * @param altNames The alternative names of the key. + * @returns The key. + */ + async deriveKey(path?: string, subject?: string, alt_names?: string[]): Promise { + console.warn('deriveKey is deprecated, please use getKey instead'); + let raw: Record = { path: path || '', subject: subject || path || '' } + if (alt_names && alt_names.length) { + raw['alt_names'] = alt_names + } + const payload = JSON.stringify(raw) + const result = await send_rpc_request(this.endpoint, '/prpc/Tappd.DeriveKey', payload) + const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) + return Object.freeze({ + ...result, + asUint8Array, + __name__: 'GetTlsKeyResponse', + }) + } + + /** + * @deprecated Use getQuote instead. + * @param report_data The report data. + * @param hash_algorithm The hash algorithm. + * @returns The quote. + */ + async tdxQuote(report_data: string | Buffer | Uint8Array, hash_algorithm?: TdxQuoteHashAlgorithms): Promise { + console.warn('tdxQuote is deprecated, please use getQuote instead'); + let hex = to_hex(report_data) + if (hash_algorithm === 'raw') { + if (hex.length > 128) { + throw new Error(`Report data is too large, it should less then 64 bytes when hash_algorithm is raw.`) + } + if (hex.length < 128) { + hex = hex.padStart(128, '0') + } + } + const payload = JSON.stringify({ report_data: hex, hash_algorithm }) + const result = await send_rpc_request(this.endpoint, '/prpc/Tappd.TdxQuote', payload) + if ('error' in result) { + const err = result['error'] as string + throw new Error(err) + } + return Object.freeze(result) + } + + async isReachable(): Promise { + try { + // Use info endpoint to test connectivity with 500ms timeout + await send_rpc_request(this.endpoint, '/prpc/Tappd.Info', '{}', 500) + return true + } catch (error) { + return false + } + } +} diff --git a/sdk/js/src/client-v1.ts b/sdk/js/src/client-v1.ts new file mode 100644 index 000000000..6e77d1bd1 --- /dev/null +++ b/sdk/js/src/client-v1.ts @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// The `dstack.guest.v1` surface: the recommended client, and what the +// unsuffixed `DstackClient` names since 0.6.0. + +import { send_rpc_request } from './send-rpc-request' +import { to_hex, throwOnRpcError, resolveDstackEndpoint, type Hex } from './shared' + +export interface IssueCertOptionsV1 { + subject?: string; + altNames?: string[]; + usageRaTls?: boolean; + usageServerAuth?: boolean; + usageClientAuth?: boolean; + withAppInfo?: boolean; + // Certificate validity start (seconds since UNIX epoch). + notBefore?: number; + // Certificate validity end (seconds since UNIX epoch). + notAfter?: number; +} + +export interface IssueCertResponseV1 { + __name__: Readonly<'IssueCertResponseV1'> + + /** + * The private key the agent generated for this certificate, PEM-encoded. + * + * PEM and nothing else. v0 attached a raw-bytes accessor here, but it existed + * to feed the key into the blockchain adapters, and v1 has no chain-flavored + * surface: this is TLS material, PEM is what a TLS stack takes, and a caller + * who genuinely wants DER converts it with a standard library. The other + * three SDKs' v1 clients return the PEM string alone too. + */ + key: string + /** The certificate chain, leaf first, each entry PEM-encoded. */ + certificate_chain: string[] +} + +export interface GetKeyResponseV1 { + __name__: Readonly<'GetKeyResponseV1'> + + /** The derived private key: 32 raw bytes for both supported algorithms. */ + key: Uint8Array + /** SEC1 compressed (33 bytes) for secp256k1, raw (32 bytes) for ed25519. */ + public_key: Uint8Array + /** Two links: the app root key over the v1 key claim, then the KMS root key. */ + signature_chain: Uint8Array[] +} + +export interface AttestResponseV1 { + __name__: Readonly<'AttestResponseV1'> + + attestation: Hex + + /** + * The GPU evidence nvattest recorded at boot, in the same bundle shape + * {@link DstackClientV1.attestGpu} returns, so one parser serves both. Empty + * unless the request asked for it and the guest has boot-time output -- + * absence is the empty array, not a sentinel. + * + * Not bound to `report_data`: nvattest ran at boot against its own nonce. + * Bind it by replaying the runtime event log and comparing sha256 of the + * bytes `decodeEvidence()` returns against `evidence_sha256` in the measured + * `gpu-attestation` event. + */ + boottime_gpu_evidence: GpuEvidenceBundleV1[] +} + +/** + * One vendor's GPU evidence, however it was obtained. + * + * Shared by {@link DstackClientV1.attestGpu} and + * {@link AttestResponseV1.boottime_gpu_evidence}; dispatch on `vendor` and + * `format`, because the two sources answer different questions and a verifier + * for one does not appraise the other: + * + * - `nvidia-nvattest-collect-evidence-json-v1` -- collected on demand by + * `attestGpu`, against the nonce you passed. + * - `nvidia-nvattest-boottime-json-v1` -- the record written at boot, carried + * by `attest`. + */ +export interface GpuEvidenceBundleV1 { + /** Stable GPU vendor identifier, for example `nvidia`. */ + vendor: string + /** Vendor-specific evidence format and version. */ + format: string + /** Opaque vendor-native evidence bytes, hex-encoded by the JSON RPC. */ + evidence: Hex + + /** + * The evidence as raw bytes, exactly as the vendor emitted it. + * + * Byte-exact by design: for a boot-time bundle the binding rule is sha256 + * over precisely these bytes, compared against `evidence_sha256` in the + * measured `gpu-attestation` event, so parsing and re-serialising the JSON + * breaks the comparison. + */ + decodeEvidence: () => Uint8Array +} + +export interface AttestGpuResponseV1 { + __name__: Readonly<'AttestGpuResponseV1'> + + bundles: GpuEvidenceBundleV1[] +} + +/** + * Identity and configuration. Not attestation. + * + * The measurement registers and the event log are deliberately absent -- they + * belong to `attest()`, which returns them quote-backed. Nothing here arrives + * with a quote behind it, so confirm anything you rely on against an + * attestation. + * + * `app_id`, `compose_hash`, `instance_id`, `device_id`, `os_image_hash` and + * `mr_aggregated` are lowercase hex; the rest are plain strings, with the three + * document fields carrying JSON owned by someone else (see `docs/guest-api-v1.md`). + */ +export interface InfoResponseV1 { + __name__: Readonly<'InfoResponseV1'> + + app_id: Hex + app_name: string + compose_hash: Hex + /** + * The app-compose document, verbatim. `compose_hash` is sha256 over exactly + * these bytes, so do not parse and re-serialize before hashing: key order, + * whitespace and unknown fields all change the digest. + */ + app_compose: string + instance_id: Hex + /** Identifies the host machine, not this instance. */ + device_id: Hex + os_image_hash: Hex + mr_aggregated: Hex + vm_config: string + key_provider_info: string + cloud_vendor: string + cloud_product: string +} + +export interface VersionResponseV1 { + __name__: Readonly<'VersionResponseV1'> + + version: string + rev: string +} + +/** + * Attach the byte accessor to the bundles a v1 RPC returned. + * + * Shared by `attest` and `attestGpu` so both hand back the same object shape, + * which is the point of the wire message being shared. + */ +function to_gpu_evidence_bundles( + bundles: Array> | undefined, +): GpuEvidenceBundleV1[] { + return (bundles ?? []).map(bundle => Object.freeze({ + ...bundle, + decodeEvidence: () => new Uint8Array(Buffer.from(bundle.evidence, 'hex')), + })) +} + +/** + * Client for `dstack.guest.v1`, served at `/v1/` by dstack 0.6.0 and later. + * + * Six methods, no more: v1 serves only what needs the TEE -- deriving keys from + * the app root key, and attesting. `sign`, `verify`, `getQuote`, `gpuInfo` and + * `emitEvent` are absent by design, not by oversight; see `docs/guest-api-v1.md`. + * + * A v1 key is NOT the v0 key of the same name. v1 derives under its own HKDF + * salt and binds the algorithm into the derivation, so `getKey('storage-encryption', + * 'secp256k1')` here returns different material than `DstackClientV0.getKey` + * ever did, and secp256k1 and ed25519 no longer share one secret. There is no + * compatibility mode. + * + * An agent that predates v1 has no `/v1` mount, so it answers with a plain + * HTTP 404 page rather than a JSON error. `version()` is the cheapest probe. + */ +export class DstackClientV1 { + protected endpoint: string + + constructor(endpoint: string | undefined = undefined) { + this.endpoint = resolveDstackEndpoint(endpoint) + } + + /** + * Issue a certificate for this application. + * + * The key is freshly generated on every call and is not derived from the app + * identity: two identical requests produce two unrelated keys. Use + * {@link getKey} for stable, attestable material. + */ + async issueCert(options: IssueCertOptionsV1 = {}): Promise { + const { + subject = '', + altNames = [], + usageRaTls = false, + usageServerAuth = true, + usageClientAuth = false, + withAppInfo = false, + notBefore, + notAfter, + } = options; + + const raw: Record = { + subject, + usage_ra_tls: usageRaTls, + usage_server_auth: usageServerAuth, + usage_client_auth: usageClientAuth, + with_app_info: withAppInfo, + } + if (altNames && altNames.length) { + raw['alt_names'] = altNames + } + // Both are `optional` on the wire, so send them only when asked for rather + // than pinning a validity window the caller never chose. + if (notBefore !== undefined) { + raw['not_before'] = notBefore + } + if (notAfter !== undefined) { + raw['not_after'] = notAfter + } + const result = await send_rpc_request<{ key: string, certificate_chain: string[] }>( + this.endpoint, '/v1/IssueCert', JSON.stringify(raw)) + throwOnRpcError(result) + return Object.freeze({ + ...result, + __name__: 'IssueCertResponseV1' as const, + }) + } + + /** + * Derive an application key from `(domain, algorithm)`. + * + * `domain` is an opaque domain-separation string, not a DNS name and not a + * path: derivation is flat, so `a/b` is not a child of `a` and no key derived + * here can derive another. + * + * @param domain Caller-chosen domain-separation string. May be empty. + * @param algorithm Exactly `secp256k1` or `ed25519`. No default, no `k256` alias. + */ + async getKey(domain: string, algorithm: string): Promise { + // v0 defaulted an empty algorithm to secp256k1, which let a typo hand back a + // key of the wrong type under a name the caller thought meant something else. + if (!algorithm) { + throw new Error('algorithm is required, use "secp256k1" or "ed25519"') + } + const payload = JSON.stringify({ domain, algorithm }) + const result = await send_rpc_request<{ key: string, public_key: string, signature_chain: string[] }>( + this.endpoint, '/v1/GetKey', payload) + throwOnRpcError(result) + return Object.freeze({ + key: new Uint8Array(Buffer.from(result.key, 'hex')), + public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), + signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), + __name__: 'GetKeyResponseV1' as const, + }) + } + + /** + * Produce a versioned attestation over the given report data. + * + * The only CVM attestation entry point in v1: the attestation already carries + * the TDX quote and the event log, so there is no separate `getQuote`. + * + * @param report_data 1 to 64 bytes, zero-padded on the right to 64 by the agent. + * @param include_boottime_gpu_evidence Also return the boot-time GPU evidence, + * as the same {@link GpuEvidenceBundleV1} list `attestGpu` returns, so a + * verifier gets both in one round trip. It is not bound to `report_data`. + */ + async attest( + report_data: string | Buffer | Uint8Array, + include_boottime_gpu_evidence: boolean = false, + ): Promise { + const hex = to_hex(report_data) + if (hex.length === 0) { + throw new Error('report data must not be empty') + } + if (hex.length > 128) { + throw new Error(`report data must be at most 64 bytes, but received ${hex.length / 2}`) + } + const payload = JSON.stringify({ report_data: hex, include_boottime_gpu_evidence }) + const result = await send_rpc_request<{ + attestation: string, + boottime_gpu_evidence?: Array>, + }>(this.endpoint, '/v1/Attest', payload) + throwOnRpcError(result) + return Object.freeze({ + __name__: 'AttestResponseV1' as const, + attestation: result.attestation as Hex, + boottime_gpu_evidence: to_gpu_evidence_bundles(result.boottime_gpu_evidence), + }) + } + + /** + * Collect GPU attestation evidence now, against a nonce you choose. + * + * Returns vendor-native evidence, not a verdict: select a verifier from each + * bundle's `vendor` and `format`, then check the signature, certificate chain, + * measurements and the embedded nonce yourself. Evidence does not by itself + * bind the GPU to this CVM. + * + * @param nonce Exactly 32 bytes, passed to the GPU verbatim. SPDM fixes the + * length; hash a longer challenge yourself. + */ + async attestGpu(nonce: Buffer | Uint8Array): Promise { + if (nonce.length !== 32) { + throw new Error(`nonce must be exactly 32 bytes, but received ${nonce.length}`) + } + const payload = JSON.stringify({ nonce: to_hex(nonce) }) + const result = await send_rpc_request<{ + bundles?: Array>, + }>(this.endpoint, '/v1/AttestGpu', payload) + throwOnRpcError(result) + return Object.freeze({ + bundles: to_gpu_evidence_bundles(result.bundles), + __name__: 'AttestGpuResponseV1' as const, + }) + } + + /** Return this application's identity and configuration. */ + async info(): Promise { + const result = await send_rpc_request>(this.endpoint, '/v1/Info', '{}') + throwOnRpcError(result) + return Object.freeze({ + ...result, + __name__: 'InfoResponseV1' as const, + }) + } + + /** Return the guest agent version. Also the cheapest probe for v1 support. */ + async version(): Promise { + const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/v1/Version', '{}') + throwOnRpcError(result) + return Object.freeze({ + ...result, + __name__: 'VersionResponseV1' as const, + }) + } +} + +/** + * The recommended client: `dstack.guest.v1`. + * + * This alias used to mean `DstackClientV0`. Code that upgrades without + * changing the name fails loudly rather than quietly deriving different keys: + * the v1 signatures differ, and `getKey` requires `algorithm` explicitly, so a + * v0 call site stops compiling (or throws) instead of returning wrong material. + * To stay on the frozen surface, import `DstackClientV0` by name from the + * package root -- `client-v0` is an internal module, not an export path. + */ +export const DstackClient = DstackClientV1 +export type DstackClient = DstackClientV1 diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index a509973da..17fbcd17d 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -2,925 +2,15 @@ // // SPDX-License-Identifier: Apache-2.0 -import fs from 'fs' -import { send_rpc_request } from './send-rpc-request' +// The package entry point, and only that: every declaration lives in the module +// named after the surface it belongs to. + export { getComposeHash } from './get-compose-hash' export { verifyEnvEncryptPublicKey, verifyEnvEncryptPublicKeyLegacy } from './verify-env-encrypt-public-key' export type { VerifyOptions } from './verify-env-encrypt-public-key' -export interface GetTlsKeyResponse { - __name__: Readonly<'GetTlsKeyResponse'> - - key: string - certificate_chain: string[] - - asUint8Array: (max_length?: number) => Uint8Array -} - -export interface GetKeyResponse { - __name__: Readonly<'GetKeyResponse'> - - key: Uint8Array - signature_chain: Uint8Array[] -} - -export interface SignResponse { - __name__: Readonly<'SignResponse'> - - signature: Uint8Array - signature_chain: Uint8Array[] - public_key: Uint8Array -} - -export interface VerifyResponse { - __name__: Readonly<'VerifyResponse'> - - valid: boolean -} - - -export type Hex = `${string}` - -export type TdxQuoteHashAlgorithms = - 'sha256' | 'sha384' | 'sha512' | 'sha3-256' | 'sha3-384' | 'sha3-512' | - 'keccak256' | 'keccak384' | 'keccak512' | 'raw' - -export interface EventLog { - imr: number - event_type: number - digest: string - event: string - event_payload: string - version?: 1 | 2 - preimage?: string -} - -export interface TcbInfo { - mrtd: string - rtmr0: string - rtmr1: string - rtmr2: string - rtmr3: string - app_compose: string - event_log: EventLog[] -} - -export type TcbInfoV03x = TcbInfo & { - rootfs_hash?: string -} - -export type TcbInfoV05x = TcbInfo & { - mr_aggregated: string - os_image_hash: string - compose_hash: string - device_id: string -} - -export interface InfoResponse { - app_id: string - instance_id: string - app_cert: string - tcb_info: VersionTcbInfo - app_name: string - device_id: string - mr_aggregated?: string - os_image_hash?: string // Optional: empty if OS image is not measured by KMS - key_provider_info: string - compose_hash: string - vm_config?: string - // Cloud provider sys_vendor (e.g. "Google"). Available on dstack OS >= 0.5.7. - cloud_vendor?: string - // Cloud provider product_name (e.g. "Google Compute Engine"). Available on dstack OS >= 0.5.7. - cloud_product?: string -} - -export interface GetQuoteResponse { - quote: Hex - event_log: string - report_data?: Hex - vm_config?: string -} - -export interface AttestResponse { - __name__: Readonly<'AttestResponse'> - - attestation: Hex -} - -export interface VersionResponse { - __name__: Readonly<'VersionResponse'> - - version: string - rev: string -} - -export function to_hex(data: string | Buffer | Uint8Array): string { - if (typeof data === 'string') { - return Buffer.from(data).toString('hex'); - } - if (data instanceof Uint8Array) { - return Buffer.from(data).toString('hex'); - } - return (data as Buffer).toString('hex'); -} - -// The v0 byte accessor, and v0's alone: the chain adapters feed it into seed -// derivation, so its truncating and zero-padding behaviour is load-bearing on -// the frozen surface. v1's IssueCert hands back PEM and nothing else. -function x509key_to_uint8array(pem: string, max_length?: number) { - const content = pem.replace(/-----BEGIN PRIVATE KEY-----/, '') - .replace(/-----END PRIVATE KEY-----/, '') - .replace(/\n/g, ''); - const binaryDer = atob(content) - if (!max_length) { - max_length = binaryDer.length - } - const result = new Uint8Array(max_length) - for (let i = 0; i < max_length; i++) { - result[i] = binaryDer.charCodeAt(i) - } - return result -} - -export interface TlsKeyOptions { - subject?: string; - altNames?: string[]; - usageRaTls?: boolean; - usageServerAuth?: boolean; - usageClientAuth?: boolean; - // Certificate validity start (seconds since UNIX epoch). Requires dstack OS >= 0.5.7. - notBefore?: number; - // Certificate validity end (seconds since UNIX epoch). Requires dstack OS >= 0.5.7. - notAfter?: number; - // Embed app info into the certificate. Requires dstack OS >= 0.5.7. - withAppInfo?: boolean; -} - -const SECP256K1_ALGORITHMS = new Set(['secp256k1', 'k256', '']) - -/** Socket paths the clients probe, legacy first, then the namespaced variants. */ -const DSTACK_SOCKET_PATHS = [ - '/var/run/dstack.sock', - '/run/dstack.sock', - '/var/run/dstack/dstack.sock', - '/run/dstack/dstack.sock', -] - -/** - * A prpc handler reports failure in the response body rather than by refusing to - * answer, so every method has to look for it; an unchecked call would hand the - * caller a response object with every field missing. - */ -function throwOnRpcError(result: unknown): void { - if (result && typeof result === 'object' && 'error' in result) { - throw new Error(String((result as { error: unknown }).error)) - } -} - -/** - * Attach the byte accessor to the bundles a v1 RPC returned. - * - * Shared by `attest` and `attestGpu` so both hand back the same object shape, - * which is the point of the wire message being shared. - */ -function to_gpu_evidence_bundles( - bundles: Array> | undefined, -): GpuEvidenceBundleV1[] { - return (bundles ?? []).map(bundle => Object.freeze({ - ...bundle, - decodeEvidence: () => new Uint8Array(Buffer.from(bundle.evidence, 'hex')), - })) -} - -/** - * Client for the frozen v0 guest agent surface, served at `/` and, - * since dstack 0.6.0, equivalently at `/v0/`. - * - * This surface is closed at the dstack 0.5.11 shape and will not change again. - * New capability lands in {@link DstackClientV1}, which derives *different* key - * material for the same inputs -- the two are separate derivation trees, not - * two spellings of one. - * - * @deprecated Legacy surface, kept for apps that already published v0-derived - * material and therefore cannot move. Use {@link DstackClientV1}, which the - * unsuffixed `DstackClient` now names, for anything new. - */ -export class DstackClientV0 { - protected endpoint: string - - constructor(endpoint: string | undefined = undefined) { - if (endpoint === undefined) { - if (process.env.DSTACK_SIMULATOR_ENDPOINT) { - console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`) - endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT - } else { - endpoint = DSTACK_SOCKET_PATHS.find(p => fs.existsSync(p)) ?? DSTACK_SOCKET_PATHS[0] - } - } - if (endpoint.startsWith('/') && !fs.existsSync(endpoint)) { - throw new Error(`Unix socket file ${endpoint} does not exist`); - } - this.endpoint = endpoint - } - - private async ensureAlgorithmSupported(algorithm: string): Promise { - if (SECP256K1_ALGORITHMS.has(algorithm)) return - try { - await this.version() - } catch { - throw new Error(`algorithm "${algorithm}" is not supported: OS version too old (Version RPC unavailable)`) - } - } - - private async ensureTlsKeyOptionsSupported(featureNames: string[]): Promise { - try { - await this.version() - } catch { - throw new Error(`TLS key options [${featureNames.join(', ')}] are not supported: OS version too old (Version RPC unavailable)`) - } - } - - async getKey(path: string = '', purpose: string = '', algorithm: string = 'secp256k1'): Promise { - await this.ensureAlgorithmSupported(algorithm) - const payload = JSON.stringify({ - path: path, - purpose: purpose, - algorithm: algorithm - }) - const result = await send_rpc_request<{ key: string, signature_chain: string[] }>(this.endpoint, '/GetKey', payload) - return Object.freeze({ - key: new Uint8Array(Buffer.from(result.key, 'hex')), - signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), - __name__: 'GetKeyResponse', - }) - } - - async getTlsKey(options: TlsKeyOptions = {}): Promise { - const { - subject = '', - altNames = [], - usageRaTls = false, - usageServerAuth = true, - usageClientAuth = false, - notBefore, - notAfter, - withAppInfo, - } = options; - - const newFeatures: string[] = [] - if (notBefore !== undefined) newFeatures.push('notBefore') - if (notAfter !== undefined) newFeatures.push('notAfter') - if (withAppInfo !== undefined) newFeatures.push('withAppInfo') - if (newFeatures.length > 0) { - await this.ensureTlsKeyOptionsSupported(newFeatures) - } - - let raw: Record = { - subject, - usage_ra_tls: usageRaTls, - usage_server_auth: usageServerAuth, - usage_client_auth: usageClientAuth, - } - if (altNames && altNames.length) { - raw['alt_names'] = altNames - } - if (notBefore !== undefined) { - raw['not_before'] = notBefore - } - if (notAfter !== undefined) { - raw['not_after'] = notAfter - } - if (withAppInfo !== undefined) { - raw['with_app_info'] = withAppInfo - } - const payload = JSON.stringify(raw) - const result = await send_rpc_request(this.endpoint, '/GetTlsKey', payload) - const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) - return Object.freeze({ - ...result, - asUint8Array, - __name__: 'GetTlsKeyResponse', - }) - } - - /** - * Request a TDX quote for the given report data. - * - * Needs Intel TDX. Without it the guest agent returns an error and this - * throws, and on GCP Confidential VMs it answers with the TDX quote alone, - * leaving out the vTPM quote GCP's verification also binds. Use `attest()` - * in both cases. - */ - async getQuote(report_data: string | Buffer | Uint8Array): Promise { - let hex = to_hex(report_data) - if (hex.length > 128) { - throw new Error(`Report data is too large, it should be less than 64 bytes.`) - } - const payload = JSON.stringify({ report_data: hex }) - const result = await send_rpc_request(this.endpoint, '/GetQuote', payload) - if ('error' in result) { - const err = result['error'] as string - throw new Error(err) - } - return Object.freeze(result) - } - - /** - * Requests a versioned attestation for the given report data. - * - * GPU evidence is not available here: this surface is frozen at the 0.5.11 - * shape. Use {@link DstackClientV1.attest} or {@link DstackClientV1.attestGpu}. - */ - async attest(report_data: string | Buffer | Uint8Array): Promise { - let hex = to_hex(report_data) - if (hex.length > 128) { - throw new Error(`Report data is too large, it should be less than 64 bytes.`) - } - const payload = JSON.stringify({ report_data: hex }) - const result = await send_rpc_request<{ attestation: string }>(this.endpoint, '/Attest', payload) - throwOnRpcError(result) - return Object.freeze({ - __name__: 'AttestResponse', - attestation: result.attestation as Hex, - }) - } - - async info(): Promise> { - const result = await send_rpc_request, 'tcb_info'> & { tcb_info: string }>(this.endpoint, '/Info', '{}') - return Object.freeze({ - ...result, - tcb_info: JSON.parse(result.tcb_info) as T, - }) - } - - /** - * Query the guest-agent version. - * - * Returns the version on OS >= 0.5.7. - * Throws on older OS versions that lack the Version RPC. - */ - async version(): Promise { - const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/Version', '{}') - return Object.freeze({ - ...result, - __name__: 'VersionResponse', - }) - } - - async isReachable(): Promise { - try { - // Use info endpoint to test connectivity with 500ms timeout - await send_rpc_request(this.endpoint, '/Info', '{}', 500) - return true - } catch (error) { - return false - } - } - - /** - * Emit an event. This extends the event to RTMR3 on TDX platform. - * - * Requires dstack OS 0.5.0 or later, and removed in 0.6.0: runtime RTMR3 - * events became system-owned, so a 0.6.0 agent answers every call with an - * error. It stays here because the frozen surface still carries the method, - * and the agent's own explanation is more useful than one invented here. - * - * @param event The event name - * @param payload The event data as string or Buffer or Uint8Array - */ - async emitEvent(event: string, payload: string | Buffer | Uint8Array): Promise { - if (!event) { - throw new Error('Event name cannot be empty') - } - - const hexPayload = to_hex(payload) - const result = await send_rpc_request( - this.endpoint, - '/EmitEvent', - JSON.stringify({ - event: event, - payload: hexPayload - }) - ) - throwOnRpcError(result) - } - - /** - * Signs a payload using a derived key. - * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") - * @param data The data to sign. If algorithm is "secp256k1_prehashed", this must be a 32-byte hash. - * @returns A SignResponse containing the signature, signature chain, and public key. - */ - async sign(algorithm: string, data: string | Buffer | Uint8Array): Promise { - const hexData = to_hex(data); - if (algorithm === 'secp256k1_prehashed' && hexData.length !== 64) { - throw new Error(`Pre-hashed signing requires a 32-byte digest, but received ${hexData.length / 2} bytes`); - } - - const payload = JSON.stringify({ - algorithm: algorithm, - data: hexData - }); - - const result = await send_rpc_request<{ signature: string, signature_chain: string[], public_key: string }>(this.endpoint, '/Sign', payload); - - return Object.freeze({ - signature: new Uint8Array(Buffer.from(result.signature, 'hex')), - signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), - public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), - __name__: 'SignResponse', - }); - } - - /** - * Verifies a payload signature. - * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") - * @param data The data that was signed. - * @param signature The signature to verify. - * @param publicKey The public key to use for verification. - * @returns A VerifyResponse indicating if the signature is valid. - */ - async verify( - algorithm: string, - data: string | Buffer | Uint8Array, - signature: string | Buffer | Uint8Array, - publicKey: string | Buffer | Uint8Array - ): Promise { - const payload = JSON.stringify({ - algorithm: algorithm, - data: to_hex(data), - signature: to_hex(signature), - public_key: to_hex(publicKey) - }); - - const result = await send_rpc_request<{ valid: boolean }>(this.endpoint, '/Verify', payload); - throwOnRpcError(result) - - return Object.freeze({ - ...result, - __name__: 'VerifyResponse', - }); - } - - // - // Legacy methods for backward compatibility with a warning to notify users about migrating to new methods. - // These methods don't mean fully compatible as past, but we keep them here until next major version. - // - - /** - * @deprecated Use getKey instead. - * @param path The path to the key. - * @param subject The subject of the key. - * @param altNames The alternative names of the key. - * @returns The key. - */ - async deriveKey(path?: string, subject?: string, altNames?: string[]): Promise { - throw new Error('deriveKey is deprecated, please use getKey instead.') - } - - /** - * @deprecated Use getQuote instead. - * @param report_data The report data. - * @param hash_algorithm The hash algorithm. - * @returns The quote. - */ - async tdxQuote(report_data: string | Buffer | Uint8Array, hash_algorithm?: TdxQuoteHashAlgorithms): Promise { - console.warn('tdxQuote is deprecated, please use getQuote instead') - if (hash_algorithm !== "raw") { - throw new Error('tdxQuote only supports raw hash algorithm.') - } - return this.getQuote(report_data) - } -} - -// `TappdClient` names `DstackClientV0` rather than the `DstackClient` alias on -// purpose: the alias points at v1 now, and Tappd speaks the v0 wire surface. -export class TappdClient extends DstackClientV0 { - constructor(endpoint: string | undefined = undefined) { - if (endpoint === undefined) { - if (process.env.TAPPD_SIMULATOR_ENDPOINT) { - console.warn(`Using tappd endpoint: ${process.env.TAPPD_SIMULATOR_ENDPOINT}`) - endpoint = process.env.TAPPD_SIMULATOR_ENDPOINT - } else { - // Try paths in order: legacy paths first, then namespaced paths - const socketPaths = [ - '/var/run/tappd.sock', - '/run/tappd.sock', - '/var/run/dstack/tappd.sock', - '/run/dstack/tappd.sock', - ] - endpoint = socketPaths.find(p => fs.existsSync(p)) ?? socketPaths[0] - } - } - console.warn('TappdClient is deprecated, please use DstackClientV0 instead') - super(endpoint) - } - - /** - * @deprecated Use getKey instead. - * @param path The path to the key. - * @param subject The subject of the key. - * @param altNames The alternative names of the key. - * @returns The key. - */ - async deriveKey(path?: string, subject?: string, alt_names?: string[]): Promise { - console.warn('deriveKey is deprecated, please use getKey instead'); - let raw: Record = { path: path || '', subject: subject || path || '' } - if (alt_names && alt_names.length) { - raw['alt_names'] = alt_names - } - const payload = JSON.stringify(raw) - const result = await send_rpc_request(this.endpoint, '/prpc/Tappd.DeriveKey', payload) - const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) - return Object.freeze({ - ...result, - asUint8Array, - __name__: 'GetTlsKeyResponse', - }) - } - - /** - * @deprecated Use getQuote instead. - * @param report_data The report data. - * @param hash_algorithm The hash algorithm. - * @returns The quote. - */ - async tdxQuote(report_data: string | Buffer | Uint8Array, hash_algorithm?: TdxQuoteHashAlgorithms): Promise { - console.warn('tdxQuote is deprecated, please use getQuote instead'); - let hex = to_hex(report_data) - if (hash_algorithm === 'raw') { - if (hex.length > 128) { - throw new Error(`Report data is too large, it should less then 64 bytes when hash_algorithm is raw.`) - } - if (hex.length < 128) { - hex = hex.padStart(128, '0') - } - } - const payload = JSON.stringify({ report_data: hex, hash_algorithm }) - const result = await send_rpc_request(this.endpoint, '/prpc/Tappd.TdxQuote', payload) - if ('error' in result) { - const err = result['error'] as string - throw new Error(err) - } - return Object.freeze(result) - } - - async isReachable(): Promise { - try { - // Use info endpoint to test connectivity with 500ms timeout - await send_rpc_request(this.endpoint, '/prpc/Tappd.Info', '{}', 500) - return true - } catch (error) { - return false - } - } -} - -// --------------------------------------------------------------------------- -// dstack.guest.v1 -// --------------------------------------------------------------------------- - -export interface IssueCertOptionsV1 { - subject?: string; - altNames?: string[]; - usageRaTls?: boolean; - usageServerAuth?: boolean; - usageClientAuth?: boolean; - withAppInfo?: boolean; - // Certificate validity start (seconds since UNIX epoch). - notBefore?: number; - // Certificate validity end (seconds since UNIX epoch). - notAfter?: number; -} - -export interface IssueCertResponseV1 { - __name__: Readonly<'IssueCertResponseV1'> - - /** - * The private key the agent generated for this certificate, PEM-encoded. - * - * PEM and nothing else. v0 attached a raw-bytes accessor here, but it existed - * to feed the key into the blockchain adapters, and v1 has no chain-flavored - * surface: this is TLS material, PEM is what a TLS stack takes, and a caller - * who genuinely wants DER converts it with a standard library. The other - * three SDKs' v1 clients return the PEM string alone too. - */ - key: string - /** The certificate chain, leaf first, each entry PEM-encoded. */ - certificate_chain: string[] -} - -export interface GetKeyResponseV1 { - __name__: Readonly<'GetKeyResponseV1'> - - /** The derived private key: 32 raw bytes for both supported algorithms. */ - key: Uint8Array - /** SEC1 compressed (33 bytes) for secp256k1, raw (32 bytes) for ed25519. */ - public_key: Uint8Array - /** Two links: the app root key over the v1 key claim, then the KMS root key. */ - signature_chain: Uint8Array[] -} - -export interface AttestResponseV1 { - __name__: Readonly<'AttestResponseV1'> - - attestation: Hex - - /** - * The GPU evidence nvattest recorded at boot, in the same bundle shape - * {@link DstackClientV1.attestGpu} returns, so one parser serves both. Empty - * unless the request asked for it and the guest has boot-time output -- - * absence is the empty array, not a sentinel. - * - * Not bound to `report_data`: nvattest ran at boot against its own nonce. - * Bind it by replaying the runtime event log and comparing sha256 of the - * bytes `decodeEvidence()` returns against `evidence_sha256` in the measured - * `gpu-attestation` event. - */ - boottime_gpu_evidence: GpuEvidenceBundleV1[] -} - -/** - * One vendor's GPU evidence, however it was obtained. - * - * Shared by {@link DstackClientV1.attestGpu} and - * {@link AttestResponseV1.boottime_gpu_evidence}; dispatch on `vendor` and - * `format`, because the two sources answer different questions and a verifier - * for one does not appraise the other: - * - * - `nvidia-nvattest-collect-evidence-json-v1` -- collected on demand by - * `attestGpu`, against the nonce you passed. - * - `nvidia-nvattest-boottime-json-v1` -- the record written at boot, carried - * by `attest`. - */ -export interface GpuEvidenceBundleV1 { - /** Stable GPU vendor identifier, for example `nvidia`. */ - vendor: string - /** Vendor-specific evidence format and version. */ - format: string - /** Opaque vendor-native evidence bytes, hex-encoded by the JSON RPC. */ - evidence: Hex - - /** - * The evidence as raw bytes, exactly as the vendor emitted it. - * - * Byte-exact by design: for a boot-time bundle the binding rule is sha256 - * over precisely these bytes, compared against `evidence_sha256` in the - * measured `gpu-attestation` event, so parsing and re-serialising the JSON - * breaks the comparison. - */ - decodeEvidence: () => Uint8Array -} - -export interface AttestGpuResponseV1 { - __name__: Readonly<'AttestGpuResponseV1'> - - bundles: GpuEvidenceBundleV1[] -} - -/** - * Identity and configuration. Not attestation. - * - * The measurement registers and the event log are deliberately absent -- they - * belong to `attest()`, which returns them quote-backed. Nothing here arrives - * with a quote behind it, so confirm anything you rely on against an - * attestation. - * - * `app_id`, `compose_hash`, `instance_id`, `device_id`, `os_image_hash` and - * `mr_aggregated` are lowercase hex; the rest are plain strings, with the three - * document fields carrying JSON owned by someone else (see `docs/guest-api-v1.md`). - */ -export interface InfoResponseV1 { - __name__: Readonly<'InfoResponseV1'> - - app_id: Hex - app_name: string - compose_hash: Hex - /** - * The app-compose document, verbatim. `compose_hash` is sha256 over exactly - * these bytes, so do not parse and re-serialize before hashing: key order, - * whitespace and unknown fields all change the digest. - */ - app_compose: string - instance_id: Hex - /** Identifies the host machine, not this instance. */ - device_id: Hex - os_image_hash: Hex - mr_aggregated: Hex - vm_config: string - key_provider_info: string - cloud_vendor: string - cloud_product: string -} - -export interface VersionResponseV1 { - __name__: Readonly<'VersionResponseV1'> - - version: string - rev: string -} - -/** - * Client for `dstack.guest.v1`, served at `/v1/` by dstack 0.6.0 and later. - * - * Six methods, no more: v1 serves only what needs the TEE -- deriving keys from - * the app root key, and attesting. `sign`, `verify`, `getQuote`, `gpuInfo` and - * `emitEvent` are absent by design, not by oversight; see `docs/guest-api-v1.md`. - * - * A v1 key is NOT the v0 key of the same name. v1 derives under its own HKDF - * salt and binds the algorithm into the derivation, so `getKey('storage-encryption', - * 'secp256k1')` here returns different material than `DstackClientV0.getKey` - * ever did, and secp256k1 and ed25519 no longer share one secret. There is no - * compatibility mode. - * - * An agent that predates v1 has no `/v1` mount, so it answers with a plain - * HTTP 404 page rather than a JSON error. `version()` is the cheapest probe. - */ -export class DstackClientV1 { - protected endpoint: string - - constructor(endpoint: string | undefined = undefined) { - if (endpoint === undefined) { - if (process.env.DSTACK_SIMULATOR_ENDPOINT) { - console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`) - endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT - } else { - endpoint = DSTACK_SOCKET_PATHS.find(p => fs.existsSync(p)) ?? DSTACK_SOCKET_PATHS[0] - } - } - if (endpoint.startsWith('/') && !fs.existsSync(endpoint)) { - throw new Error(`Unix socket file ${endpoint} does not exist`); - } - this.endpoint = endpoint - } - - /** - * Issue a certificate for this application. - * - * The key is freshly generated on every call and is not derived from the app - * identity: two identical requests produce two unrelated keys. Use - * {@link getKey} for stable, attestable material. - */ - async issueCert(options: IssueCertOptionsV1 = {}): Promise { - const { - subject = '', - altNames = [], - usageRaTls = false, - usageServerAuth = true, - usageClientAuth = false, - withAppInfo = false, - notBefore, - notAfter, - } = options; - - const raw: Record = { - subject, - usage_ra_tls: usageRaTls, - usage_server_auth: usageServerAuth, - usage_client_auth: usageClientAuth, - with_app_info: withAppInfo, - } - if (altNames && altNames.length) { - raw['alt_names'] = altNames - } - // Both are `optional` on the wire, so send them only when asked for rather - // than pinning a validity window the caller never chose. - if (notBefore !== undefined) { - raw['not_before'] = notBefore - } - if (notAfter !== undefined) { - raw['not_after'] = notAfter - } - const result = await send_rpc_request<{ key: string, certificate_chain: string[] }>( - this.endpoint, '/v1/IssueCert', JSON.stringify(raw)) - throwOnRpcError(result) - return Object.freeze({ - ...result, - __name__: 'IssueCertResponseV1' as const, - }) - } - - /** - * Derive an application key from `(domain, algorithm)`. - * - * `domain` is an opaque domain-separation string, not a DNS name and not a - * path: derivation is flat, so `a/b` is not a child of `a` and no key derived - * here can derive another. - * - * @param domain Caller-chosen domain-separation string. May be empty. - * @param algorithm Exactly `secp256k1` or `ed25519`. No default, no `k256` alias. - */ - async getKey(domain: string, algorithm: string): Promise { - // v0 defaulted an empty algorithm to secp256k1, which let a typo hand back a - // key of the wrong type under a name the caller thought meant something else. - if (!algorithm) { - throw new Error('algorithm is required, use "secp256k1" or "ed25519"') - } - const payload = JSON.stringify({ domain, algorithm }) - const result = await send_rpc_request<{ key: string, public_key: string, signature_chain: string[] }>( - this.endpoint, '/v1/GetKey', payload) - throwOnRpcError(result) - return Object.freeze({ - key: new Uint8Array(Buffer.from(result.key, 'hex')), - public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), - signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), - __name__: 'GetKeyResponseV1' as const, - }) - } - - /** - * Produce a versioned attestation over the given report data. - * - * The only CVM attestation entry point in v1: the attestation already carries - * the TDX quote and the event log, so there is no separate `getQuote`. - * - * @param report_data 1 to 64 bytes, zero-padded on the right to 64 by the agent. - * @param include_boottime_gpu_evidence Also return the boot-time GPU evidence, - * as the same {@link GpuEvidenceBundleV1} list `attestGpu` returns, so a - * verifier gets both in one round trip. It is not bound to `report_data`. - */ - async attest( - report_data: string | Buffer | Uint8Array, - include_boottime_gpu_evidence: boolean = false, - ): Promise { - const hex = to_hex(report_data) - if (hex.length === 0) { - throw new Error('report data must not be empty') - } - if (hex.length > 128) { - throw new Error(`report data must be at most 64 bytes, but received ${hex.length / 2}`) - } - const payload = JSON.stringify({ report_data: hex, include_boottime_gpu_evidence }) - const result = await send_rpc_request<{ - attestation: string, - boottime_gpu_evidence?: Array>, - }>(this.endpoint, '/v1/Attest', payload) - throwOnRpcError(result) - return Object.freeze({ - __name__: 'AttestResponseV1' as const, - attestation: result.attestation as Hex, - boottime_gpu_evidence: to_gpu_evidence_bundles(result.boottime_gpu_evidence), - }) - } - - /** - * Collect GPU attestation evidence now, against a nonce you choose. - * - * Returns vendor-native evidence, not a verdict: select a verifier from each - * bundle's `vendor` and `format`, then check the signature, certificate chain, - * measurements and the embedded nonce yourself. Evidence does not by itself - * bind the GPU to this CVM. - * - * @param nonce Exactly 32 bytes, passed to the GPU verbatim. SPDM fixes the - * length; hash a longer challenge yourself. - */ - async attestGpu(nonce: Buffer | Uint8Array): Promise { - if (nonce.length !== 32) { - throw new Error(`nonce must be exactly 32 bytes, but received ${nonce.length}`) - } - const payload = JSON.stringify({ nonce: to_hex(nonce) }) - const result = await send_rpc_request<{ - bundles?: Array>, - }>(this.endpoint, '/v1/AttestGpu', payload) - throwOnRpcError(result) - return Object.freeze({ - bundles: to_gpu_evidence_bundles(result.bundles), - __name__: 'AttestGpuResponseV1' as const, - }) - } - - /** Return this application's identity and configuration. */ - async info(): Promise { - const result = await send_rpc_request>(this.endpoint, '/v1/Info', '{}') - throwOnRpcError(result) - return Object.freeze({ - ...result, - __name__: 'InfoResponseV1' as const, - }) - } - - /** Return the guest agent version. Also the cheapest probe for v1 support. */ - async version(): Promise { - const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/v1/Version', '{}') - throwOnRpcError(result) - return Object.freeze({ - ...result, - __name__: 'VersionResponseV1' as const, - }) - } -} +export { to_hex } from './shared' +export type { Hex } from './shared' -/** - * The recommended client: `dstack.guest.v1`. - * - * Declared here rather than beside {@link DstackClientV0} because a `const` - * cannot name a class that has not been evaluated yet. - * - * This alias used to mean {@link DstackClientV0}. Code that upgrades without - * changing the name fails loudly rather than quietly deriving different keys: - * the v1 signatures differ, and `getKey` requires `algorithm` explicitly, so a - * v0 call site stops compiling (or throws) instead of returning wrong material. - * To stay on the frozen surface, name {@link DstackClientV0}. - */ -export const DstackClient = DstackClientV1 -export type DstackClient = DstackClientV1 +export * from './client-v0' +export * from './client-v1' diff --git a/sdk/js/src/shared.ts b/sdk/js/src/shared.ts new file mode 100644 index 000000000..db527ae8b --- /dev/null +++ b/sdk/js/src/shared.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: © 2024-2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// Helpers both client surfaces need. Kept out of `client-v0.ts` so the v1 +// client does not have to import from the frozen module to reach them. + +import fs from 'fs' + +export type Hex = `${string}` + +export function to_hex(data: string | Buffer | Uint8Array): string { + if (typeof data === 'string') { + return Buffer.from(data).toString('hex'); + } + if (data instanceof Uint8Array) { + return Buffer.from(data).toString('hex'); + } + return (data as Buffer).toString('hex'); +} + +/** Socket paths the clients probe, legacy first, then the namespaced variants. */ +const DSTACK_SOCKET_PATHS = [ + '/var/run/dstack.sock', + '/run/dstack.sock', + '/var/run/dstack/dstack.sock', + '/run/dstack/dstack.sock', +] + +/** + * A prpc handler reports failure in the response body rather than by refusing to + * answer, so every method has to look for it; an unchecked call would hand the + * caller a response object with every field missing. + */ +export function throwOnRpcError(result: unknown): void { + if (result && typeof result === 'object' && 'error' in result) { + throw new Error(String((result as { error: unknown }).error)) + } +} + +/** + * Resolve the socket or URL a client talks to. + * + * Shared by both clients because they address the same agent: v0 and v1 are two + * mounts on one socket, so probing separate paths per surface would only give + * them a way to disagree about where the agent is. + */ +export function resolveDstackEndpoint(endpoint: string | undefined): string { + if (endpoint === undefined) { + if (process.env.DSTACK_SIMULATOR_ENDPOINT) { + console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`) + endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT + } else { + endpoint = DSTACK_SOCKET_PATHS.find(p => fs.existsSync(p)) ?? DSTACK_SOCKET_PATHS[0] + } + } + if (endpoint.startsWith('/') && !fs.existsSync(endpoint)) { + throw new Error(`Unix socket file ${endpoint} does not exist`); + } + return endpoint +} diff --git a/sdk/js/src/solana.ts b/sdk/js/src/solana.ts index 5fe69f633..36e633451 100644 --- a/sdk/js/src/solana.ts +++ b/sdk/js/src/solana.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import { sha256 } from '@noble/hashes/sha256' -import { type GetKeyResponse, type GetTlsKeyResponse } from './index' +import { type GetKeyResponse, type GetTlsKeyResponse } from './client-v0' import { Keypair } from '@solana/web3.js' /** diff --git a/sdk/js/src/viem.ts b/sdk/js/src/viem.ts index d2b505624..4f74bc524 100644 --- a/sdk/js/src/viem.ts +++ b/sdk/js/src/viem.ts @@ -4,7 +4,7 @@ import { sha256 } from '@noble/hashes/sha256' import { bytesToHex } from '@noble/hashes/utils' -import { type GetKeyResponse, type GetTlsKeyResponse } from './index' +import { type GetKeyResponse, type GetTlsKeyResponse } from './client-v0' import { privateKeyToAccount } from 'viem/accounts' /** diff --git a/sdk/python/src/dstack_sdk/__init__.py b/sdk/python/src/dstack_sdk/__init__.py index 4fee0ebdd..730b2f728 100644 --- a/sdk/python/src/dstack_sdk/__init__.py +++ b/sdk/python/src/dstack_sdk/__init__.py @@ -2,20 +2,20 @@ # # SPDX-License-Identifier: Apache-2.0 -from .dstack_client import AsyncDstackClientV0 -from .dstack_client import AsyncTappdClient -from .dstack_client import AttestResponse -from .dstack_client import DstackClientV0 -from .dstack_client import EventLog -from .dstack_client import GetKeyResponse -from .dstack_client import GetQuoteResponse -from .dstack_client import GetTlsKeyResponse -from .dstack_client import InfoResponse -from .dstack_client import SignResponse -from .dstack_client import TappdClient -from .dstack_client import TcbInfo -from .dstack_client import VerifyResponse -from .dstack_client import VersionResponse +from .dstack_client_v0 import AsyncDstackClientV0 +from .dstack_client_v0 import AsyncTappdClient +from .dstack_client_v0 import AttestResponse +from .dstack_client_v0 import DstackClientV0 +from .dstack_client_v0 import EventLog +from .dstack_client_v0 import GetKeyResponse +from .dstack_client_v0 import GetQuoteResponse +from .dstack_client_v0 import GetTlsKeyResponse +from .dstack_client_v0 import InfoResponse +from .dstack_client_v0 import SignResponse +from .dstack_client_v0 import TappdClient +from .dstack_client_v0 import TcbInfo +from .dstack_client_v0 import VerifyResponse +from .dstack_client_v0 import VersionResponse from .dstack_client_v1 import AsyncDstackClient from .dstack_client_v1 import AsyncDstackClientV1 from .dstack_client_v1 import AttestGpuResponseV1 diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client_v0.py similarity index 94% rename from sdk/python/src/dstack_sdk/dstack_client.py rename to sdk/python/src/dstack_sdk/dstack_client_v0.py index b854cee0d..d3b7f5e17 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client_v0.py @@ -413,6 +413,33 @@ class stays for code that must keep the v0 key derivation or needs PATH_PREFIX = "/" + def __init__( + self, + endpoint: str | None = None, + *, + use_sync_http: bool = False, + timeout: float = 3, + _warn: bool = True, + ): + """Initialize the legacy async client, warning that v0 is deprecated. + + ``_warn`` is private: the sync wrappers build one of these as their own + transport and have already warned, so they pass ``False``. It is a + separate flag rather than a reading of ``use_sync_http`` because that + one is public and documented -- a caller who sets it is still a caller + who deserves the warning. + """ + # Only when this class is what the caller actually asked for: a + # subclass (``AsyncTappdClient``) names its own surface in its own + # warning. + if _warn and type(self) is AsyncDstackClientV0: + emit_deprecation_warning( + "AsyncDstackClientV0 is deprecated: the v0 surface is frozen at " + "dstack 0.5.11. Use AsyncDstackClient (AsyncDstackClientV1), " + "which derives different key material -- see docs/guest-api-v1.md" + ) + super().__init__(endpoint, use_sync_http=use_sync_http, timeout=timeout) + async def _ensure_algorithm_supported(self, algorithm: str) -> None: """Check OS version when a non-secp256k1 algorithm is requested.""" if algorithm in ("secp256k1", "k256", ""): @@ -633,8 +660,14 @@ def __init__(self, endpoint: str | None = None, *, timeout: float = 3): If a non-HTTP(S) endpoint is provided, it is treated as a Unix socket path and validated for existence. """ + if type(self) is DstackClientV0: + emit_deprecation_warning( + "DstackClientV0 is deprecated: the v0 surface is frozen at " + "dstack 0.5.11. Use DstackClient (DstackClientV1), which " + "derives different key material -- see docs/guest-api-v1.md" + ) self.async_client = AsyncDstackClientV0( - endpoint, use_sync_http=True, timeout=timeout + endpoint, use_sync_http=True, timeout=timeout, _warn=False ) @call_async @@ -751,10 +784,11 @@ def __init__( *, use_sync_http: bool = False, timeout: float = 3, + _warn: bool = True, ): """Initialize deprecated async tappd client wrapper.""" - if not use_sync_http: - # Already warned in TappdClient.__init__ + if _warn: + # ``TappdClient`` has already warned when it builds one of these. emit_deprecation_warning( "AsyncTappdClient is deprecated, please use AsyncDstackClientV0 instead" ) @@ -837,7 +871,7 @@ def __init__(self, endpoint: str | None = None, timeout: float = 3): ) endpoint = get_tappd_endpoint(endpoint) self.async_client = AsyncTappdClient( - endpoint, use_sync_http=True, timeout=timeout + endpoint, use_sync_http=True, timeout=timeout, _warn=False ) @call_async diff --git a/sdk/python/src/dstack_sdk/dstack_client_v1.py b/sdk/python/src/dstack_sdk/dstack_client_v1.py index 4fff04afb..55ccdd0fb 100644 --- a/sdk/python/src/dstack_sdk/dstack_client_v1.py +++ b/sdk/python/src/dstack_sdk/dstack_client_v1.py @@ -24,9 +24,9 @@ from pydantic import BaseModel -from .dstack_client import AsyncBaseClient -from .dstack_client import BaseClient -from .dstack_client import call_async +from .dstack_client_v0 import AsyncBaseClient +from .dstack_client_v0 import BaseClient +from .dstack_client_v0 import call_async class IssueCertResponseV1(BaseModel): diff --git a/sdk/python/src/dstack_sdk/ethereum.py b/sdk/python/src/dstack_sdk/ethereum.py index 6315f67c2..609607e57 100644 --- a/sdk/python/src/dstack_sdk/ethereum.py +++ b/sdk/python/src/dstack_sdk/ethereum.py @@ -16,8 +16,8 @@ from eth_account import Account from eth_account.signers.local import LocalAccount -from .dstack_client import GetKeyResponse -from .dstack_client import GetTlsKeyResponse +from .dstack_client_v0 import GetKeyResponse +from .dstack_client_v0 import GetTlsKeyResponse def to_account(get_key_response: GetKeyResponse | GetTlsKeyResponse) -> LocalAccount: diff --git a/sdk/python/src/dstack_sdk/solana.py b/sdk/python/src/dstack_sdk/solana.py index 058bb3e83..aca1c5896 100644 --- a/sdk/python/src/dstack_sdk/solana.py +++ b/sdk/python/src/dstack_sdk/solana.py @@ -15,8 +15,8 @@ from solders.keypair import Keypair -from .dstack_client import GetKeyResponse -from .dstack_client import GetTlsKeyResponse +from .dstack_client_v0 import GetKeyResponse +from .dstack_client_v0 import GetTlsKeyResponse def to_keypair(get_key_response: GetKeyResponse | GetTlsKeyResponse) -> Keypair: diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 18f355841..6e23010de 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -20,8 +20,8 @@ from dstack_sdk import TappdClient from dstack_sdk import VerifyResponse from dstack_sdk import VersionResponse -from dstack_sdk.dstack_client import InfoResponse -from dstack_sdk.dstack_client import TcbInfo +from dstack_sdk.dstack_client_v0 import InfoResponse +from dstack_sdk.dstack_client_v0 import TcbInfo def test_sync_client_get_key(): @@ -414,6 +414,45 @@ async def test_async_sign_prehashed_length_error(): # Test deprecated TappdClient +def test_dstack_client_v0_deprecated(): + """The v0 client warns at construction. + + The frozen surface is what a 0.5.x program keeps working against, so the + class stays -- but the unsuffixed ``DstackClient`` name now means v1, and a + caller who landed on v0 by way of the rename should be told rather than + discovering it when the derived key does not match. + """ + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + DstackClientV0() + + v0_warnings = [ + warning + for warning in w + if issubclass(warning.category, DeprecationWarning) + and "DstackClientV0 is deprecated" in str(warning.message) + ] + + assert len(v0_warnings) == 1 + assert "frozen at dstack 0.5.11" in str(v0_warnings[0].message) + + +def test_async_dstack_client_v0_deprecated(): + """Same for the async client; both are entry points to the frozen API.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + AsyncDstackClientV0() + + v0_warnings = [ + warning + for warning in w + if issubclass(warning.category, DeprecationWarning) + and "AsyncDstackClientV0 is deprecated" in str(warning.message) + ] + + assert len(v0_warnings) == 1 + + def test_tappd_client_deprecated(): """Test that TappdClient shows deprecation warning.""" with warnings.catch_warnings(record=True) as w: @@ -658,3 +697,26 @@ async def fake_send(self, method, payload): client = AsyncDstackClientV0() with pytest.raises(RuntimeError, match="TLS key options"): await client.get_tls_key(with_app_info=False) + + +def test_v0_warns_even_when_the_caller_asks_for_sync_http(): + """``use_sync_http`` is a public transport option, not a warning switch. + + The sync wrappers build their async twin with it, and used to suppress the + deprecation warning by reading it -- so a user who set the documented flag + themselves was silently opted out of the one signal telling them the surface + is frozen. + """ + with pytest.warns(DeprecationWarning, match="AsyncDstackClientV0 is deprecated"): + AsyncDstackClientV0(use_sync_http=True) + + +def test_v0_sync_wrapper_warns_exactly_once(): + """It builds an AsyncDstackClientV0 internally; that must not warn twice.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + DstackClientV0() + v0_warnings = [ + w for w in caught if "DstackClientV0 is deprecated" in str(w.message) + ] + assert len(v0_warnings) == 1, [str(w.message) for w in caught] diff --git a/sdk/python/tests/test_typing.py b/sdk/python/tests/test_typing.py index 9f7cbc997..a17731d6f 100644 --- a/sdk/python/tests/test_typing.py +++ b/sdk/python/tests/test_typing.py @@ -12,7 +12,7 @@ from dstack_sdk import GetKeyResponse from dstack_sdk import GetQuoteResponse from dstack_sdk import GetTlsKeyResponse -from dstack_sdk.dstack_client import InfoResponse +from dstack_sdk.dstack_client_v0 import InfoResponse # Use a test endpoint to avoid socket file not found errors TEST_ENDPOINT = "http://localhost:8080" diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 3de94903f..0c34f65ac 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -206,7 +206,7 @@ use dstack_sdk::tappd_client::TappdClient; let client = TappdClient::new(None); // After -use dstack_sdk::dstack_client::DstackClientV0; +use dstack_sdk::dstack_client_v0::DstackClientV0; let client = DstackClientV0::new(None); ``` @@ -222,7 +222,7 @@ only if you need something v1 does not carry -- `sign`, `verify`, `emit_event`, `get_quote` -- or to keep existing code working while you migrate. ```rust -use dstack_sdk::dstack_client::DstackClientV0; +use dstack_sdk::dstack_client_v0::DstackClientV0; let client = DstackClientV0::new(None); ``` @@ -300,7 +300,7 @@ No GPU-evidence flag on v0: that field is reserved on this surface, and only `get_tls_key()` creates fresh TLS certificates. Unlike `get_key()`, each call generates a new random key. ```rust -use dstack_sdk_types::dstack::TlsKeyConfig; +use dstack_sdk_types::dstack_v0::TlsKeyConfig; let tls_config = TlsKeyConfig::builder() .subject("api.example.com") @@ -360,7 +360,7 @@ way. The v1 surface has no chain-related functionality: it returns key material, and what an application builds from those bytes is its own business. ```rust -use dstack_sdk::dstack_client::DstackClientV0; +use dstack_sdk::dstack_client_v0::DstackClientV0; use dstack_sdk::ethereum::to_account; let client = DstackClientV0::new(None); diff --git a/sdk/rust/examples/dstack_client_usage.rs b/sdk/rust/examples/dstack_client_usage.rs index 5b3141531..2f3f0c72a 100644 --- a/sdk/rust/examples/dstack_client_usage.rs +++ b/sdk/rust/examples/dstack_client_usage.rs @@ -3,8 +3,11 @@ // // SPDX-License-Identifier: Apache-2.0 -use dstack_sdk::dstack_client::DstackClientV0; -use dstack_sdk_types::dstack::TlsKeyConfig; +// Demonstrates the deprecated v0 surface on purpose. +#![allow(deprecated)] + +use dstack_sdk::dstack_client_v0::DstackClientV0; +use dstack_sdk_types::dstack_v0::TlsKeyConfig; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/sdk/rust/examples/tappd_client_usage.rs b/sdk/rust/examples/tappd_client_usage.rs index e9a426035..d7980fbf4 100644 --- a/sdk/rust/examples/tappd_client_usage.rs +++ b/sdk/rust/examples/tappd_client_usage.rs @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: Apache-2.0 +// Demonstrates the deprecated v0 surface on purpose. +#![allow(deprecated)] + use dstack_sdk::tappd_client::TappdClient; #[tokio::main] diff --git a/sdk/rust/src/dstack_client.rs b/sdk/rust/src/dstack_client_v0.rs similarity index 97% rename from sdk/rust/src/dstack_client.rs rename to sdk/rust/src/dstack_client_v0.rs index da72becbf..01c9c7435 100644 --- a/sdk/rust/src/dstack_client.rs +++ b/sdk/rust/src/dstack_client_v0.rs @@ -12,7 +12,7 @@ use serde::{de::DeserializeOwned, Serialize}; use serde_json::{json, Value}; use std::env; -pub use dstack_sdk_types::dstack::*; +pub use dstack_sdk_types::dstack_v0::*; // Internal request structs for hex encoding #[derive(Debug, Serialize)] @@ -172,6 +172,10 @@ pub(crate) async fn unix_post( /// For anything new, use [`crate::dstack_client_v1::DstackClientV1`]. Note that /// **v1 derives different key material than v0 for the same inputs** -- see /// `docs/guest-api-v1.md` for the migration. +#[deprecated( + since = "0.6.0", + note = "the v0 surface is frozen at dstack 0.5.11; use `DstackClientV1`, which the unsuffixed `DstackClient` now names. Note that v1 derives different key material -- see docs/guest-api-v1.md" +)] pub struct DstackClientV0 { /// The base URL for HTTP requests base_url: String, @@ -181,8 +185,12 @@ pub struct DstackClientV0 { client: ClientKind, } +// The client is deprecated, not broken: its own implementation still has to +// name it. +#[allow(deprecated)] impl BaseClient for DstackClientV0 {} +#[allow(deprecated)] impl DstackClientV0 { pub fn new(endpoint: Option<&str>) -> Self { let endpoint = get_endpoint(endpoint); diff --git a/sdk/rust/src/dstack_client_v1.rs b/sdk/rust/src/dstack_client_v1.rs index 32748d68d..ba44f82b6 100644 --- a/sdk/rust/src/dstack_client_v1.rs +++ b/sdk/rust/src/dstack_client_v1.rs @@ -21,13 +21,13 @@ use serde_json::json; pub use dstack_sdk_types::dstack_v1::*; -use crate::dstack_client::{get_endpoint, http_post, unix_post, BaseClient, ClientKind}; +use crate::dstack_client_v0::{get_endpoint, http_post, unix_post, BaseClient, ClientKind}; /// Client for the v1 guest-agent surface. **This is the default client**, and /// what the unsuffixed [`crate::DstackClient`] names. /// /// **v1 keys are not v0 keys.** Deriving under the same name here as on -/// [`crate::dstack_client::DstackClientV0`] returns different key material, by +/// [`crate::dstack_client_v0::DstackClientV0`] returns different key material, by /// design: the v0 KDF ignored the algorithm, so one secret served both curves. /// There is no compatibility mode. An application holding assets under a v0 key /// migrates them deliberately -- see `docs/guest-api-v1.md`. @@ -167,5 +167,5 @@ impl DstackClientV1 { /// client, and code that was calling v0 methods through it stops compiling /// rather than silently deriving different key material -- the v1 signatures /// differ, and `get_key` requires an explicit `algorithm`. To stay on the -/// frozen surface, name [`crate::dstack_client::DstackClientV0`] explicitly. +/// frozen surface, name [`crate::dstack_client_v0::DstackClientV0`] explicitly. pub type DstackClient = DstackClientV1; diff --git a/sdk/rust/src/ethereum.rs b/sdk/rust/src/ethereum.rs index f53af5e4b..f26bb84db 100644 --- a/sdk/rust/src/ethereum.rs +++ b/sdk/rust/src/ethereum.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 use alloy::signers::local::PrivateKeySigner; -use dstack_sdk_types::dstack::GetKeyResponse; +use dstack_sdk_types::dstack_v0::GetKeyResponse; /// Build a signer from a `get_key` response. /// diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 80417eb17..af438decb 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache-2.0 -pub mod dstack_client; +pub mod dstack_client_v0; pub mod dstack_client_v1; /// The recommended client: the v1 guest-agent surface. diff --git a/sdk/rust/src/tappd_client.rs b/sdk/rust/src/tappd_client.rs index e313c52eb..b7cf335c6 100644 --- a/sdk/rust/src/tappd_client.rs +++ b/sdk/rust/src/tappd_client.rs @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use crate::dstack_client::BaseClient; +use crate::dstack_client_v0::BaseClient; use anyhow::{bail, Result}; use hex::encode as hex_encode; use http_client_unix_domain_socket::{ClientUnix, Method}; @@ -43,6 +43,10 @@ pub enum TappdClientKind { } /// The main client for interacting with the legacy Tappd service +#[deprecated( + since = "0.6.0", + note = "the tappd surface was superseded by v0 in dstack 0.3.0; use `DstackClientV1`, which the unsuffixed `DstackClient` now names" +)] pub struct TappdClient { /// The base URL for HTTP requests base_url: String, @@ -52,8 +56,12 @@ pub struct TappdClient { client: TappdClientKind, } +// Same as the v0 client: deprecating the type does not stop its own methods +// from naming it. +#[allow(deprecated)] impl BaseClient for TappdClient {} +#[allow(deprecated)] impl TappdClient { pub fn new(endpoint: Option<&str>) -> Self { let endpoint = get_tappd_endpoint(endpoint); diff --git a/sdk/rust/tests/test_client.rs b/sdk/rust/tests/test_client.rs index 9084b4f84..e4cc38bdb 100644 --- a/sdk/rust/tests/test_client.rs +++ b/sdk/rust/tests/test_client.rs @@ -5,8 +5,11 @@ // // SPDX-License-Identifier: Apache-2.0 +// This file exercises the deprecated v0 surface; that is its purpose. +#![allow(deprecated)] + use dcap_qvl::quote::Quote; -use dstack_sdk::dstack_client::DstackClientV0 as AsyncDstackClient; +use dstack_sdk::dstack_client_v0::DstackClientV0 as AsyncDstackClient; use sha2::{Digest, Sha256}; #[tokio::test] @@ -27,7 +30,7 @@ async fn test_async_client_get_quote() { #[tokio::test] async fn test_async_client_get_tls_key() { let client = AsyncDstackClient::new(None); - let key_config = dstack_sdk_types::dstack::TlsKeyConfig::builder().build(); + let key_config = dstack_sdk_types::dstack_v0::TlsKeyConfig::builder().build(); let result = client.get_tls_key(key_config).await.unwrap(); assert!(result.key.starts_with("-----BEGIN PRIVATE KEY-----")); assert!(!result.certificate_chain.is_empty()); @@ -36,8 +39,8 @@ async fn test_async_client_get_tls_key() { #[tokio::test] async fn test_tls_key_uniqueness() { let client = AsyncDstackClient::new(None); - let key_config_1 = dstack_sdk_types::dstack::TlsKeyConfig::builder().build(); - let key_config_2 = dstack_sdk_types::dstack::TlsKeyConfig::builder().build(); + let key_config_1 = dstack_sdk_types::dstack_v0::TlsKeyConfig::builder().build(); + let key_config_2 = dstack_sdk_types::dstack_v0::TlsKeyConfig::builder().build(); let result1 = client.get_tls_key(key_config_1).await.unwrap(); let result2 = client.get_tls_key(key_config_2).await.unwrap(); assert_ne!(result1.key, result2.key); diff --git a/sdk/rust/tests/test_client_v1.rs b/sdk/rust/tests/test_client_v1.rs index bbddd60a9..75386e872 100644 --- a/sdk/rust/tests/test_client_v1.rs +++ b/sdk/rust/tests/test_client_v1.rs @@ -4,7 +4,6 @@ //! `DstackClientV1` against the guest-agent simulator. -use dstack_sdk::dstack_client::DstackClientV0; use dstack_sdk::dstack_client_v1::{DstackClientV1, IssueCertConfig}; fn client() -> DstackClientV1 { @@ -97,9 +96,12 @@ async fn the_two_algorithms_never_share_key_material() { /// than merely documented: an app that reuses its v0 path as a v1 domain gets /// different key material. #[tokio::test] +// The point of the assertion is that the two surfaces disagree, so it has to +// name the deprecated one. +#[allow(deprecated)] async fn v1_keys_differ_from_v0_keys_for_the_same_name() { let v1 = client().get_key("test", "secp256k1").await.unwrap(); - let v0 = DstackClientV0::new(None) + let v0 = dstack_sdk::dstack_client_v0::DstackClientV0::new(None) .get_key(Some("test".to_string()), Some("signing".to_string())) .await .unwrap(); diff --git a/sdk/rust/tests/test_eth.rs b/sdk/rust/tests/test_eth.rs index 2b6bea755..f9aa662be 100644 --- a/sdk/rust/tests/test_eth.rs +++ b/sdk/rust/tests/test_eth.rs @@ -4,9 +4,12 @@ // // SPDX-License-Identifier: Apache-2.0 -use dstack_sdk::dstack_client::DstackClientV0; +// This file exercises the deprecated v0 surface; that is its purpose. +#![allow(deprecated)] + +use dstack_sdk::dstack_client_v0::DstackClientV0; use dstack_sdk::ethereum::to_account; -use dstack_sdk_types::dstack::GetKeyResponse; +use dstack_sdk_types::dstack_v0::GetKeyResponse; #[tokio::test] async fn test_async_to_keypair() { diff --git a/sdk/rust/tests/test_tappd_client.rs b/sdk/rust/tests/test_tappd_client.rs index a5ef116f7..d15247e24 100644 --- a/sdk/rust/tests/test_tappd_client.rs +++ b/sdk/rust/tests/test_tappd_client.rs @@ -3,6 +3,9 @@ // // SPDX-License-Identifier: Apache-2.0 +// This file exercises the deprecated v0 surface; that is its purpose. +#![allow(deprecated)] + use dstack_sdk::tappd_client::TappdClient; use dstack_sdk_types::tappd::DeriveKeyResponse; use std::env; diff --git a/sdk/rust/types/README.md b/sdk/rust/types/README.md index 7ca4a40c6..a8db4e617 100644 --- a/sdk/rust/types/README.md +++ b/sdk/rust/types/README.md @@ -19,7 +19,7 @@ This crate is `#![no_std]` compatible and provides two main modules: ## Basic Usage ```rust -use dstack_sdk_types::dstack::{GetKeyResponse, GetQuoteResponse, InfoResponse}; +use dstack_sdk_types::dstack_v0::{GetKeyResponse, GetQuoteResponse, InfoResponse}; use dstack_sdk_types::tappd::{DeriveKeyResponse, TdxQuoteResponse, TappdInfoResponse}; // Parse a response from the dstack API diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack_v0.rs similarity index 100% rename from sdk/rust/types/src/dstack.rs rename to sdk/rust/types/src/dstack_v0.rs diff --git a/sdk/rust/types/src/dstack_v1.rs b/sdk/rust/types/src/dstack_v1.rs index 2c59c908d..040d14f59 100644 --- a/sdk/rust/types/src/dstack_v1.rs +++ b/sdk/rust/types/src/dstack_v1.rs @@ -4,7 +4,7 @@ //! Types for the `dstack.guest.v1` API surface. //! -//! Separate from [`crate::dstack`] because the two surfaces are separate +//! Separate from [`crate::dstack_v0`] because the two surfaces are separate //! contracts, not versions of one type: v1's `GetKeyResponse` carries a public //! key the v0 one has no field for, and its `InfoResponse` is flat where the v0 //! one nests a `tcb_info` document. Sharing a type between them would mean one diff --git a/sdk/rust/types/src/lib.rs b/sdk/rust/types/src/lib.rs index 1925a0218..3e49ae7bb 100644 --- a/sdk/rust/types/src/lib.rs +++ b/sdk/rust/types/src/lib.rs @@ -6,6 +6,6 @@ extern crate alloc; -pub mod dstack; +pub mod dstack_v0; pub mod dstack_v1; pub mod tappd; diff --git a/sdk/rust/types/src/tappd.rs b/sdk/rust/types/src/tappd.rs index 06e72e8d7..8d0c816bb 100644 --- a/sdk/rust/types/src/tappd.rs +++ b/sdk/rust/types/src/tappd.rs @@ -12,7 +12,7 @@ use borsh::BorshSchema; #[cfg(feature = "borsh")] use borsh::{BorshDeserialize, BorshSerialize}; -use crate::dstack::EventLog; +use crate::dstack_v0::EventLog; /// Hash algorithms supported by the TDX quote generation #[derive(Debug, Clone, Serialize, Deserialize)]