diff --git a/sdk/go/README.md b/sdk/go/README.md index b915fe12..43815a56 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -1,6 +1,6 @@ # dstack SDK for Go -Access TEE features from your Go application running inside dstack. Derive deterministic keys, generate attestation quotes, create TLS certificates, and sign data—all backed by hardware security. +The dstack SDK provides a Go client for secure communication with the dstack Trusted Execution Environment (TEE). This SDK enables applications to derive cryptographic keys, generate remote attestation quotes, and perform other security-critical operations within confidential computing environments. ## Installation @@ -8,196 +8,821 @@ Access TEE features from your Go application running inside dstack. Derive deter go get github.com/Dstack-TEE/dstack/sdk/go ``` -## Quick Start +## Overview + +The dstack SDK enables secure communication with dstack Trusted Execution Environment (TEE) instances. dstack applications are defined using `app-compose.json` (based on the `AppCompose` structure) and deployed as containerized applications using Docker Compose. + +### Application Architecture + +dstack applications consist of: +- **App Configuration**: `app-compose.json` defining app metadata, security settings, and Docker Compose content +- **Container Deployment**: Docker Compose configuration embedded within the app definition +- **TEE Integration**: Access to TEE functionality via Unix socket (`/var/run/dstack.sock`) + +### SDK Capabilities + +- **Key Derivation**: Deterministic secp256k1 key generation for blockchain and Web3 applications +- **Remote Attestation**: TDX quote generation providing cryptographic proof of execution environment +- **TLS Certificate Management**: Fresh certificate generation with optional RA-TLS support for secure connections +- **Deployment Security**: Client-side encryption of sensitive environment variables ensuring secrets are only accessible to target TEE applications +- **Blockchain Integration**: Ready-to-use adapters for Ethereum and Solana ecosystems + +### Socket Connection Requirements + +To use the SDK, your Docker Compose configuration must bind-mount the dstack socket: + +```yaml +# docker-compose.yml +services: + your-app: + image: your-app-image + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock # dstack OS 0.5.x + # For dstack OS 0.3.x compatibility (deprecated): + # - /var/run/tappd.sock:/var/run/tappd.sock +``` + +## Basic Usage + +### Application Setup + +First, ensure your dstack application is properly configured: + +**1. App Configuration (`app-compose.json`)** +```json +{ + "manifest_version": 1, + "name": "my-secure-app", + "runner": "docker-compose", + "docker_compose_file": "services:\n app:\n build: .\n volumes:\n - /var/run/dstack.sock:/var/run/dstack.sock\n environment:\n - NODE_ENV=production", + "public_tcbinfo": true, + "kms_enabled": false, + "gateway_enabled": false +} +``` + +**Note**: The `docker_compose_file` field contains the actual Docker Compose YAML content as a string, not a file path. + +### SDK Integration ```go package main import ( "context" + "encoding/hex" + "encoding/json" "fmt" + "log" + "time" "github.com/Dstack-TEE/dstack/sdk/go/dstack" ) func main() { + // Create client - automatically connects to /var/run/dstack.sock client := dstack.NewDstackClient() - // Derive a deterministic key for your wallet - key, _ := client.GetKey(context.Background(), "wallet/eth", "", "secp256k1") - fmt.Println(key.Key) // Same path always returns the same key + // For local development with simulator + // devClient := dstack.NewDstackClient(dstack.WithEndpoint("http://localhost:8090")) + + ctx := context.Background() + + // Get TEE instance information + info, err := client.Info(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("App ID:", info.AppID) + fmt.Println("Instance ID:", info.InstanceID) + fmt.Println("App Name:", info.AppName) + fmt.Println("TCB Info:", info.TcbInfo) + + // Derive deterministic keys for blockchain applications + walletKey, err := client.GetKey(ctx, "wallet/ethereum", "mainnet", "secp256k1") + if err != nil { + log.Fatal(err) + } + + keyBytes, _ := walletKey.DecodeKey() + fmt.Println("Derived key (32 bytes):", hex.EncodeToString(keyBytes)) // secp256k1 private key + fmt.Println("Signature chain:", walletKey.SignatureChain) // Authenticity proof + + // Generate remote attestation quote + applicationData := map[string]interface{}{ + "version": "1.0.0", + "timestamp": time.Now().Unix(), + "user_id": "alice", + } + + jsonData, _ := json.Marshal(applicationData) + quote, err := client.GetQuote(ctx, jsonData) + if err != nil { + log.Fatal(err) + } + + fmt.Println("TDX Quote:", quote.Quote) + fmt.Println("Event Log:", quote.EventLog) + + // Verify measurement registers + rtmrs, err := quote.ReplayRTMRs() + if err != nil { + log.Fatal(err) + } + fmt.Println("RTMR0-3:", rtmrs) +} +``` + +### Version Compatibility + +- **dstack OS 0.5.x**: Use `/var/run/dstack.sock` (current) +- **dstack OS 0.3.x**: Use `/var/run/tappd.sock` (deprecated but supported) - // Generate an attestation quote - attest, _ := client.Attest(context.Background(), []byte("my-app-state")) - fmt.Println(attest.Attestation) +The SDK automatically detects the correct socket path, but you must ensure the appropriate volume binding in your Docker Compose configuration. + +## Advanced Features + +### TLS Certificate Generation + +Generate fresh TLS certificates with optional Remote Attestation support. **Important**: `GetTlsKey()` generates random keys on each call - it's designed specifically for TLS/SSL scenarios where fresh keys are required. + +```go +// Generate TLS certificate with different usage scenarios +tlsKey, err := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ + Subject: "my-secure-service", // Certificate common name + AltNames: []string{"localhost", "127.0.0.1"}, // Additional valid domains/IPs + UsageRaTls: true, // Include remote attestation + UsageServerAuth: true, // Enable server authentication (default) + UsageClientAuth: false, // Disable client authentication +}) +if err != nil { + log.Fatal(err) } + +fmt.Println("Private Key (PEM):", tlsKey.Key) +fmt.Println("Certificate Chain:", tlsKey.CertificateChain) + +// ⚠️ WARNING: Each call generates a different key +tlsKey1, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{}) +tlsKey2, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{}) +// tlsKey1.Key != tlsKey2.Key (always different!) ``` -The client automatically connects to `/var/run/dstack.sock`. For local development with the simulator: +### Event Logging + +> [!NOTE] +> This feature isn't available in the simulator. We recommend sticking with `report_data` for most cases since it's simpler and safer to use. If you're not super familiar with SGX/TDX attestation quotes, it's best to avoid adding data directly into quotes as it could cause verification issues. + +Extend RTMR3 with custom events for audit trails: ```go -client := dstack.NewDstackClient(dstack.WithEndpoint("http://localhost:8090")) +// Emit custom events (requires dstack OS 0.5.0+) +eventData := map[string]interface{}{ + "action": "transfer", + "amount": 1000, + "timestamp": time.Now().Unix(), +} +eventPayload, _ := json.Marshal(eventData) + +err := client.EmitEvent(ctx, "user-action", eventPayload) +if err != nil { + log.Fatal(err) +} + +// Events are automatically included in subsequent quotes +quote, err := client.GetQuote(ctx, []byte("audit-data")) +if err != nil { + log.Fatal(err) +} + +var events []interface{} +json.Unmarshal([]byte(quote.EventLog), &events) +``` + +## Optional blockchain helpers (build tags) + +By default, the Go SDK builds a **core profile** (attestation, key derivation, info, signing, env encryption). + +Optional helpers are split by tags: + +- `ethereum` tag: + - `ToEthereumAccount()` + - `ToEthereumAccountSecure()` +- `solana` tag: + - `ToSolanaKeypair()` + - `ToSolanaKeypairSecure()` + +### Enable Ethereum helpers + +```bash +# add optional dependency +go get github.com/ethereum/go-ethereum@v1.16.8 + +# build/test with ethereum helpers enabled +go build -tags ethereum ./... +go test -tags ethereum ./... +``` + +### Enable Solana helpers + +```bash +# no extra dependency is required for solana helper APIs +go build -tags solana ./... +go test -tags solana ./... +``` + +### Enable both + +```bash +go get github.com/ethereum/go-ethereum@v1.16.8 +go build -tags "ethereum solana" ./... +go test -tags "ethereum solana" ./... ``` -## Core API +If you don't need blockchain helper APIs, do not use these tags and you won't pull optional helper imports. -### Derive Keys +### Testing against a local starter app -`GetKey()` derives deterministic keys bound to your application's identity (`app_id`). The same path always produces the same key for your app, but different apps get different keys even with the same path. +You can validate SDK changes immediately from another Go project by using `replace`: ```go -// Derive keys by path -ethKey, _ := client.GetKey(ctx, "wallet/ethereum", "", "secp256k1") -btcKey, _ := client.GetKey(ctx, "wallet/bitcoin", "", "secp256k1") +require github.com/Dstack-TEE/dstack/sdk/go v0.0.0 +replace github.com/Dstack-TEE/dstack/sdk/go => ../dstack/sdk/go +``` -// Use path to separate keys -mainnetKey, _ := client.GetKey(ctx, "wallet/eth/mainnet", "", "secp256k1") -testnetKey, _ := client.GetKey(ctx, "wallet/eth/testnet", "", "secp256k1") +Then run your starter normally: -// Different algorithm -edKey, _ := client.GetKey(ctx, "signing/key", "", "ed25519") +```bash +go mod tidy +go run . ``` -**Parameters:** -- `path`: Key derivation path (determines the key) -- `purpose`: Included in signature chain message, does not affect the derived key -- `algorithm`: `"secp256k1"` or `"ed25519"` +If your starter enables optional blockchain routes, run with matching tags: + +```bash +# ethereum only +go get github.com/ethereum/go-ethereum@v1.16.8 +go run -tags ethereum . + +# solana only +go run -tags solana . + +# both +go run -tags "ethereum solana" . +``` -**Returns:** `*GetKeyResponse` -- `Key`: Hex-encoded private key -- `SignatureChain`: Signatures proving the key was derived in a genuine TEE +## Blockchain Integration -### Generate Attestation Quotes +### Ethereum -`GetQuote()` creates a TDX quote proving your code runs in a genuine TEE. +> requires build tag: `ethereum` ```go -quote, _ := client.GetQuote(ctx, []byte("user:alice:nonce123")) +import ( + "github.com/Dstack-TEE/dstack/sdk/go/dstack" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) -// Replay RTMRs from the event log -rtmrs, _ := quote.ReplayRTMRs() -fmt.Println(rtmrs) +keyResult, err := client.GetKey(ctx, "ethereum/main", "wallet", "secp256k1") +if err != nil { + log.Fatal(err) +} + +// Standard account creation +account, err := dstack.ToEthereumAccount(keyResult) +if err != nil { + log.Fatal(err) +} + +// Enhanced security with SHA256 hashing (recommended) +secureAccount, err := dstack.ToEthereumAccountSecure(keyResult) +if err != nil { + log.Fatal(err) +} + +fmt.Println("Ethereum Address:", secureAccount.Address.Hex()) + +// Connect to Ethereum network +ethClient, err := ethclient.Dial("https://mainnet.infura.io/v3/YOUR-PROJECT-ID") +if err != nil { + log.Fatal(err) +} + +// Use account for transactions... ``` -**Parameters:** -- `reportData`: Exactly 64 bytes recommended. If shorter, pad with zeros. If longer, hash it first (e.g., SHA-256). +### Solana + +> requires build tag: `solana` + +```go +import ( + "crypto/ed25519" + "encoding/hex" + + "github.com/Dstack-TEE/dstack/sdk/go/dstack" +) -**Returns:** `*GetQuoteResponse` -- `Quote`: TDX quote as bytes -- `EventLog`: JSON string of measured events -- `ReplayRTMRs()`: Method to compute RTMR values from event log +keyResult, err := client.GetKey(ctx, "solana/main", "wallet", "secp256k1") +if err != nil { + log.Fatal(err) +} + +// Standard keypair creation +keypair, err := dstack.ToSolanaKeypair(keyResult) +if err != nil { + log.Fatal(err) +} + +// Enhanced security with SHA256 hashing (recommended) +secureKeypair, err := dstack.ToSolanaKeypairSecure(keyResult) +if err != nil { + log.Fatal(err) +} + +fmt.Println("Solana Public Key:", hex.EncodeToString(secureKeypair.PublicKey)) + +// Sign messages +message := []byte("Hello Solana") +signature := secureKeypair.Sign(message) +fmt.Println("Signature:", hex.EncodeToString(signature)) -`Attest()` creates a versioned attestation with the given report data. +// Verify signature +isValid := secureKeypair.Verify(message, signature) +fmt.Println("Valid signature:", isValid) +``` + +## Environment Variables Encryption + +**Important**: This feature is specifically for **deployment-time security**, not runtime SDK operations. + +The SDK provides end-to-end encryption capabilities for securely transmitting sensitive environment variables during dstack application deployment. + +### Deployment Encryption Workflow ```go -attest, _ := client.Attest(ctx, []byte("my-app-state")) -fmt.Println(attest.Attestation) +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/Dstack-TEE/dstack/sdk/go/dstack" +) + +// 1. Define sensitive environment variables +envVars := []dstack.EnvVar{ + {Key: "DATABASE_URL", Value: "postgresql://user:pass@host:5432/db"}, + {Key: "API_SECRET_KEY", Value: "your-secret-key"}, + {Key: "JWT_PRIVATE_KEY", Value: "-----BEGIN PRIVATE KEY-----\n..."}, + {Key: "WALLET_MNEMONIC", Value: "abandon abandon abandon..."}, +} + +// 2. Obtain encryption public key from KMS API (dstack-vmm or Phala Cloud) +// (HTTP request implementation depends on your HTTP client) +publicKey := "a1b2c3d4..." // From KMS API +signature := "e1f2g3h4..." // From KMS API + +// 3. Verify KMS API authenticity to prevent man-in-the-middle attacks +publicKeyBytes, _ := hex.DecodeString(publicKey) +signatureBytes, _ := hex.DecodeString(signature) + +// Prefer timestamped verification to prevent replay attacks. +timestamp := uint64(time.Now().Unix()) // should come from KMS API response +trustedPubkey, err := dstack.VerifyEnvEncryptPublicKeyWithTimestamp( + publicKeyBytes, + signatureBytes, + "your-app-id-hex", + timestamp, + nil, // use default freshness policy (max age 300s) +) +if err != nil || trustedPubkey == nil { + log.Fatal("KMS API provided untrusted encryption key") +} + +fmt.Println("Verified KMS public key:", hex.EncodeToString(trustedPubkey)) + +// Note: VerifyEnvEncryptPublicKey() is kept for legacy compatibility (without timestamp check). + +// 4. Encrypt environment variables for secure deployment +encryptedData, err := dstack.EncryptEnvVars(envVars, publicKey) +if err != nil { + log.Fatal(err) +} +fmt.Println("Encrypted payload:", encryptedData) + +// 5. Deploy with encrypted configuration +// deployDstackApp(..., encryptedData) ``` -**Parameters:** -- `reportData`: Exactly 64 bytes recommended. If shorter, pad with zeros. If longer, hash it first (e.g., SHA-256). +## Cryptographic Security -**Returns:** `*AttestResponse` -- `Attestation`: Versioned attestation as bytes +### Key Derivation Security -### Get Instance Info +The SDK implements secure key derivation using: + +- **Deterministic Generation**: Keys are derived using HMAC-based Key Derivation Function (HKDF) +- **Application Isolation**: Each path produces unique keys, preventing cross-application access +- **Signature Verification**: All derived keys include cryptographic proof of origin +- **TEE Protection**: Master keys never leave the secure enclave ```go -info, _ := client.Info(ctx) -fmt.Println(info.AppID) -fmt.Println(info.InstanceID) -fmt.Println(info.TcbInfo) +// Each path generates a unique, deterministic key +wallet1, _ := client.GetKey(ctx, "app1/wallet", "ethereum", "secp256k1") +wallet2, _ := client.GetKey(ctx, "app2/wallet", "ethereum", "secp256k1") +// wallet1.Key != wallet2.Key (guaranteed different) -// Decode TCB info for detailed measurements -tcb, _ := info.DecodeTcbInfo() -fmt.Println(tcb.Mrtd) +sameWallet, _ := client.GetKey(ctx, "app1/wallet", "ethereum", "secp256k1") +// wallet1.Key == sameWallet.Key (guaranteed identical) ``` -**Returns:** `*InfoResponse` -- `AppID`: Application identifier -- `InstanceID`: Instance identifier -- `AppName`: Application name -- `TcbInfo`: TCB measurements (JSON string) -- `ComposeHash`: Hash of the app configuration -- `AppCert`: Application certificate (PEM) -- `DecodeTcbInfo()`: Helper method to parse TcbInfo JSON +### Remote Attestation -### Generate TLS Certificates +TDX quotes provide cryptographic proof of: -`GetTlsKey()` creates fresh TLS certificates. Unlike `GetKey()`, each call generates a new random key. +- **Code Integrity**: Measurement of loaded application code +- **Data Integrity**: Inclusion of application-specific data in quote +- **Environment Authenticity**: Verification of TEE platform and configuration ```go -tls, _ := client.GetTlsKey( - ctx, - dstack.WithSubject("api.example.com"), - dstack.WithAltNames([]string{"localhost"}), - dstack.WithUsageRaTls(true), // Embed attestation in certificate - dstack.WithUsageServerAuth(true), -) +applicationState := map[string]interface{}{ + "version": "1.0.0", + "config_hash": "sha256:...", + "timestamp": time.Now().Unix(), +} -fmt.Println(tls.Key) // PEM private key -fmt.Println(tls.CertificateChain) // Certificate chain +stateData, _ := json.Marshal(applicationState) +quote, err := client.GetQuote(ctx, stateData) +if err != nil { + log.Fatal(err) +} + +// Quote can be verified by external parties to confirm: +// 1. Application is running in genuine TEE +// 2. Application code matches expected measurements +// 3. Application state is authentic and unmodified ``` -**Options:** -- `WithSubject(subject)`: Certificate common name (e.g., domain name) -- `WithAltNames(altNames)`: List of subject alternative names -- `WithUsageRaTls(bool)`: Embed TDX quote in certificate extension -- `WithUsageServerAuth(bool)`: Enable for server authentication -- `WithUsageClientAuth(bool)`: Enable for client authentication +### Environment Encryption Protocol + +The encryption scheme uses: -**Returns:** `*GetTlsKeyResponse` -- `Key`: PEM-encoded private key -- `CertificateChain`: List of PEM certificates +- **X25519 ECDH**: Elliptic curve key exchange for forward secrecy +- **AES-256-GCM**: Authenticated encryption with 256-bit keys +- **Ephemeral Keys**: New keypair generated for each encryption operation +- **Authenticated Data**: Prevents tampering and ensures integrity + +## Development and Testing + +### Local Development + +For development without physical TDX hardware: + +```bash +# Clone and build simulator +git clone https://github.com/Dstack-TEE/dstack.git +cd dstack/sdk/simulator +./build.sh +./dstack-simulator -### Sign and Verify +# Set environment variable +export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090 +``` -Sign data using TEE-derived keys (not yet released): +### Testing Connectivity ```go -result, _ := client.Sign(ctx, "ed25519", []byte("message to sign")) -fmt.Println(result.Signature) -fmt.Println(result.PublicKey) +client := dstack.NewDstackClient() -// Verify the signature -valid, _ := client.Verify(ctx, "ed25519", []byte("message to sign"), result.Signature, result.PublicKey) -fmt.Println(valid.Valid) // true +// Check if dstack service is available +isAvailable := client.IsReachable(context.Background()) +if !isAvailable { + log.Fatal("dstack service is not reachable") +} ``` -**`Sign()` Parameters:** -- `algorithm`: `"ed25519"`, `"secp256k1"`, or `"secp256k1_prehashed"` -- `data`: Data to sign +The client automatically connects to `/var/run/dstack.sock`. For local development with the simulator: + +```go +client := dstack.NewDstackClient(dstack.WithEndpoint("http://localhost:8090")) +``` -**`Sign()` Returns:** `*SignResponse` -- `Signature`: Signature bytes -- `PublicKey`: Public key bytes -- `SignatureChain`: Signatures proving TEE origin +**Options:** +- `WithEndpoint(endpoint string)`: Connection endpoint + - Unix socket path (production): `/var/run/dstack.sock` + - HTTP/HTTPS URL (development): `http://localhost:8090` + - Environment variable: `DSTACK_SIMULATOR_ENDPOINT` +- `WithLogger(logger *slog.Logger)`: Custom logger (default: `slog.Default()`) + +**Production App Configuration:** + +The Docker Compose configuration is embedded in `app-compose.json`: + +```json +{ + "manifest_version": 1, + "name": "production-app", + "runner": "docker-compose", + "docker_compose_file": "services:\n app:\n image: your-app\n volumes:\n - /var/run/dstack.sock:/var/run/dstack.sock\n environment:\n - NODE_ENV=production", + "public_tcbinfo": true +} +``` -**`Verify()` Parameters:** -- `algorithm`: Algorithm used for signing -- `data`: Original data -- `signature`: Signature to verify -- `publicKey`: Public key to verify against +**Important**: The `docker_compose_file` contains YAML content as a string, ensuring the volume binding for `/var/run/dstack.sock` is included. -**`Verify()` Returns:** `*VerifyResponse` -- `Valid`: Boolean indicating if signature is valid +#### Methods -### Emit Events +##### `Info(ctx context.Context) (*InfoResponse, error)` -Extend RTMR3 with custom measurements for your application's boot sequence (requires dstack OS 0.5.0+). These measurements are append-only and become part of the attestation record. +Retrieves comprehensive information about the TEE instance. + +**Returns:** `InfoResponse` +- `AppID`: Unique application identifier +- `InstanceID`: Unique instance identifier +- `AppName`: Application name from configuration +- `DeviceID`: TEE device identifier +- `TcbInfo`: Trusted Computing Base information + - `Mrtd`: Measurement of TEE domain + - `Rtmr0-3`: Runtime Measurement Registers + - `EventLog`: Boot and runtime events +- `AppCert`: Application certificate in PEM format + +##### `GetKey(ctx context.Context, path string, purpose string) (*GetKeyResponse, error)` + +Derives a deterministic secp256k1/K256 private key for blockchain and Web3 applications. This is the primary method for obtaining cryptographic keys for wallets, signing, and other deterministic key scenarios. + +**Parameters:** +- `path`: Unique identifier for key derivation (e.g., `"wallet/ethereum"`, `"signing/solana"`) +- `purpose`: Additional context for key usage (default: `""`) + +**Returns:** `GetKeyResponse` +- `Key`: 32-byte secp256k1 private key as hex string (suitable for Ethereum, Bitcoin, Solana, etc.) +- `SignatureChain`: Array of cryptographic signatures proving key authenticity + +**Key Characteristics:** +- **Deterministic**: Same path + purpose always generates identical key +- **Isolated**: Different paths produce cryptographically independent keys +- **Blockchain-Ready**: Compatible with secp256k1 curve (Ethereum, Bitcoin, Solana) +- **Verifiable**: Signature chain proves key was derived inside genuine TEE + +**Use Cases:** +- Cryptocurrency wallets +- Transaction signing +- DeFi protocol interactions +- NFT operations +- Any scenario requiring consistent, reproducible keys ```go -client.EmitEvent(ctx, "config_loaded", []byte("production")) -client.EmitEvent(ctx, "plugin_initialized", []byte("auth-v2")) +// Examples of deterministic key derivation +ethWallet, _ := client.GetKey(ctx, "wallet/ethereum", "mainnet", "secp256k1") +btcWallet, _ := client.GetKey(ctx, "wallet/bitcoin", "mainnet") +solWallet, _ := client.GetKey(ctx, "wallet/solana", "mainnet") + +// Same path always returns same key +key1, _ := client.GetKey(ctx, "my-app/signing", "", "secp256k1") +key2, _ := client.GetKey(ctx, "my-app/signing", "", "secp256k1") +// key1.Key == key2.Key (guaranteed identical) + +// Different paths return different keys +userA, _ := client.GetKey(ctx, "user/alice/wallet", "", "secp256k1") +userB, _ := client.GetKey(ctx, "user/bob/wallet", "", "secp256k1") +// userA.Key != userB.Key (guaranteed different) ``` +##### `GetQuote(ctx context.Context, reportData []byte) (*GetQuoteResponse, error)` + +Generates a TDX attestation quote containing the provided report data. + **Parameters:** -- `event`: Event name (string identifier) -- `payload`: Event value (bytes) +- `reportData`: Data to include in quote (max 64 bytes) + +**Returns:** `GetQuoteResponse` +- `Quote`: TDX quote as hex string +- `EventLog`: JSON string of system events +- `ReplayRTMRs()`: Function returning computed RTMR values + +**Use Cases:** +- Remote attestation of application state +- Cryptographic proof of execution environment +- Audit trail generation + +##### `GetTlsKey(ctx context.Context, options TlsKeyOptions) (*GetTlsKeyResponse, error)` + +Generates a fresh, random TLS key pair with X.509 certificate for TLS/SSL connections. **Important**: This method generates different keys on each call - use `GetKey()` for deterministic keys. + +**Parameters:** `TlsKeyOptions` +- `Subject`: Certificate subject (Common Name) - typically the domain name (default: `""`) +- `AltNames`: Subject Alternative Names - additional domains/IPs for the certificate (default: `[]`) +- `UsageRaTls`: Include TDX attestation quote in certificate extension for remote verification (default: `false`) +- `UsageServerAuth`: Enable server authentication - allows certificate to authenticate servers (default: `true`) +- `UsageClientAuth`: Enable client authentication - allows certificate to authenticate clients (default: `false`) + +**Returns:** `GetTlsKeyResponse` +- `Key`: Private key in PEM format (X.509/PKCS#8) +- `CertificateChain`: Certificate chain array + +**Key Characteristics:** +- **Random Generation**: Each call produces a completely different key +- **TLS-Optimized**: Keys and certificates designed for TLS/SSL scenarios +- **RA-TLS Support**: Optional remote attestation extension in certificates +- **TEE-Signed**: Certificates signed by TEE-resident Certificate Authority + +```go +// Example 1: Standard HTTPS server certificate +serverCert, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ + Subject: "api.example.com", + AltNames: []string{"api.example.com", "www.api.example.com", "10.0.0.1"}, + // UsageServerAuth: true (default) - allows server authentication + // UsageClientAuth: false (default) - no client authentication +}) + +// Example 2: Certificate with remote attestation (RA-TLS) +attestedCert, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ + Subject: "secure-api.example.com", + UsageRaTls: true, // Include TDX quote for remote verification + // Clients can verify the TEE environment through the certificate +}) + +// ⚠️ Each call generates different keys (unlike GetKey) +cert1, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{}) +cert2, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{}) +// cert1.Key != cert2.Key (always different) +``` + +##### `EmitEvent(ctx context.Context, event string, payload []byte) error` + +Extends RTMR3 with a custom event for audit logging. + +**Parameters:** +- `event`: Event identifier string +- `payload`: Event data + +**Requirements:** +- dstack OS version 0.5.0 or later +- Events are permanently recorded in TEE measurements + +##### `IsReachable(ctx context.Context) bool` + +Tests connectivity to the dstack service. + +**Returns:** `bool` indicating service availability + +## Utility Functions + +### Compose Hash Calculation + +```go +import "github.com/Dstack-TEE/dstack/sdk/go/dstack" + +appCompose := dstack.AppCompose{ + ManifestVersion: &[]int{1}[0], + Name: "my-app", + Runner: "docker-compose", + DockerComposeFile: "docker-compose.yml", +} + +hash, err := dstack.GetComposeHash(appCompose) +if err != nil { + log.Fatal(err) +} +fmt.Println("Configuration hash:", hash) +``` + +### KMS Public Key Verification + +Verify the authenticity of encryption public keys provided by KMS APIs: + +```go +import ( + "encoding/hex" + "time" + "github.com/Dstack-TEE/dstack/sdk/go/dstack" +) + +// Example: Verify KMS-provided encryption key +publicKey, _ := hex.DecodeString("e33a1832c6562067ff8f844a61e51ad051f1180b66ec2551fb0251735f3ee90a") +signature, _ := hex.DecodeString("8542c49081fbf4e03f62034f13fbf70630bdf256a53032e38465a27c36fd6bed7a5e7111652004aef37f7fd92fbfc1285212c4ae6a6154203a48f5e16cad2cef00") +appID := "0000000000000000000000000000000000000000" + +timestamp := uint64(time.Now().Unix()) // should come from KMS API response +kmsIdentity, err := dstack.VerifyEnvEncryptPublicKeyWithTimestamp(publicKey, signature, appID, timestamp, nil) + +if err == nil && kmsIdentity != nil { + fmt.Println("Trusted KMS identity:", hex.EncodeToString(kmsIdentity)) + // Safe to use the public key for encryption +} else { + fmt.Println("KMS signature verification failed") + // Potential man-in-the-middle attack +} +``` + +## Security Best Practices + +1. **Key Management** + - Use descriptive, unique paths for key derivation + - Never expose derived keys outside the TEE + - Implement proper access controls in your application + +2. **Remote Attestation** + - Always verify quotes before trusting remote TEE instances + - Include application-specific data in quote generation + - Validate RTMR measurements against expected values + +3. **TLS Configuration** + - Enable RA-TLS for attestation-based authentication + - Use appropriate certificate validity periods + - Implement proper certificate validation + +4. **Error Handling** + - Handle cryptographic operation failures gracefully + - Log security events for monitoring + - Implement fallback mechanisms where appropriate + +## Migration Guide + +### Critical API Changes: Understanding the Separation + +The legacy client mixed two different use cases that have now been properly separated: + +1. **`GetKey()`**: Deterministic key derivation for Web3/blockchain (secp256k1) +2. **`GetTlsKey()`**: Random TLS certificate generation for HTTPS/SSL + +### From TappdClient to DstackClient + +**⚠️ BREAKING CHANGE**: `TappdClient` is deprecated and will be removed. All users must migrate to `DstackClient`. + +### Complete Migration Reference + +| Component | TappdClient (Old) | DstackClient (New) | Status | +|-----------|-------------------|-------------------|--------| +| **Socket Path** | `/var/run/tappd.sock` | `/var/run/dstack.sock` | ✅ Updated | +| **HTTP URL Format** | `http://localhost/prpc/Tappd.` | `http://localhost/` | ✅ Simplified | +| **K256 Key Method** | `DeriveKey(...)` | `GetKey(...)` | ✅ Renamed | +| **TLS Certificate Method** | `DeriveKey(...)` | `GetTlsKey(...)` | ✅ Separated | +| **TDX Quote** | `TdxQuote(...)` | `GetQuote(report_data)` | ✅ Renamed | + +#### Migration Steps + +**Step 1: Update Imports and Client** + +```go +// Before +import "github.com/Dstack-TEE/dstack/sdk/go/tappd" +client := tappd.NewTappdClient() + +// After +import "github.com/Dstack-TEE/dstack/sdk/go/dstack" +client := dstack.NewDstackClient() +``` + +**Step 2: Update Method Calls** + +```go +// For deterministic keys (most common) +// Before: TappdClient methods +keyResult, _ := client.DeriveKey(ctx, "wallet") + +// After: DstackClient methods +keyResult, _ := client.GetKey(ctx, "wallet", "ethereum") + +// For TLS certificates +// Before: DeriveKey with TLS options +tlsCert, _ := client.DeriveKeyWithSubjectAndAltNames(ctx, "api", "example.com", []string{"localhost"}) + +// After: GetTlsKey with proper options +tlsCert, _ := client.GetTlsKey(ctx, dstack.TlsKeyOptions{ + Subject: "example.com", + AltNames: []string{"localhost"}, +}) +``` + +### Migration Checklist + +- [ ] **Infrastructure Updates:** + - [ ] Update Docker volume binding to `/var/run/dstack.sock` + - [ ] Change environment variables from `TAPPD_*` to `DSTACK_*` + +- [ ] **Client Code Updates:** + - [ ] Replace `tappd.NewTappdClient()` with `dstack.NewDstackClient()` + - [ ] Replace `DeriveKey()` calls with appropriate method: + - [ ] `GetKey()` for Web3/blockchain keys (deterministic) + - [ ] `GetTlsKey()` for TLS certificates (random) + - [ ] Replace `TdxQuote()` calls with `GetQuote()` + - [ ] **SECURITY CRITICAL**: Update blockchain integration functions: + - [ ] Replace `ToEthereumAccount()` with `ToEthereumAccountSecure()` (Ethereum) + - [ ] Replace `ToSolanaKeypair()` with `ToSolanaKeypairSecure()` (Solana) + +- [ ] **Testing:** + - [ ] Test that deterministic keys still work as expected + - [ ] Verify TLS certificate generation works + - [ ] Test quote generation with new interface + - [ ] Verify blockchain integrations work with secure functions ## Development -For local development without TDX hardware, use the simulator: +### Running the Simulator + +For local development without TDX devices, you can use the simulator: ```bash git clone https://github.com/Dstack-TEE/dstack.git @@ -206,10 +831,18 @@ cd dstack/sdk/simulator ./dstack-simulator ``` -Then set the endpoint: +### Running Tests ```bash -export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090 +# Set environment variables and run tests +TAPPD_SIMULATOR_ENDPOINT=/path/to/simulator/tappd.sock \ +DSTACK_SIMULATOR_ENDPOINT=/path/to/simulator/dstack.sock \ +go test -v ./dstack ./tappd + +# Run cross-language consistency tests +TAPPD_SIMULATOR_ENDPOINT=/path/to/simulator/tappd.sock \ +DSTACK_SIMULATOR_ENDPOINT=/path/to/simulator/dstack.sock \ +go run test-outputs.go ``` Run tests: diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index 654fe693..7b75eaa5 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -9,9 +9,13 @@ package dstack import ( "bytes" "context" + "crypto/ecdsa" + "crypto/ed25519" "crypto/sha512" + "crypto/x509" "encoding/hex" "encoding/json" + "encoding/pem" "fmt" "io" "log/slog" @@ -19,6 +23,7 @@ import ( "net/http" "os" "strings" + "time" ) // Represents the response from a TLS key derivation request. @@ -27,20 +32,83 @@ type GetTlsKeyResponse struct { CertificateChain []string `json:"certificate_chain"` } +// AsUint8Array converts the private key to bytes, optionally limiting the length +func (r *GetTlsKeyResponse) AsUint8Array(maxLength ...int) ([]byte, error) { + block, _ := pem.Decode([]byte(r.Key)) + if block == nil { + return nil, fmt.Errorf("failed to decode pem private key") + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + var keyBytes []byte + switch k := key.(type) { + case *ecdsa.PrivateKey: + keyBytes = k.D.FillBytes(make([]byte, (k.Curve.Params().N.BitLen()+7)/8)) + case ed25519.PrivateKey: + keyBytes = k.Seed() + default: + return nil, fmt.Errorf("unsupported key type: %T", key) + } + + if len(maxLength) > 0 && maxLength[0] > 0 && maxLength[0] < len(keyBytes) { + return keyBytes[:maxLength[0]], nil + } + return keyBytes, nil +} + // Represents the response from a key derivation request. type GetKeyResponse struct { Key string `json:"key"` SignatureChain []string `json:"signature_chain"` } +// DecodeKey returns the key as bytes +func (r *GetKeyResponse) DecodeKey() ([]byte, error) { + return hex.DecodeString(r.Key) +} + +// DecodeSignatureChain returns the signature chain as bytes +func (r *GetKeyResponse) DecodeSignatureChain() ([][]byte, error) { + result := make([][]byte, len(r.SignatureChain)) + for i, sig := range r.SignatureChain { + bytes, err := hex.DecodeString(sig) + if err != nil { + return nil, fmt.Errorf("failed to decode signature %d: %w", i, err) + } + result[i] = bytes + } + return result, nil +} + // Represents the response from a quote request. type GetQuoteResponse struct { - Quote []byte `json:"quote"` + Quote string `json:"quote"` EventLog string `json:"event_log"` - ReportData []byte `json:"report_data"` + ReportData string `json:"report_data"` VmConfig string `json:"vm_config"` } +// DecodeQuote returns the quote bytes +func (r *GetQuoteResponse) DecodeQuote() ([]byte, error) { + return hex.DecodeString(r.Quote) +} + +// DecodeReportData returns the report data bytes +func (r *GetQuoteResponse) DecodeReportData() ([]byte, error) { + return hex.DecodeString(r.ReportData) +} + +// DecodeEventLog returns the event log as structured data +func (r *GetQuoteResponse) DecodeEventLog() ([]EventLog, error) { + var events []EventLog + err := json.Unmarshal([]byte(r.EventLog), &events) + return events, err +} + // Represents the response from an attestation request. type AttestResponse struct { Attestation []byte @@ -57,17 +125,20 @@ type EventLog struct { // Represents the TCB information type TcbInfo struct { - Mrtd string `json:"mrtd"` - Rtmr0 string `json:"rtmr0"` - Rtmr1 string `json:"rtmr1"` - Rtmr2 string `json:"rtmr2"` - Rtmr3 string `json:"rtmr3"` - // The hash of the OS image. This is empty if the OS image is not measured by KMS. - OsImageHash string `json:"os_image_hash,omitempty"` - ComposeHash string `json:"compose_hash"` - DeviceID string `json:"device_id"` - AppCompose string `json:"app_compose"` - EventLog []EventLog `json:"event_log"` + Mrtd string `json:"mrtd"` + Rtmr0 string `json:"rtmr0"` + Rtmr1 string `json:"rtmr1"` + Rtmr2 string `json:"rtmr2"` + Rtmr3 string `json:"rtmr3"` + AppCompose string `json:"app_compose"` + EventLog []EventLog `json:"event_log"` + // V0.3.x fields + RootfsHash string `json:"rootfs_hash,omitempty"` + // V0.5.x fields + MrAggregated string `json:"mr_aggregated,omitempty"` + OsImageHash string `json:"os_image_hash,omitempty"` + ComposeHash string `json:"compose_hash,omitempty"` + DeviceID string `json:"device_id,omitempty"` } // Represents the response from an info request @@ -81,9 +152,11 @@ type InfoResponse struct { MrAggregated string `json:"mr_aggregated,omitempty"` KeyProviderInfo string `json:"key_provider_info"` // Optional: empty if OS image is not measured by KMS - OsImageHash string `json:"os_image_hash,omitempty"` - ComposeHash string `json:"compose_hash"` - VmConfig string `json:"vm_config,omitempty"` + OsImageHash string `json:"os_image_hash,omitempty"` + ComposeHash string `json:"compose_hash"` + VmConfig string `json:"vm_config,omitempty"` + CloudVendor string `json:"cloud_vendor,omitempty"` + CloudProduct string `json:"cloud_product,omitempty"` } // DecodeTcbInfo decodes the TcbInfo string into a TcbInfo struct @@ -269,6 +342,7 @@ func (c *DstackClient) sendRPCRequest(ctx context.Context, path string, payload } req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "dstack-sdk-go/0.1.0") resp, err := c.httpClient.Do(req) if err != nil { return nil, err @@ -297,6 +371,9 @@ type tlsKeyOptions struct { usageRaTls bool usageServerAuth bool usageClientAuth bool + notBefore *uint64 + notAfter *uint64 + withAppInfo *bool } // WithSubject sets the subject for the TLS key @@ -334,6 +411,27 @@ func WithUsageClientAuth(usage bool) TlsKeyOption { } } +// WithNotBefore sets the not_before timestamp for the certificate +func WithNotBefore(t uint64) TlsKeyOption { + return func(opts *tlsKeyOptions) { + opts.notBefore = &t + } +} + +// WithNotAfter sets the not_after timestamp for the certificate +func WithNotAfter(t uint64) TlsKeyOption { + return func(opts *tlsKeyOptions) { + opts.notAfter = &t + } +} + +// WithAppInfo sets the with_app_info flag for the certificate +func WithAppInfo(enabled bool) TlsKeyOption { + return func(opts *tlsKeyOptions) { + opts.withAppInfo = &enabled + } +} + // Gets a TLS key from the dstack service with optional parameters. func (c *DstackClient) GetTlsKey( ctx context.Context, @@ -356,6 +454,15 @@ func (c *DstackClient) GetTlsKey( if len(opts.altNames) > 0 { payload["alt_names"] = opts.altNames } + if opts.notBefore != nil { + payload["not_before"] = *opts.notBefore + } + if opts.notAfter != nil { + payload["not_after"] = *opts.notAfter + } + if opts.withAppInfo != nil { + payload["with_app_info"] = *opts.withAppInfo + } data, err := c.sendRPCRequest(ctx, "/GetTlsKey", payload) if err != nil { @@ -429,30 +536,12 @@ func (c *DstackClient) GetQuote(ctx context.Context, reportData []byte) (*GetQuo return nil, err } - var response struct { - Quote string `json:"quote"` - EventLog string `json:"event_log"` - ReportData string `json:"report_data"` - } + var response GetQuoteResponse if err := json.Unmarshal(data, &response); err != nil { return nil, err } - quote, err := hex.DecodeString(response.Quote) - if err != nil { - return nil, err - } - - reportDataBytes, err := hex.DecodeString(response.ReportData) - if err != nil { - return nil, err - } - - return &GetQuoteResponse{ - Quote: quote, - EventLog: response.EventLog, - ReportData: reportDataBytes, - }, nil + return &response, nil } // Gets a versioned attestation from the dstack service. @@ -600,14 +689,142 @@ func (c *DstackClient) Verify(ctx context.Context, algorithm string, data []byte return &response, nil } +// IsReachable checks if the service is reachable +func (c *DstackClient) IsReachable(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + _, err := c.Info(ctx) + return err == nil +} + // EmitEvent sends an event to be extended to RTMR3 on TDX platform. // The event will be extended to RTMR3 with the provided name and payload. // // Requires dstack OS 0.5.0 or later. func (c *DstackClient) EmitEvent(ctx context.Context, event string, payload []byte) error { + if event == "" { + return fmt.Errorf("event name cannot be empty") + } _, err := c.sendRPCRequest(ctx, "/EmitEvent", map[string]interface{}{ "event": event, "payload": hex.EncodeToString(payload), }) return err } + +// Legacy methods for backward compatibility with warnings + +// DeriveKey is deprecated. Use GetKey instead. +// Deprecated: Use GetKey instead. +func (c *DstackClient) 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 *DstackClient) TdxQuote(ctx context.Context, reportData []byte, hashAlgorithm string) (*GetQuoteResponse, error) { + c.logger.Warn("tdxQuote is deprecated, please use GetQuote instead") + if hashAlgorithm != "raw" { + return nil, fmt.Errorf("tdxQuote only supports raw hash algorithm") + } + return c.GetQuote(ctx, reportData) +} + +// TappdClient is a deprecated wrapper around DstackClient for backward compatibility. +// Deprecated: Use DstackClient instead. +type TappdClient struct { + *DstackClient +} + +// NewTappdClient creates a new deprecated TappdClient. +// Deprecated: Use NewDstackClient instead. +func NewTappdClient(opts ...DstackClientOption) *TappdClient { + // Create a modified option to use TAPPD_SIMULATOR_ENDPOINT + tappdOpts := make([]DstackClientOption, 0, len(opts)+1) + + // Add default endpoint option that checks TAPPD_SIMULATOR_ENDPOINT + tappdOpts = append(tappdOpts, func(c *DstackClient) { + if c.endpoint == "" { + if simEndpoint, exists := os.LookupEnv("TAPPD_SIMULATOR_ENDPOINT"); exists { + c.logger.Warn("Using tappd endpoint", "endpoint", simEndpoint) + c.endpoint = simEndpoint + } else { + c.endpoint = "/var/run/tappd.sock" + } + } + }) + + // Add user-provided options + tappdOpts = append(tappdOpts, opts...) + + client := NewDstackClient(tappdOpts...) + client.logger.Warn("TappdClient is deprecated, please use DstackClient instead") + + return &TappdClient{ + DstackClient: client, + } +} + +// 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") + + if subject == "" { + subject = path + } + + payload := map[string]interface{}{ + "path": path, + "subject": subject, + } + if len(altNames) > 0 { + payload["alt_names"] = altNames + } + + data, err := tc.sendRPCRequest(ctx, "/prpc/Tappd.DeriveKey", payload) + if err != nil { + return nil, err + } + + var response GetTlsKeyResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + return &response, nil +} + +// 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") + + if hashAlgorithm == "raw" { + if len(reportData) > 64 { + return nil, fmt.Errorf("report data is too large, it should be at most 64 bytes when hashAlgorithm is raw") + } + if len(reportData) < 64 { + // Left-pad with zeros + padding := make([]byte, 64-len(reportData)) + reportData = append(padding, reportData...) + } + } + + payload := map[string]interface{}{ + "report_data": hex.EncodeToString(reportData), + "hash_algorithm": hashAlgorithm, + } + + data, err := tc.sendRPCRequest(ctx, "/prpc/Tappd.TdxQuote", payload) + if err != nil { + return nil, err + } + + var response GetQuoteResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + return &response, nil +} diff --git a/sdk/go/dstack/client_test.go b/sdk/go/dstack/client_test.go index 3fd8370c..e6072a73 100644 --- a/sdk/go/dstack/client_test.go +++ b/sdk/go/dstack/client_test.go @@ -8,20 +8,15 @@ import ( "bytes" "context" "crypto/sha256" + "crypto/x509" "encoding/hex" "encoding/json" + "encoding/pem" "fmt" - "math/big" "strings" "testing" - "crypto/x509" - "encoding/pem" - - "crypto/ecdsa" - "github.com/Dstack-TEE/dstack/sdk/go/dstack" - "github.com/ethereum/go-ethereum/crypto" ) func TestGetKey(t *testing.T) { @@ -62,11 +57,16 @@ func TestGetQuote(t *testing.T) { } // Get quote RTMRs manually + quoteBytes, err := resp.DecodeQuote() + if err != nil { + t.Fatal(err) + } + quoteRtmrs := [4][48]byte{ - [48]byte(resp.Quote[376:424]), - [48]byte(resp.Quote[424:472]), - [48]byte(resp.Quote[472:520]), - [48]byte(resp.Quote[520:568]), + [48]byte(quoteBytes[376:424]), + [48]byte(quoteBytes[424:472]), + [48]byte(quoteBytes[472:520]), + [48]byte(quoteBytes[520:568]), } // Test ReplayRTMRs @@ -448,188 +448,6 @@ func TestInfo(t *testing.T) { } } -func TestGetKeySignatureVerification(t *testing.T) { - expectedAppPubkey, _ := hex.DecodeString("02818494263695e8839122dbd88e281d7380622999df4e60a14befa0f2d096fc7c") - expectedKmsPubkey, _ := hex.DecodeString("0321529e458424ab1f710a3a57ec4dad2fb195ddca572f7469242ba6c7563085b6") - - client := dstack.NewDstackClient() - path := "/test/path" - purpose := "test-purpose" - resp, err := client.GetKey(context.Background(), path, purpose, "secp256k1") - if err != nil { - t.Fatal(err) - } - - if resp.Key == "" { - t.Error("expected key to not be empty") - } - - if len(resp.SignatureChain) != 2 { - t.Fatalf("expected signature chain to have 2 elements, got %d", len(resp.SignatureChain)) - } - - // Extract the app signature and KMS signature from the chain - appSignatureHex := resp.SignatureChain[0] - kmsSignatureHex := resp.SignatureChain[1] - - // Convert hex strings to bytes - appSignature, err := hex.DecodeString(appSignatureHex) - if err != nil { - t.Fatalf("failed to decode app signature: %v", err) - } - - kmsSignature, err := hex.DecodeString(kmsSignatureHex) - if err != nil { - t.Fatalf("failed to decode KMS signature: %v", err) - } - - // Verify signatures have the correct format (signature + recovery ID) - if len(appSignature) != 65 { - t.Errorf("expected app signature to be 65 bytes (64 bytes signature + 1 byte recovery ID), got %d", len(appSignature)) - } - - if len(kmsSignature) != 65 { - t.Errorf("expected KMS signature to be 65 bytes (64 bytes signature + 1 byte recovery ID), got %d", len(kmsSignature)) - } - - // Get app info to retrieve app ID for verification - infoResp, err := client.Info(context.Background()) - if err != nil { - t.Fatal(err) - } - - // 1. Derive the public key from the private key - derivedPrivKey := resp.Key - derivedPubKey, err := derivePublicKeyFromPrivate(derivedPrivKey) - if err != nil { - t.Fatalf("failed to derive public key: %v", err) - } - - // 2. Construct the message that was signed - message := fmt.Sprintf("%s:%s", purpose, hex.EncodeToString(derivedPubKey)) - - // 3. Recover the app's public key from the signature - appPubKey, err := recoverPublicKey(message, appSignature) - if err != nil { - t.Fatalf("failed to recover app public key: %v", err) - } - - // Convert the recovered public key to compressed format for comparison - appPubKeyCompressed, err := compressPublicKey(appPubKey) - if err != nil { - t.Fatalf("failed to compress recovered public key: %v", err) - } - - if !bytes.Equal(appPubKeyCompressed, expectedAppPubkey) { - t.Errorf("app public key mismatch:\nExpected: %s\nActual: %s", - hex.EncodeToString(expectedAppPubkey), - hex.EncodeToString(appPubKeyCompressed)) - } - - // 4. Verify the app ID matches what we expect - // The app ID should be derivable from the app's public key - // or should match what's returned from the Info endpoint - appIDFromInfo, err := hex.DecodeString(infoResp.AppID) - if err != nil { - t.Fatalf("failed to decode app ID: %v", err) - } - - // 5. Construct the message that KMS would have signed - // This would typically be something like "dstack-kms-issued:{app_id}{app_public_key}" - kmsMessage := fmt.Sprintf("dstack-kms-issued:%s%s", appIDFromInfo, string(appPubKeyCompressed)) - kmsPubKey, err := recoverPublicKey(kmsMessage, kmsSignature) - if err != nil { - t.Fatalf("failed to recover KMS public key: %v", err) - } - - kmsPubKeyCompressed, err := compressPublicKey(kmsPubKey) - if err != nil { - t.Fatalf("failed to compress KMS public key: %v", err) - } - - if !bytes.Equal(kmsPubKeyCompressed, expectedKmsPubkey) { - t.Errorf("KMS public key mismatch:\nExpected: %s\nActual: %s", - hex.EncodeToString(expectedKmsPubkey), - hex.EncodeToString(kmsPubKeyCompressed)) - } - - // Verify that the recovered app public key can verify the app signature - verified, err := verifySignature(message, appSignature, appPubKey) - if err != nil { - t.Fatalf("signature verification error: %v", err) - } - if !verified { - t.Error("app signature verification failed") - } -} - -// Helper function to derive a public key from a private key -func derivePublicKeyFromPrivate(privateKeyHex string) ([]byte, error) { - privateKeyBytes, err := hex.DecodeString(privateKeyHex) - if err != nil { - return nil, fmt.Errorf("failed to decode private key: %w", err) - } - - // Import the private key - privateKey, err := crypto.ToECDSA(privateKeyBytes) - if err != nil { - return nil, fmt.Errorf("failed to convert to ECDSA private key: %w", err) - } - - // Derive the public key in compressed format - publicKey := crypto.CompressPubkey(&privateKey.PublicKey) - return publicKey, nil -} - -// Helper function to recover a public key from a signature -func recoverPublicKey(message string, signature []byte) ([]byte, error) { - if len(signature) != 65 { - return nil, fmt.Errorf("invalid signature length: expected 65 bytes, got %d", len(signature)) - } - - // Hash the message using Keccak256 - messageHash := crypto.Keccak256([]byte(message)) - - // Recover the public key - pubKey, err := crypto.Ecrecover(messageHash, signature) - if err != nil { - return nil, fmt.Errorf("failed to recover public key: %w", err) - } - - return pubKey, nil -} - -// Helper function to verify a signature -func verifySignature(message string, signature []byte, publicKey []byte) (bool, error) { - if len(signature) != 65 { - return false, fmt.Errorf("invalid signature length: expected 65 bytes, got %d", len(signature)) - } - - // Hash the message using Keccak256 - messageHash := crypto.Keccak256([]byte(message)) - - // The last byte is the recovery ID, we need to remove it for verification - signatureWithoutRecoveryID := signature[:64] - - // Verify the signature - return crypto.VerifySignature(publicKey, messageHash, signatureWithoutRecoveryID), nil -} - -// Add this helper function to compress a public key -func compressPublicKey(uncompressedKey []byte) ([]byte, error) { - if len(uncompressedKey) < 65 || uncompressedKey[0] != 4 { - return nil, fmt.Errorf("invalid uncompressed public key") - } - x := new(big.Int).SetBytes(uncompressedKey[1:33]) - y := new(big.Int).SetBytes(uncompressedKey[33:65]) - pubKey := &ecdsa.PublicKey{ - Curve: crypto.S256(), - X: x, - Y: y, - } - return crypto.CompressPubkey(pubKey), nil -} - func TestSignAndVerifyEd25519(t *testing.T) { client := dstack.NewDstackClient() dataToSign := []byte("test message for ed25519") diff --git a/sdk/go/dstack/client_web3_test.go b/sdk/go/dstack/client_web3_test.go new file mode 100644 index 00000000..537e6180 --- /dev/null +++ b/sdk/go/dstack/client_web3_test.go @@ -0,0 +1,141 @@ +//go:build ethereum +// +build ethereum + +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack_test + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/hex" + "fmt" + "math/big" + "testing" + + "github.com/Dstack-TEE/dstack/sdk/go/dstack" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestGetKeySignatureVerification(t *testing.T) { + expectedAppPubkey, _ := hex.DecodeString("02818494263695e8839122dbd88e281d7380622999df4e60a14befa0f2d096fc7c") + expectedKmsPubkey, _ := hex.DecodeString("0321529e458424ab1f710a3a57ec4dad2fb195ddca572f7469242ba6c7563085b6") + + client := dstack.NewDstackClient() + path := "/test/path" + purpose := "test-purpose" + resp, err := client.GetKey(context.Background(), path, purpose, "secp256k1") + if err != nil { + t.Fatal(err) + } + + if resp.Key == "" { + t.Error("expected key to not be empty") + } + + if len(resp.SignatureChain) != 2 { + t.Fatalf("expected signature chain to have 2 elements, got %d", len(resp.SignatureChain)) + } + + appSignature, err := hex.DecodeString(resp.SignatureChain[0]) + if err != nil { + t.Fatalf("failed to decode app signature: %v", err) + } + kmsSignature, err := hex.DecodeString(resp.SignatureChain[1]) + if err != nil { + t.Fatalf("failed to decode KMS signature: %v", err) + } + + infoResp, err := client.Info(context.Background()) + if err != nil { + t.Fatal(err) + } + + derivedPubKey, err := derivePublicKeyFromPrivate(resp.Key) + if err != nil { + t.Fatalf("failed to derive public key: %v", err) + } + + message := fmt.Sprintf("%s:%s", purpose, hex.EncodeToString(derivedPubKey)) + appPubKey, err := recoverPublicKey(message, appSignature) + if err != nil { + t.Fatalf("failed to recover app public key: %v", err) + } + appPubKeyCompressed, err := compressPublicKey(appPubKey) + if err != nil { + t.Fatalf("failed to compress recovered public key: %v", err) + } + if !bytes.Equal(appPubKeyCompressed, expectedAppPubkey) { + t.Errorf("app public key mismatch:\nExpected: %s\nActual: %s", hex.EncodeToString(expectedAppPubkey), hex.EncodeToString(appPubKeyCompressed)) + } + + appIDFromInfo, err := hex.DecodeString(infoResp.AppID) + if err != nil { + t.Fatalf("failed to decode app ID: %v", err) + } + kmsMessage := fmt.Sprintf("dstack-kms-issued:%s%s", appIDFromInfo, string(appPubKeyCompressed)) + kmsPubKey, err := recoverPublicKey(kmsMessage, kmsSignature) + if err != nil { + t.Fatalf("failed to recover KMS public key: %v", err) + } + kmsPubKeyCompressed, err := compressPublicKey(kmsPubKey) + if err != nil { + t.Fatalf("failed to compress KMS public key: %v", err) + } + if !bytes.Equal(kmsPubKeyCompressed, expectedKmsPubkey) { + t.Errorf("KMS public key mismatch:\nExpected: %s\nActual: %s", hex.EncodeToString(expectedKmsPubkey), hex.EncodeToString(kmsPubKeyCompressed)) + } + + verified, err := verifySignature(message, appSignature, appPubKey) + if err != nil { + t.Fatalf("signature verification error: %v", err) + } + if !verified { + t.Error("app signature verification failed") + } +} + +func derivePublicKeyFromPrivate(privateKeyHex string) ([]byte, error) { + privateKeyBytes, err := hex.DecodeString(privateKeyHex) + if err != nil { + return nil, fmt.Errorf("failed to decode private key: %w", err) + } + privateKey, err := crypto.ToECDSA(privateKeyBytes) + if err != nil { + return nil, fmt.Errorf("failed to convert to ECDSA private key: %w", err) + } + return crypto.CompressPubkey(&privateKey.PublicKey), nil +} + +func recoverPublicKey(message string, signature []byte) ([]byte, error) { + if len(signature) != 65 { + return nil, fmt.Errorf("invalid signature length: expected 65 bytes, got %d", len(signature)) + } + messageHash := crypto.Keccak256([]byte(message)) + pubKey, err := crypto.Ecrecover(messageHash, signature) + if err != nil { + return nil, fmt.Errorf("failed to recover public key: %w", err) + } + return pubKey, nil +} + +func verifySignature(message string, signature []byte, publicKey []byte) (bool, error) { + if len(signature) != 65 { + return false, fmt.Errorf("invalid signature length: expected 65 bytes, got %d", len(signature)) + } + messageHash := crypto.Keccak256([]byte(message)) + return crypto.VerifySignature(publicKey, messageHash, signature[:64]), nil +} + +func compressPublicKey(uncompressedKey []byte) ([]byte, error) { + if len(uncompressedKey) < 65 || uncompressedKey[0] != 4 { + return nil, fmt.Errorf("invalid uncompressed public key") + } + x := new(big.Int).SetBytes(uncompressedKey[1:33]) + y := new(big.Int).SetBytes(uncompressedKey[33:65]) + pubKey := &ecdsa.PublicKey{Curve: crypto.S256(), X: x, Y: y} + return crypto.CompressPubkey(pubKey), nil +} diff --git a/sdk/go/dstack/compose_hash.go b/sdk/go/dstack/compose_hash.go new file mode 100644 index 00000000..4b683cda --- /dev/null +++ b/sdk/go/dstack/compose_hash.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" +) + +// KeyProviderKind represents the key provider type +type KeyProviderKind string + +const ( + KeyProviderNone KeyProviderKind = "none" + KeyProviderKMS KeyProviderKind = "kms" + KeyProviderLocal KeyProviderKind = "local" +) + +// DockerConfig represents Docker configuration +type DockerConfig struct { + Registry string `json:"registry,omitempty"` + Username string `json:"username,omitempty"` + TokenKey string `json:"token_key,omitempty"` +} + +// AppCompose represents the application composition structure +type AppCompose struct { + ManifestVersion *int `json:"manifest_version,omitempty"` + Name string `json:"name,omitempty"` + Features []string `json:"features,omitempty"` // Deprecated + Runner string `json:"runner"` + DockerComposeFile string `json:"docker_compose_file,omitempty"` + DockerConfig *DockerConfig `json:"docker_config,omitempty"` + PublicLogs *bool `json:"public_logs,omitempty"` + PublicSysinfo *bool `json:"public_sysinfo,omitempty"` + PublicTcbinfo *bool `json:"public_tcbinfo,omitempty"` + KmsEnabled *bool `json:"kms_enabled,omitempty"` + GatewayEnabled *bool `json:"gateway_enabled,omitempty"` + TproxyEnabled *bool `json:"tproxy_enabled,omitempty"` // For backward compatibility + LocalKeyProviderEnabled *bool `json:"local_key_provider_enabled,omitempty"` + KeyProvider KeyProviderKind `json:"key_provider,omitempty"` + KeyProviderID string `json:"key_provider_id,omitempty"` // hex string + AllowedEnvs []string `json:"allowed_envs,omitempty"` + NoInstanceID *bool `json:"no_instance_id,omitempty"` + SecureTime *bool `json:"secure_time,omitempty"` + BashScript string `json:"bash_script,omitempty"` // Legacy + PreLaunchScript string `json:"pre_launch_script,omitempty"` // Legacy +} + +// preprocessAppCompose removes conflicting fields based on runner type +func preprocessAppCompose(appCompose AppCompose) AppCompose { + if appCompose.Runner == "bash" { + appCompose.DockerComposeFile = "" + } else if appCompose.Runner == "docker-compose" { + appCompose.BashScript = "" + } + + if appCompose.PreLaunchScript == "" { + // Remove empty pre_launch_script field for deterministic output + } + + return appCompose +} + +// sortKeys recursively sorts all object keys for deterministic JSON output +func sortKeys(v interface{}) interface{} { + switch value := v.(type) { + case map[string]interface{}: + result := make(map[string]interface{}) + keys := make([]string, 0, len(value)) + for k := range value { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + result[k] = sortKeys(value[k]) + } + return result + case []interface{}: + result := make([]interface{}, len(value)) + for i, item := range value { + result[i] = sortKeys(item) + } + return result + default: + return value + } +} + +// toDeterministicJSON converts the structure to deterministic JSON +func toDeterministicJSON(v interface{}) (string, error) { + sorted := sortKeys(v) + jsonBytes, err := json.Marshal(sorted) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// GetComposeHash computes the SHA256 hash of the application composition +func GetComposeHash(appCompose AppCompose, normalize ...bool) (string, error) { + shouldNormalize := len(normalize) > 0 && normalize[0] + + if shouldNormalize { + appCompose = preprocessAppCompose(appCompose) + } + + // Convert to generic map for sorting + jsonBytes, err := json.Marshal(appCompose) + if err != nil { + return "", err + } + + var genericMap interface{} + if err := json.Unmarshal(jsonBytes, &genericMap); err != nil { + return "", err + } + + manifestStr, err := toDeterministicJSON(genericMap) + if err != nil { + return "", err + } + + hash := sha256.Sum256([]byte(manifestStr)) + return hex.EncodeToString(hash[:]), nil +} \ No newline at end of file diff --git a/sdk/go/dstack/encrypt_env_vars.go b/sdk/go/dstack/encrypt_env_vars.go new file mode 100644 index 00000000..2e089acd --- /dev/null +++ b/sdk/go/dstack/encrypt_env_vars.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "golang.org/x/crypto/curve25519" +) + +// EnvVar represents an environment variable key-value pair. +type EnvVar struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// EncryptEnvVars encrypts environment variables using X25519 ECDH + AES-256-GCM. +// +// publicKeyHex is the remote X25519 public key (hex-encoded, with or without 0x prefix). +// Returns hex(ephemeral_pubkey || iv || ciphertext). +func EncryptEnvVars(envs []EnvVar, publicKeyHex string) (string, error) { + cleanHex := strings.TrimPrefix(publicKeyHex, "0x") + remotePubKey, err := hex.DecodeString(cleanHex) + if err != nil { + return "", fmt.Errorf("failed to decode public key: %w", err) + } + if len(remotePubKey) != 32 { + return "", fmt.Errorf("invalid public key length: expected 32 bytes, got %d", len(remotePubKey)) + } + + envJSON, err := json.Marshal(struct { + Env []EnvVar `json:"env"` + }{Env: envs}) + if err != nil { + return "", fmt.Errorf("failed to marshal env vars: %w", err) + } + + ephemeralPrivKey := make([]byte, 32) + if _, err := rand.Read(ephemeralPrivKey); err != nil { + return "", fmt.Errorf("failed to generate ephemeral private key: %w", err) + } + + ephemeralPubKey, err := curve25519.X25519(ephemeralPrivKey, curve25519.Basepoint) + if err != nil { + return "", fmt.Errorf("failed to derive ephemeral public key: %w", err) + } + + sharedSecret, err := curve25519.X25519(ephemeralPrivKey, remotePubKey) + if err != nil { + return "", fmt.Errorf("failed to derive shared secret: %w", err) + } + + block, err := aes.NewCipher(sharedSecret) + if err != nil { + return "", fmt.Errorf("failed to create aes cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create aes-gcm: %w", err) + } + + iv := make([]byte, 12) + if _, err := rand.Read(iv); err != nil { + return "", fmt.Errorf("failed to generate iv: %w", err) + } + + ciphertext := gcm.Seal(nil, iv, envJSON, nil) + result := make([]byte, 0, len(ephemeralPubKey)+len(iv)+len(ciphertext)) + result = append(result, ephemeralPubKey...) + result = append(result, iv...) + result = append(result, ciphertext...) + + return hex.EncodeToString(result), nil +} diff --git a/sdk/go/dstack/encrypt_env_vars_test.go b/sdk/go/dstack/encrypt_env_vars_test.go new file mode 100644 index 00000000..d8016d0e --- /dev/null +++ b/sdk/go/dstack/encrypt_env_vars_test.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack_test + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "encoding/json" + "testing" + + "github.com/Dstack-TEE/dstack/sdk/go/dstack" + "golang.org/x/crypto/curve25519" +) + +func TestEncryptEnvVars(t *testing.T) { + remotePriv := make([]byte, 32) + if _, err := rand.Read(remotePriv); err != nil { + t.Fatal(err) + } + remotePub, err := curve25519.X25519(remotePriv, curve25519.Basepoint) + if err != nil { + t.Fatal(err) + } + + envs := []dstack.EnvVar{ + {Key: "NODE_ENV", Value: "production"}, + {Key: "MESSAGE", Value: "Hello 世界"}, + } + + encryptedHex, err := dstack.EncryptEnvVars(envs, hex.EncodeToString(remotePub)) + if err != nil { + t.Fatal(err) + } + + encrypted, err := hex.DecodeString(encryptedHex) + if err != nil { + t.Fatal(err) + } + if len(encrypted) <= 44 { + t.Fatalf("expected encrypted payload > 44 bytes, got %d", len(encrypted)) + } + + ephemeralPub := encrypted[:32] + iv := encrypted[32:44] + ciphertext := encrypted[44:] + + sharedSecret, err := curve25519.X25519(remotePriv, ephemeralPub) + if err != nil { + t.Fatal(err) + } + + block, err := aes.NewCipher(sharedSecret) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + + plaintext, err := gcm.Open(nil, iv, ciphertext, nil) + if err != nil { + t.Fatal(err) + } + + var payload struct { + Env []dstack.EnvVar `json:"env"` + } + if err := json.Unmarshal(plaintext, &payload); err != nil { + t.Fatal(err) + } + + if len(payload.Env) != len(envs) { + t.Fatalf("expected %d env vars, got %d", len(envs), len(payload.Env)) + } + for i := range envs { + if payload.Env[i] != envs[i] { + t.Fatalf("env var mismatch at %d: expected %+v, got %+v", i, envs[i], payload.Env[i]) + } + } +} + +func TestEncryptEnvVarsInvalidKey(t *testing.T) { + _, err := dstack.EncryptEnvVars([]dstack.EnvVar{{Key: "A", Value: "B"}}, "abcd") + if err == nil { + t.Fatal("expected error for invalid public key length") + } +} diff --git a/sdk/go/dstack/ethereum.go b/sdk/go/dstack/ethereum.go new file mode 100644 index 00000000..518b2bad --- /dev/null +++ b/sdk/go/dstack/ethereum.go @@ -0,0 +1,132 @@ +//go:build ethereum +// +build ethereum + +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack + +import ( + "crypto/ecdsa" + "crypto/sha256" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// EthereumAccount represents an Ethereum account with address and private key +type EthereumAccount struct { + Address common.Address + PrivateKey *ecdsa.PrivateKey +} + +// 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) { + case *GetTlsKeyResponse: + // Legacy behavior for GetTlsKeyResponse with warning + fmt.Println("Warning: toEthereumAccount: Please don't use GetTlsKey method to get key, use GetKey instead.") + + keyBytes, err := resp.AsUint8Array(32) + if err != nil { + return nil, fmt.Errorf("failed to extract key bytes: %w", err) + } + + privateKey, err := crypto.ToECDSA(keyBytes) + if err != nil { + return nil, fmt.Errorf("failed to create ECDSA private key: %w", err) + } + + address := crypto.PubkeyToAddress(privateKey.PublicKey) + + return &EthereumAccount{ + Address: address, + PrivateKey: privateKey, + }, nil + + case *GetKeyResponse: + keyBytes, err := resp.DecodeKey() + if err != nil { + return nil, fmt.Errorf("failed to decode key: %w", err) + } + + privateKey, err := crypto.ToECDSA(keyBytes) + if err != nil { + return nil, fmt.Errorf("failed to create ECDSA private key: %w", err) + } + + address := crypto.PubkeyToAddress(privateKey.PublicKey) + + return &EthereumAccount{ + Address: address, + PrivateKey: privateKey, + }, nil + + default: + return nil, fmt.Errorf("unsupported key response type") + } +} + +// ToEthereumAccountSecure creates an Ethereum account from GetKeyResponse or GetTlsKeyResponse using secure key derivation. +// This method applies SHA256 hashing to the complete key material for enhanced security. +func ToEthereumAccountSecure(keyResponse interface{}) (*EthereumAccount, error) { + switch resp := keyResponse.(type) { + case *GetTlsKeyResponse: + // Legacy behavior for GetTlsKeyResponse with warning + fmt.Println("Warning: toEthereumAccountSecure: Please don't use GetTlsKey method to get key, use GetKey instead.") + + keyBytes, err := resp.AsUint8Array() + if err != nil { + return nil, fmt.Errorf("failed to extract key bytes: %w", err) + } + + // Apply SHA256 hashing for security + hash := sha256.Sum256(keyBytes) + + privateKey, err := crypto.ToECDSA(hash[:]) + if err != nil { + return nil, fmt.Errorf("failed to create ECDSA private key: %w", err) + } + + address := crypto.PubkeyToAddress(privateKey.PublicKey) + + return &EthereumAccount{ + Address: address, + PrivateKey: privateKey, + }, nil + + case *GetKeyResponse: + keyBytes, err := resp.DecodeKey() + if err != nil { + return nil, fmt.Errorf("failed to decode key: %w", err) + } + + privateKey, err := crypto.ToECDSA(keyBytes) + if err != nil { + return nil, fmt.Errorf("failed to create ECDSA private key: %w", err) + } + + address := crypto.PubkeyToAddress(privateKey.PublicKey) + + return &EthereumAccount{ + Address: address, + PrivateKey: privateKey, + }, nil + + default: + return nil, fmt.Errorf("unsupported key response type") + } +} + +// Sign signs a message hash using the account's private key +func (a *EthereumAccount) Sign(messageHash []byte) ([]byte, error) { + return crypto.Sign(messageHash, a.PrivateKey) +} + +// PublicKey returns the public key +func (a *EthereumAccount) PublicKey() *ecdsa.PublicKey { + return &a.PrivateKey.PublicKey +} diff --git a/sdk/go/dstack/solana.go b/sdk/go/dstack/solana.go new file mode 100644 index 00000000..6ce95d19 --- /dev/null +++ b/sdk/go/dstack/solana.go @@ -0,0 +1,139 @@ +//go:build solana +// +build solana + +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack + +import ( + "crypto/ed25519" + "crypto/sha256" + "fmt" +) + +// SolanaKeypair represents a Solana keypair with public and private keys +type SolanaKeypair struct { + PublicKey ed25519.PublicKey + PrivateKey ed25519.PrivateKey +} + +// 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) { + case *GetTlsKeyResponse: + // Legacy behavior for GetTlsKeyResponse with warning + fmt.Println("Warning: toSolanaKeypair: Please don't use GetTlsKey method to get key, use GetKey instead.") + + // Use first 32 bytes directly for legacy compatibility + keyBytes, err := resp.AsUint8Array(32) + if err != nil { + return nil, fmt.Errorf("failed to extract key bytes: %w", err) + } + + // Generate Ed25519 keypair from seed + privateKey := ed25519.NewKeyFromSeed(keyBytes) + publicKey := privateKey.Public().(ed25519.PublicKey) + + return &SolanaKeypair{ + PublicKey: publicKey, + PrivateKey: privateKey, + }, nil + + case *GetKeyResponse: + keyBytes, err := resp.DecodeKey() + if err != nil { + return nil, fmt.Errorf("failed to decode key: %w", err) + } + + if len(keyBytes) < 32 { + return nil, fmt.Errorf("key too short, need at least 32 bytes") + } + + // Use first 32 bytes as seed + seed := keyBytes[:32] + privateKey := ed25519.NewKeyFromSeed(seed) + publicKey := privateKey.Public().(ed25519.PublicKey) + + return &SolanaKeypair{ + PublicKey: publicKey, + PrivateKey: privateKey, + }, nil + + default: + return nil, fmt.Errorf("unsupported key response type") + } +} + +// ToSolanaKeypairSecure creates a Solana keypair from GetKeyResponse or GetTlsKeyResponse using secure key derivation. +// This method applies SHA256 hashing to the complete key material for enhanced security. +func ToSolanaKeypairSecure(keyResponse interface{}) (*SolanaKeypair, error) { + switch resp := keyResponse.(type) { + case *GetTlsKeyResponse: + // Legacy behavior for GetTlsKeyResponse with warning + fmt.Println("Warning: toSolanaKeypairSecure: Please don't use GetTlsKey method to get key, use GetKey instead.") + + keyBytes, err := resp.AsUint8Array() + if err != nil { + return nil, fmt.Errorf("failed to extract key bytes: %w", err) + } + + // Apply SHA256 hashing for security + hash := sha256.Sum256(keyBytes) + + privateKey := ed25519.NewKeyFromSeed(hash[:]) + publicKey := privateKey.Public().(ed25519.PublicKey) + + return &SolanaKeypair{ + PublicKey: publicKey, + PrivateKey: privateKey, + }, nil + + case *GetKeyResponse: + keyBytes, err := resp.DecodeKey() + if err != nil { + return nil, fmt.Errorf("failed to decode key: %w", err) + } + + if len(keyBytes) < 32 { + return nil, fmt.Errorf("key too short, need at least 32 bytes") + } + + // Use first 32 bytes as seed for legacy compatibility + seed := keyBytes[:32] + privateKey := ed25519.NewKeyFromSeed(seed) + publicKey := privateKey.Public().(ed25519.PublicKey) + + return &SolanaKeypair{ + PublicKey: publicKey, + PrivateKey: privateKey, + }, nil + + default: + return nil, fmt.Errorf("unsupported key response type") + } +} + +// Sign signs a message using the keypair's private key +func (k *SolanaKeypair) Sign(message []byte) []byte { + return ed25519.Sign(k.PrivateKey, message) +} + +// Verify verifies a signature against a message using the keypair's public key +func (k *SolanaKeypair) Verify(message, signature []byte) bool { + return ed25519.Verify(k.PublicKey, message, signature) +} + +// PublicKeyString returns the public key as a hex string (simplified) +func (k *SolanaKeypair) PublicKeyString() string { + // This would require a base58 encoder, for now return hex + // In a real implementation, you'd use github.com/mr-tron/base58 + return fmt.Sprintf("%x", k.PublicKey) +} + +// Bytes returns the full 64-byte private key (32-byte seed + 32-byte public key) +func (k *SolanaKeypair) Bytes() []byte { + return k.PrivateKey +} diff --git a/sdk/go/dstack/verify_signature.go b/sdk/go/dstack/verify_signature.go new file mode 100644 index 00000000..97255cc4 --- /dev/null +++ b/sdk/go/dstack/verify_signature.go @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "strings" + "time" + + secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + secp256k1ecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "golang.org/x/crypto/sha3" +) + +const ( + defaultVerifyMaxAgeSeconds uint64 = 300 + defaultVerifyFutureSkewSeconds uint64 = 60 +) + +// VerifyEnvEncryptPublicKeyOptions configures timestamp validation for signature verification. +type VerifyEnvEncryptPublicKeyOptions struct { + MaxAgeSeconds uint64 + FutureSkewSeconds uint64 +} + +func normalizeVerifyOptions(opts *VerifyEnvEncryptPublicKeyOptions) (maxAgeSeconds uint64, futureSkewSeconds uint64) { + maxAgeSeconds = defaultVerifyMaxAgeSeconds + futureSkewSeconds = defaultVerifyFutureSkewSeconds + if opts == nil { + return + } + if opts.MaxAgeSeconds > 0 { + maxAgeSeconds = opts.MaxAgeSeconds + } + if opts.FutureSkewSeconds > 0 { + futureSkewSeconds = opts.FutureSkewSeconds + } + return +} + +func buildVerifyMessage(publicKey []byte, appID string) ([]byte, error) { + prefix := []byte("dstack-env-encrypt-pubkey") + + cleanAppID := appID + if strings.HasPrefix(appID, "0x") { + cleanAppID = appID[2:] + } + + appIDBytes, err := hex.DecodeString(cleanAppID) + if err != nil { + return nil, err + } + + separator := []byte(":") + return bytes.Join([][]byte{prefix, separator, appIDBytes, publicKey}, nil), nil +} + +func keccak256(data []byte) []byte { + hasher := sha3.NewLegacyKeccak256() + hasher.Write(data) + return hasher.Sum(nil) +} + +func toCompactSignature(signature []byte) ([]byte, error) { + if len(signature) != 65 { + return nil, nil + } + + recovery := signature[64] + if recovery >= 27 { + recovery -= 27 + } + if recovery > 3 { + return nil, nil + } + + compact := make([]byte, 65) + compact[0] = 27 + recovery + 4 // compressed key + copy(compact[1:33], signature[:32]) + copy(compact[33:65], signature[32:64]) + return compact, nil +} + +func recoverCompressedPublicKey(message []byte, signature []byte) ([]byte, error) { + if len(signature) != 65 { + return nil, nil + } + + compactSig, err := toCompactSignature(signature) + if err != nil || compactSig == nil { + return nil, err + } + + messageHash := keccak256(message) + pubKey, _, err := secp256k1ecdsa.RecoverCompact(compactSig, messageHash) + if err != nil { + return nil, nil + } + + compressed := pubKey.SerializeCompressed() + result := make([]byte, 2+hex.EncodedLen(len(compressed))) + result[0] = '0' + result[1] = 'x' + hex.Encode(result[2:], compressed) + return result, nil +} + +// VerifyEnvEncryptPublicKey verifies the signature of a public key (legacy format without timestamp). +func VerifyEnvEncryptPublicKey(publicKey []byte, signature []byte, appID string) ([]byte, error) { + message, err := buildVerifyMessage(publicKey, appID) + if err != nil { + return nil, nil + } + return recoverCompressedPublicKey(message, signature) +} + +// VerifyEnvEncryptPublicKeyWithTimestamp verifies a public-key signature with timestamp freshness checks. +// +// Message format: +// +// prefix + ":" + app_id + timestamp_be_u64 + public_key +func VerifyEnvEncryptPublicKeyWithTimestamp( + publicKey []byte, + signature []byte, + appID string, + timestamp uint64, + opts *VerifyEnvEncryptPublicKeyOptions, +) ([]byte, error) { + if len(signature) != 65 { + return nil, nil + } + + maxAgeSeconds, futureSkewSeconds := normalizeVerifyOptions(opts) + now := uint64(time.Now().Unix()) + if timestamp > now { + if timestamp-now > futureSkewSeconds { + return nil, fmt.Errorf("timestamp is too far in the future") + } + } else if now-timestamp > maxAgeSeconds { + return nil, fmt.Errorf("timestamp is too old: %ds > %ds", now-timestamp, maxAgeSeconds) + } + + baseMessage, err := buildVerifyMessage(publicKey, appID) + if err != nil { + return nil, nil + } + + timestampBytes := make([]byte, 8) + binary.BigEndian.PutUint64(timestampBytes, timestamp) + message := append(append([]byte{}, baseMessage[:len(baseMessage)-len(publicKey)]...), timestampBytes...) + message = append(message, publicKey...) + + return recoverCompressedPublicKey(message, signature) +} + +// VerifySignatureSimple is a simplified version for basic signature verification. +func VerifySignatureSimple(message []byte, signature []byte, expectedPubKey []byte) bool { + if len(signature) != 65 { + return false + } + + pubKey, err := secp256k1.ParsePubKey(expectedPubKey) + if err != nil { + return false + } + + r := new(secp256k1.ModNScalar) + s := new(secp256k1.ModNScalar) + if r.SetByteSlice(signature[:32]) { + return false + } + if s.SetByteSlice(signature[32:64]) { + return false + } + + sig := secp256k1ecdsa.NewSignature(r, s) + return sig.Verify(keccak256(message), pubKey) +} diff --git a/sdk/go/dstack/verify_signature_test.go b/sdk/go/dstack/verify_signature_test.go new file mode 100644 index 00000000..5b0dc0eb --- /dev/null +++ b/sdk/go/dstack/verify_signature_test.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +package dstack_test + +import ( + "testing" + "time" + + "github.com/Dstack-TEE/dstack/sdk/go/dstack" +) + +func TestVerifyEnvEncryptPublicKeyWithTimestampTooOld(t *testing.T) { + pub := make([]byte, 32) + sig := make([]byte, 65) + now := uint64(time.Now().Unix()) + + _, err := dstack.VerifyEnvEncryptPublicKeyWithTimestamp( + pub, + sig, + "0000000000000000000000000000000000000000", + now-301, + nil, + ) + if err == nil { + t.Fatal("expected stale timestamp error") + } +} + +func TestVerifyEnvEncryptPublicKeyWithTimestampTooFuture(t *testing.T) { + pub := make([]byte, 32) + sig := make([]byte, 65) + now := uint64(time.Now().Unix()) + + _, err := dstack.VerifyEnvEncryptPublicKeyWithTimestamp( + pub, + sig, + "0000000000000000000000000000000000000000", + now+61, + nil, + ) + if err == nil { + t.Fatal("expected future timestamp error") + } +} + +func TestVerifyEnvEncryptPublicKeyWithTimestampCustomMaxAge(t *testing.T) { + pub := make([]byte, 32) + sig := make([]byte, 65) + now := uint64(time.Now().Unix()) + + _, err := dstack.VerifyEnvEncryptPublicKeyWithTimestamp( + pub, + sig, + "0000000000000000000000000000000000000000", + now-400, + &dstack.VerifyEnvEncryptPublicKeyOptions{MaxAgeSeconds: 500}, + ) + if err != nil { + t.Fatalf("expected no timestamp error with custom max age, got: %v", err) + } +} diff --git a/sdk/go/go.mod b/sdk/go/go.mod index 4bc0097d..bd630fea 100644 --- a/sdk/go/go.mod +++ b/sdk/go/go.mod @@ -7,12 +7,12 @@ module github.com/Dstack-TEE/dstack/sdk/go go 1.24.0 -require github.com/ethereum/go-ethereum v1.16.8 +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 + golang.org/x/crypto v0.45.0 +) require ( github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect - github.com/holiman/uint256 v1.3.2 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/sys v0.38.0 // indirect ) diff --git a/sdk/go/tappd/client.go b/sdk/go/tappd/client.go index 8a30a8bb..84cd47f5 100644 --- a/sdk/go/tappd/client.go +++ b/sdk/go/tappd/client.go @@ -184,8 +184,8 @@ func WithLogger(logger *slog.Logger) TappdClientOption { // Creates a new TappdClient instance based on the provided endpoint. // If the endpoint is empty, it will use the simulator endpoint if it is -// set in the environment through DSTACK_SIMULATOR_ENDPOINT. Otherwise, it -// will try /var/run/dstack/tappd.sock first, falling back to /var/run/tappd.sock +// set in the environment through TAPPD_SIMULATOR_ENDPOINT. Otherwise, it +// will try /var/run/tappd.sock first, falling back to other known paths // for backward compatibility. func NewTappdClient(opts ...TappdClientOption) *TappdClient { client := &TappdClient{ @@ -219,15 +219,15 @@ func NewTappdClient(opts ...TappdClientOption) *TappdClient { // Returns the appropriate endpoint based on environment and input. If the // endpoint is empty, it will use the simulator endpoint if it is set in the -// environment through DSTACK_SIMULATOR_ENDPOINT. Otherwise, it will try -// /var/run/dstack/tappd.sock first, falling back to /var/run/tappd.sock +// environment through TAPPD_SIMULATOR_ENDPOINT. Otherwise, it will try +// /var/run/tappd.sock first, falling back to other known paths // for backward compatibility. func (c *TappdClient) getEndpoint() string { if c.endpoint != "" { return c.endpoint } - if simEndpoint, exists := os.LookupEnv("DSTACK_SIMULATOR_ENDPOINT"); exists { - c.logger.Info("using simulator endpoint", "endpoint", simEndpoint) + if simEndpoint, exists := os.LookupEnv("TAPPD_SIMULATOR_ENDPOINT"); exists { + c.logger.Info("using tappd simulator endpoint", "endpoint", simEndpoint) return simEndpoint } // Try paths in order: legacy paths first, then namespaced paths