From f8d2bac580568fc3991b76f96bf60f9b1e876540 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 22 Jan 2026 14:21:28 +0000 Subject: [PATCH 1/3] Add timestamp to GetAppEnvEncryptPubKey - Add timestamp and signature_v1 fields to PublicKeyResponse proto - signature_v1 includes timestamp in the signed message - Keep legacy signature field for backward compatibility - Update JS SDK with verifyEnvEncryptPublicKey (with timestamp) and verifyEnvEncryptPublicKeyLegacy functions --- kms/README.md | 10 ++ kms/rpc/proto/kms_rpc.proto | 7 + kms/src/crypto.rs | 18 ++ kms/src/main_service.rs | 20 ++- sdk/js/README.md | 15 +- .../__tests__/browser-compatibility.test.ts | 46 +++-- .../verify-env-encrypt-public-key.test.ts | 121 +++++++++++-- sdk/js/src/index.ts | 3 +- .../verify-env-encrypt-public-key.browser.ts | 167 +++++++++++++++--- sdk/js/src/verify-env-encrypt-public-key.ts | 129 ++++++++++++-- vmm/src/vmm-cli.py | 70 +++++++- 11 files changed, 526 insertions(+), 80 deletions(-) diff --git a/kms/README.md b/kms/README.md index 8e7936fc..08d05b1c 100644 --- a/kms/README.md +++ b/kms/README.md @@ -179,6 +179,16 @@ The keys are derived with app id which guarantees apps can not get the keys from The `GetAppEnvEncryptPubKey` RPC is used by the frontend web page to request the app environment encryption public key when deploying a new app. This key is used to encrypt the app environment variables, which can only be decrypted by the app in TEE. +The response includes: +- `public_key`: The X25519 public key for encrypting environment variables +- `timestamp`: Unix timestamp (seconds) when the response was generated +- `signature`: Legacy signature for backward compatibility + - Signs: `Keccak256("dstack-env-encrypt-pubkey" + ":" + app_id + public_key)` +- `signature_v1`: New signature with timestamp to prevent replay attacks + - Signs: `Keccak256("dstack-env-encrypt-pubkey" + ":" + app_id + timestamp_be_bytes + public_key)` + +Clients should prefer verifying `signature_v1` with timestamp to protect against replay attacks. Fall back to `signature` only for backward compatibility with older KMS versions. + ### SignCert The `SignCert` RPC is used by the dstack app to sign a TLS certificate. In this RPC, the KMS node will: diff --git a/kms/rpc/proto/kms_rpc.proto b/kms/rpc/proto/kms_rpc.proto index 00815121..b927c283 100644 --- a/kms/rpc/proto/kms_rpc.proto +++ b/kms/rpc/proto/kms_rpc.proto @@ -19,7 +19,14 @@ message AppId { message PublicKeyResponse { bytes public_key = 1; + // Legacy signature without timestamp (for backward compatibility). + // Signs: Keccak256("dstack-env-encrypt-pubkey" + ":" + app_id + public_key) bytes signature = 2; + // Unix timestamp in seconds when the response was generated. + uint64 timestamp = 3; + // New signature with timestamp to prevent replay attacks. + // Signs: Keccak256("dstack-env-encrypt-pubkey" + ":" + app_id + timestamp_be_bytes + public_key) + bytes signature_v1 = 4; } message AppKeyResponse { diff --git a/kms/src/crypto.rs b/kms/src/crypto.rs index 6400d700..488977dd 100644 --- a/kms/src/crypto.rs +++ b/kms/src/crypto.rs @@ -42,3 +42,21 @@ pub(crate) fn sign_message( signature_bytes.push(recid.to_byte()); Ok(signature_bytes) } + +/// Sign a message with a timestamp to prevent replay attacks. +/// The signature covers: prefix + ":" + appid + timestamp_be_bytes + message +pub(crate) fn sign_message_with_timestamp( + key: &SigningKey, + prefix: &[u8], + appid: &[u8], + timestamp: u64, + message: &[u8], +) -> Result> { + let timestamp_bytes = timestamp.to_be_bytes(); + let digest = + Keccak256::new_with_prefix([prefix, b":", appid, ×tamp_bytes[..], message].concat()); + let (signature, recid) = key.sign_digest_recoverable(digest)?; + let mut signature_bytes = signature.to_vec(); + signature_bytes.push(recid.to_byte()); + Ok(signature_bytes) +} diff --git a/kms/src/main_service.rs b/kms/src/main_service.rs index 3c4fcc62..941f05f6 100644 --- a/kms/src/main_service.rs +++ b/kms/src/main_service.rs @@ -27,7 +27,7 @@ use upgrade_authority::BootInfo; use crate::{ config::KmsConfig, - crypto::{derive_k256_key, sign_message}, + crypto::{derive_k256_key, sign_message, sign_message_with_timestamp}, }; mod upgrade_authority; @@ -288,6 +288,12 @@ impl KmsRpc for RpcHandler { let pubkey = x25519_dalek::PublicKey::from(&secret); let public_key = pubkey.to_bytes().to_vec(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("System time before UNIX epoch")? + .as_secs(); + + // Legacy signature (without timestamp) for backward compatibility let signature = sign_message( &self.state.k256_key, b"dstack-env-encrypt-pubkey", @@ -296,9 +302,21 @@ impl KmsRpc for RpcHandler { ) .context("Failed to sign the public key")?; + // New signature with timestamp to prevent replay attacks + let signature_v1 = sign_message_with_timestamp( + &self.state.k256_key, + b"dstack-env-encrypt-pubkey", + &request.app_id, + timestamp, + &public_key, + ) + .context("Failed to sign the public key with timestamp")?; + Ok(PublicKeyResponse { public_key, signature, + timestamp, + signature_v1, }) } diff --git a/sdk/js/README.md b/sdk/js/README.md index 70e7e82a..aebe7e3b 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -219,13 +219,20 @@ These utilities are for deployment scripts, not runtime SDK operations. Encrypt secrets before deploying to dstack: ```typescript -import { encryptEnvVars, verifyEnvEncryptPublicKey, type EnvVar } from '@phala/dstack-sdk'; +import { encryptEnvVars, verifyEnvEncryptPublicKey, verifyEnvEncryptPublicKeyLegacy, type EnvVar } from '@phala/dstack-sdk'; // Get and verify the KMS public key -// (obtain public_key and signature from KMS API) -const kmsIdentity = verifyEnvEncryptPublicKey(publicKeyBytes, signatureBytes, appId); +// (obtain public_key, signature_v1, and timestamp from KMS API) + +// Prefer signature_v1 with timestamp (prevents replay attacks) +const kmsIdentity = verifyEnvEncryptPublicKey(publicKeyBytes, signatureV1Bytes, appId, timestamp); if (!kmsIdentity) { - throw new Error('Invalid KMS key'); + // Fall back to legacy signature for backward compatibility with older KMS + const legacyIdentity = verifyEnvEncryptPublicKeyLegacy(publicKeyBytes, signatureBytes, appId); + if (!legacyIdentity) { + throw new Error('Invalid KMS key'); + } + console.warn('Using legacy signature without timestamp protection'); } // Encrypt variables diff --git a/sdk/js/src/__tests__/browser-compatibility.test.ts b/sdk/js/src/__tests__/browser-compatibility.test.ts index 8c1f247c..ceadc663 100644 --- a/sdk/js/src/__tests__/browser-compatibility.test.ts +++ b/sdk/js/src/__tests__/browser-compatibility.test.ts @@ -168,13 +168,14 @@ describe('Browser Compatibility Tests', () => { const testPublicKey = new Uint8Array(32).fill(1) // 32 bytes const testSignature = new Uint8Array(65).fill(2) // 65 bytes const testAppId = 'test-app-id' + const testTimestamp = BigInt(Math.floor(Date.now() / 1000)) it('should accept the same input parameters', async () => { - const nodeResult = await nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( - testPublicKey, testSignature, testAppId + const nodeResult = nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( + testPublicKey, testSignature, testAppId, testTimestamp ) const browserResult = await browserVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( - testPublicKey, testSignature, testAppId + testPublicKey, testSignature, testAppId, testTimestamp ) // Both should return string or null @@ -187,18 +188,18 @@ describe('Browser Compatibility Tests', () => { const invalidSignature = new Uint8Array(32) // Wrong size // Both should handle invalid inputs similarly - const nodeResult1 = await nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( - invalidPublicKey, testSignature, testAppId + const nodeResult1 = nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( + invalidPublicKey, testSignature, testAppId, testTimestamp ) const browserResult1 = await browserVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( - invalidPublicKey, testSignature, testAppId + invalidPublicKey, testSignature, testAppId, testTimestamp ) - const nodeResult2 = await nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( - testPublicKey, invalidSignature, testAppId + const nodeResult2 = nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( + testPublicKey, invalidSignature, testAppId, testTimestamp ) const browserResult2 = await browserVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( - testPublicKey, invalidSignature, testAppId + testPublicKey, invalidSignature, testAppId, testTimestamp ) // Both should return null for invalid inputs (or handle errors consistently) @@ -209,16 +210,32 @@ describe('Browser Compatibility Tests', () => { }) it('should handle empty/invalid app ID consistently', async () => { - const nodeResult = await nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( - testPublicKey, testSignature, '' + const nodeResult = nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( + testPublicKey, testSignature, '', testTimestamp ) const browserResult = await browserVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey( - testPublicKey, testSignature, '' + testPublicKey, testSignature, '', testTimestamp ) expect(nodeResult).toBeNull() expect(browserResult).toBeNull() }) + + it('should have matching legacy function exports', async () => { + // Test legacy functions exist and have the same interface + expect(typeof nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKeyLegacy).toBe('function') + expect(typeof browserVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKeyLegacy).toBe('function') + + const nodeResult = nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKeyLegacy( + testPublicKey, testSignature, testAppId + ) + const browserResult = await browserVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKeyLegacy( + testPublicKey, testSignature, testAppId + ) + + expect(nodeResult === null || typeof nodeResult === 'string').toBeTruthy() + expect(browserResult === null || typeof browserResult === 'string').toBeTruthy() + }) }) describe('Function Signatures', () => { @@ -226,11 +243,12 @@ describe('Browser Compatibility Tests', () => { // These checks ensure TypeScript compatibility const nodeEncryptFn: typeof nodeEncryptEnvVars.encryptEnvVars = browserEncryptEnvVars.encryptEnvVars const nodeHashFn: typeof nodeGetComposeHash.getComposeHash = browserGetComposeHash.getComposeHash - const nodeVerifyFn: typeof nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey = browserVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey + // Note: verify functions have slightly different signatures (sync vs async) but same parameters + expect(typeof browserVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey).toBe('function') + expect(typeof nodeVerifyEnvEncryptPublicKey.verifyEnvEncryptPublicKey).toBe('function') expect(typeof nodeEncryptFn).toBe('function') expect(typeof nodeHashFn).toBe('function') - expect(typeof nodeVerifyFn).toBe('function') }) }) }) \ No newline at end of file diff --git a/sdk/js/src/__tests__/verify-env-encrypt-public-key.test.ts b/sdk/js/src/__tests__/verify-env-encrypt-public-key.test.ts index bf7c5d84..908b9e15 100644 --- a/sdk/js/src/__tests__/verify-env-encrypt-public-key.test.ts +++ b/sdk/js/src/__tests__/verify-env-encrypt-public-key.test.ts @@ -2,17 +2,17 @@ // // SPDX-License-Identifier: Apache-2.0 -import { verifyEnvEncryptPublicKey } from '../verify-env-encrypt-public-key' -import { describe, it, expect } from 'vitest' +import { verifyEnvEncryptPublicKey, verifyEnvEncryptPublicKeyLegacy } from '../verify-env-encrypt-public-key' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -describe('verifySignature', () => { +describe('verifyEnvEncryptPublicKeyLegacy', () => { it('should verify signature correctly with example data', () => { const publicKey = new Uint8Array(Buffer.from('e33a1832c6562067ff8f844a61e51ad051f1180b66ec2551fb0251735f3ee90a', 'hex')) const signature = new Uint8Array(Buffer.from('8542c49081fbf4e03f62034f13fbf70630bdf256a53032e38465a27c36fd6bed7a5e7111652004aef37f7fd92fbfc1285212c4ae6a6154203a48f5e16cad2cef00', 'hex')) const appId = '00'.repeat(20) - - const result = verifyEnvEncryptPublicKey(publicKey, signature, appId) - + + const result = verifyEnvEncryptPublicKeyLegacy(publicKey, signature, appId) + expect(result).toBe('0x0217610d74cbd39b6143842c6d8bc310d79da1d82cc9d17f8876376221eda0c38f') }) @@ -20,9 +20,9 @@ describe('verifySignature', () => { const publicKey = new Uint8Array(Buffer.from('e33a1832c6562067ff8f844a61e51ad051f1180b66ec2551fb0251735f3ee90a', 'hex')) const signature = new Uint8Array(Buffer.from('8542c49081fbf4e03f62034f13fbf70630bdf256a53032e38465a27c36fd6bed7a5e7111652004aef37f7fd92fbfc1285212c4ae6a6154203a48f5e16cad2cef00', 'hex')) const appId = '0x' + '00'.repeat(20) - - const result = verifyEnvEncryptPublicKey(publicKey, signature, appId) - + + const result = verifyEnvEncryptPublicKeyLegacy(publicKey, signature, appId) + expect(result).toBe('0x0217610d74cbd39b6143842c6d8bc310d79da1d82cc9d17f8876376221eda0c38f') }) @@ -30,9 +30,9 @@ describe('verifySignature', () => { const publicKey = new Uint8Array(32) const signature = new Uint8Array(64) // Wrong length const appId = '00'.repeat(20) - - const result = verifyEnvEncryptPublicKey(publicKey, signature, appId) - + + const result = verifyEnvEncryptPublicKeyLegacy(publicKey, signature, appId) + expect(result).toBeNull() }) @@ -40,9 +40,98 @@ describe('verifySignature', () => { const publicKey = new Uint8Array(32) const signature = new Uint8Array(65) // All zeros const appId = '00'.repeat(20) - - const result = verifyEnvEncryptPublicKey(publicKey, signature, appId) - + + const result = verifyEnvEncryptPublicKeyLegacy(publicKey, signature, appId) + + expect(result).toBeNull() + }) +}) + +describe('verifyEnvEncryptPublicKey with timestamp', () => { + beforeEach(() => { + // Mock Date.now to return a fixed timestamp for testing + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-01-15T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should return null for invalid signature length', () => { + const publicKey = new Uint8Array(32) + const signature = new Uint8Array(64) // Wrong length + const appId = '00'.repeat(20) + const timestamp = BigInt(Math.floor(Date.now() / 1000)) + + const result = verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp) + + expect(result).toBeNull() + }) + + it('should return null for stale timestamp', () => { + const publicKey = new Uint8Array(32) + const signature = new Uint8Array(65) + const appId = '00'.repeat(20) + // Timestamp from 10 minutes ago (600 seconds) + const timestamp = BigInt(Math.floor(Date.now() / 1000)) - 600n + + const result = verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp) + + expect(result).toBeNull() + }) + + it('should return null for timestamp too far in the future', () => { + const publicKey = new Uint8Array(32) + const signature = new Uint8Array(65) + const appId = '00'.repeat(20) + // Timestamp 2 minutes in the future + const timestamp = BigInt(Math.floor(Date.now() / 1000)) + 120n + + const result = verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp) + + expect(result).toBeNull() + }) + + it('should accept timestamp within allowed clock skew (future)', () => { + const publicKey = new Uint8Array(32) + const signature = new Uint8Array(65) // Invalid signature, but we're testing timestamp check + const appId = '00'.repeat(20) + // Timestamp 30 seconds in the future (within 60s skew) + const timestamp = BigInt(Math.floor(Date.now() / 1000)) + 30n + + // Will return null due to invalid signature, not timestamp + const result = verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp) + + // The function should not reject due to timestamp, but will fail signature verification + expect(result).toBeNull() + }) + + it('should accept custom maxAgeSeconds option', () => { + const publicKey = new Uint8Array(32) + const signature = new Uint8Array(65) + const appId = '00'.repeat(20) + // Timestamp from 400 seconds ago (would fail default 300s, but pass 600s) + const timestamp = BigInt(Math.floor(Date.now() / 1000)) - 400n + + // With default maxAge (300s), this should fail due to stale timestamp + const result1 = verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp) + expect(result1).toBeNull() + + // With extended maxAge (600s), it would pass timestamp check but fail signature + const result2 = verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp, { maxAgeSeconds: 600 }) + // Still null due to invalid signature data, but the timestamp check passed + expect(result2).toBeNull() + }) + + it('should accept number timestamp', () => { + const publicKey = new Uint8Array(32) + const signature = new Uint8Array(65) + const appId = '00'.repeat(20) + const timestamp = Math.floor(Date.now() / 1000) // number instead of bigint + + // Will return null due to invalid signature, but should handle number timestamp + const result = verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp) expect(result).toBeNull() }) -}) \ No newline at end of file +}) diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index ddb28245..c6075f32 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -6,7 +6,8 @@ import fs from 'fs' import crypto from 'crypto' import { send_rpc_request } from './send-rpc-request' export { getComposeHash } from './get-compose-hash' -export { verifyEnvEncryptPublicKey } from './verify-env-encrypt-public-key' +export { verifyEnvEncryptPublicKey, verifyEnvEncryptPublicKeyLegacy } from './verify-env-encrypt-public-key' +export type { VerifyOptions } from './verify-env-encrypt-public-key' export interface GetTlsKeyResponse { __name__: Readonly<'GetTlsKeyResponse'> diff --git a/sdk/js/src/verify-env-encrypt-public-key.browser.ts b/sdk/js/src/verify-env-encrypt-public-key.browser.ts index bd616aa0..523b52b4 100644 --- a/sdk/js/src/verify-env-encrypt-public-key.browser.ts +++ b/sdk/js/src/verify-env-encrypt-public-key.browser.ts @@ -5,73 +5,196 @@ import { keccak_256 } from "@noble/hashes/sha3"; import { secp256k1 } from "@noble/curves/secp256k1"; +/** Default maximum age for timestamp verification (5 minutes) */ +const DEFAULT_MAX_AGE_SECONDS = 300; + +/** + * Options for verifying env encrypt public key + */ +export interface VerifyOptions { + /** + * Maximum age of the response in seconds. + * If the timestamp is older than this, verification fails. + * Default: 300 (5 minutes) + */ + maxAgeSeconds?: number; +} + +/** + * Convert a bigint to big-endian bytes + */ +function bigintToBeBytes(value: bigint, length: number): Uint8Array { + const bytes = new Uint8Array(length); + for (let i = length - 1; i >= 0; i--) { + bytes[i] = Number(value & 0xffn); + value >>= 8n; + } + return bytes; +} + /** - * Verify the signature of a public key. - * + * Convert hex string to Uint8Array + */ +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); + } + return bytes; +} + +/** + * Verify the signature of a public key with timestamp validation. + * * @param publicKey - The public key bytes to verify (32 bytes) * @param signature - The signature bytes (65 bytes) * @param appId - The application ID + * @param timestamp - Unix timestamp in seconds when the response was generated + * @param options - Optional verification options * @returns The compressed public key if valid, null otherwise - * + * * @example * ```typescript - * const publicKey = new Uint8Array(Buffer.from('e33a1832c6562067ff8f844a61e51ad051f1180b66ec2551fb0251735f3ee90a', 'hex')); - * const signature = new Uint8Array(Buffer.from('8542c49081fbf4e03f62034f13fbf70630bdf256a53032e38465a27c36fd6bed7a5e7111652004aef37f7fd92fbfc1285212c4ae6a6154203a48f5e16cad2cef00', 'hex')); + * const publicKey = new Uint8Array([...]); + * const signature = new Uint8Array([...]); * const appId = '00'.repeat(20); - * const compressedPubkey = verifySignature(publicKey, signature, appId); - * console.log(compressedPubkey); // 0x0217610d74cbd39b6143842c6d8bc310d79da1d82cc9d17f8876376221eda0c38f + * const timestamp = 1700000000n; + * const compressedPubkey = await verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp); * ``` */ export async function verifyEnvEncryptPublicKey( - publicKey: Uint8Array, - signature: Uint8Array, - appId: string + publicKey: Uint8Array, + signature: Uint8Array, + appId: string, + timestamp: bigint | number, + options?: VerifyOptions ): Promise { if (signature.length !== 65) { return null; } + // Convert timestamp to bigint for consistent handling + const ts = typeof timestamp === 'bigint' ? timestamp : BigInt(timestamp); + + // Validate timestamp freshness + const maxAge = options?.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; + const now = BigInt(Math.floor(Date.now() / 1000)); + const age = now - ts; + + if (age < 0n) { + // Timestamp is in the future - allow small clock skew (60 seconds) + if (age < -60n) { + console.error('timestamp is too far in the future'); + return null; + } + } else if (age > BigInt(maxAge)) { + console.error(`timestamp is too old: ${age}s > ${maxAge}s`); + return null; + } + // Create the message to verify const prefix = new TextEncoder().encode("dstack-env-encrypt-pubkey"); - + // Remove 0x prefix if present let cleanAppId = appId; if (appId.startsWith("0x")) { cleanAppId = appId.slice(2); } - - const appIdBytes = new Uint8Array(cleanAppId.length / 2); - for (let i = 0; i < cleanAppId.length; i += 2) { - appIdBytes[i / 2] = parseInt(cleanAppId.substr(i, 2), 16); + + const appIdBytes = hexToBytes(cleanAppId); + const separator = new TextEncoder().encode(":"); + + // Convert timestamp to big-endian bytes (8 bytes) + const timestampBytes = bigintToBeBytes(ts, 8); + + // Construct message: prefix + ":" + app_id + timestamp_be_bytes + public_key + const message = new Uint8Array(prefix.length + separator.length + appIdBytes.length + timestampBytes.length + publicKey.length); + let offset = 0; + message.set(prefix, offset); offset += prefix.length; + message.set(separator, offset); offset += separator.length; + message.set(appIdBytes, offset); offset += appIdBytes.length; + message.set(timestampBytes, offset); offset += timestampBytes.length; + message.set(publicKey, offset); + + // Hash the message with Keccak-256 + const messageHash = keccak_256(message); + + try { + // Extract r, s, v from signature (last byte is recovery id) + const r = signature.slice(0, 32); + const s = signature.slice(32, 64); + const recovery = signature[64]; + + // Create signature in DER format for secp256k1 + const sigBytes = new Uint8Array(64); + sigBytes.set(r, 0); + sigBytes.set(s, 32); + + // Recover the public key from the signature + const recoveredPubKey = secp256k1.Signature.fromCompact(sigBytes) + .addRecoveryBit(recovery) + .recoverPublicKey(messageHash); + + // Return compressed public key with 0x prefix + const compressedBytes = recoveredPubKey.toRawBytes(true); + return '0x' + Array.from(compressedBytes, b => b.toString(16).padStart(2, '0')).join(''); + } catch (error) { + console.error('signature verification failed:', error); + return null; + } +} + +/** + * @deprecated Use verifyEnvEncryptPublicKey with timestamp parameter instead. + * This function is kept for backward compatibility but does not protect against replay attacks. + */ +export async function verifyEnvEncryptPublicKeyLegacy( + publicKey: Uint8Array, + signature: Uint8Array, + appId: string +): Promise { + if (signature.length !== 65) { + return null; + } + + // Create the message to verify + const prefix = new TextEncoder().encode("dstack-env-encrypt-pubkey"); + + // Remove 0x prefix if present + let cleanAppId = appId; + if (appId.startsWith("0x")) { + cleanAppId = appId.slice(2); } + + const appIdBytes = hexToBytes(cleanAppId); const separator = new TextEncoder().encode(":"); - + // Construct message: prefix + ":" + app_id + public_key const message = new Uint8Array(prefix.length + separator.length + appIdBytes.length + publicKey.length); message.set(prefix, 0); message.set(separator, prefix.length); message.set(appIdBytes, prefix.length + separator.length); message.set(publicKey, prefix.length + separator.length + appIdBytes.length); - + // Hash the message with Keccak-256 const messageHash = keccak_256(message); - + try { // Extract r, s, v from signature (last byte is recovery id) const r = signature.slice(0, 32); const s = signature.slice(32, 64); const recovery = signature[64]; - + // Create signature in DER format for secp256k1 const sigBytes = new Uint8Array(64); sigBytes.set(r, 0); sigBytes.set(s, 32); - + // Recover the public key from the signature const recoveredPubKey = secp256k1.Signature.fromCompact(sigBytes) .addRecoveryBit(recovery) .recoverPublicKey(messageHash); - + // Return compressed public key with 0x prefix const compressedBytes = recoveredPubKey.toRawBytes(true); return '0x' + Array.from(compressedBytes, b => b.toString(16).padStart(2, '0')).join(''); @@ -79,4 +202,4 @@ export async function verifyEnvEncryptPublicKey( console.error('signature verification failed:', error); return null; } -} \ No newline at end of file +} diff --git a/sdk/js/src/verify-env-encrypt-public-key.ts b/sdk/js/src/verify-env-encrypt-public-key.ts index 821b2845..556fb625 100644 --- a/sdk/js/src/verify-env-encrypt-public-key.ts +++ b/sdk/js/src/verify-env-encrypt-public-key.ts @@ -5,26 +5,123 @@ import { keccak_256 } from "@noble/hashes/sha3"; import { secp256k1 } from "@noble/curves/secp256k1"; +/** Default maximum age for timestamp verification (5 minutes) */ +const DEFAULT_MAX_AGE_SECONDS = 300; + +/** + * Options for verifying env encrypt public key + */ +export interface VerifyOptions { + /** + * Maximum age of the response in seconds. + * If the timestamp is older than this, verification fails. + * Default: 300 (5 minutes) + */ + maxAgeSeconds?: number; +} + /** - * Verify the signature of a public key. - * + * Verify the signature of a public key with timestamp validation. + * * @param publicKey - The public key bytes to verify (32 bytes) * @param signature - The signature bytes (65 bytes) * @param appId - The application ID + * @param timestamp - Unix timestamp in seconds when the response was generated + * @param options - Optional verification options * @returns The compressed public key if valid, null otherwise - * + * * @example * ```typescript * const publicKey = new Uint8Array(Buffer.from('e33a1832c6562067ff8f844a61e51ad051f1180b66ec2551fb0251735f3ee90a', 'hex')); - * const signature = new Uint8Array(Buffer.from('8542c49081fbf4e03f62034f13fbf70630bdf256a53032e38465a27c36fd6bed7a5e7111652004aef37f7fd92fbfc1285212c4ae6a6154203a48f5e16cad2cef00', 'hex')); + * const signature = new Uint8Array(Buffer.from('...', 'hex')); * const appId = '00'.repeat(20); - * const compressedPubkey = verifySignature(publicKey, signature, appId); - * console.log(compressedPubkey); // 0x0217610d74cbd39b6143842c6d8bc310d79da1d82cc9d17f8876376221eda0c38f + * const timestamp = 1700000000n; + * const compressedPubkey = verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp); * ``` */ export function verifyEnvEncryptPublicKey( - publicKey: Uint8Array, - signature: Uint8Array, + publicKey: Uint8Array, + signature: Uint8Array, + appId: string, + timestamp: bigint | number, + options?: VerifyOptions +): string | null { + if (signature.length !== 65) { + return null; + } + + // Convert timestamp to bigint for consistent handling + const ts = typeof timestamp === 'bigint' ? timestamp : BigInt(timestamp); + + // Validate timestamp freshness + const maxAge = options?.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; + const now = BigInt(Math.floor(Date.now() / 1000)); + const age = now - ts; + + if (age < 0n) { + // Timestamp is in the future - allow small clock skew (60 seconds) + if (age < -60n) { + console.error('timestamp is too far in the future'); + return null; + } + } else if (age > BigInt(maxAge)) { + console.error(`timestamp is too old: ${age}s > ${maxAge}s`); + return null; + } + + // Create the message to verify + const prefix = Buffer.from("dstack-env-encrypt-pubkey", "utf8"); + + // Remove 0x prefix if present + let cleanAppId = appId; + if (appId.startsWith("0x")) { + cleanAppId = appId.slice(2); + } + + const appIdBytes = Buffer.from(cleanAppId, "hex"); + const separator = Buffer.from(":", "utf8"); + + // Convert timestamp to big-endian bytes (8 bytes) + const timestampBytes = Buffer.alloc(8); + timestampBytes.writeBigUInt64BE(ts); + + // Construct message: prefix + ":" + app_id + timestamp_be_bytes + public_key + const message = Buffer.concat([prefix, separator, appIdBytes, timestampBytes, Buffer.from(publicKey)]); + + // Hash the message with Keccak-256 + const messageHash = keccak_256(message); + + try { + // Extract r, s, v from signature (last byte is recovery id) + const r = signature.slice(0, 32); + const s = signature.slice(32, 64); + const recovery = signature[64]; + + // Create signature in DER format for secp256k1 + const sigBytes = new Uint8Array(64); + sigBytes.set(r, 0); + sigBytes.set(s, 32); + + // Recover the public key from the signature + const recoveredPubKey = secp256k1.Signature.fromCompact(sigBytes) + .addRecoveryBit(recovery) + .recoverPublicKey(messageHash); + + // Return compressed public key with 0x prefix + return '0x' + Buffer.from(recoveredPubKey.toRawBytes(true)).toString('hex'); + } catch (error) { + console.error('signature verification failed:', error); + return null; + } +} + +/** + * @deprecated Use verifyEnvEncryptPublicKey with timestamp parameter instead. + * This function is kept for backward compatibility but does not protect against replay attacks. + */ +export function verifyEnvEncryptPublicKeyLegacy( + publicKey: Uint8Array, + signature: Uint8Array, appId: string ): string | null { if (signature.length !== 65) { @@ -33,38 +130,38 @@ export function verifyEnvEncryptPublicKey( // Create the message to verify const prefix = Buffer.from("dstack-env-encrypt-pubkey", "utf8"); - + // Remove 0x prefix if present let cleanAppId = appId; if (appId.startsWith("0x")) { cleanAppId = appId.slice(2); } - + const appIdBytes = Buffer.from(cleanAppId, "hex"); const separator = Buffer.from(":", "utf8"); - + // Construct message: prefix + ":" + app_id + public_key const message = Buffer.concat([prefix, separator, appIdBytes, Buffer.from(publicKey)]); - + // Hash the message with Keccak-256 const messageHash = keccak_256(message); - + try { // Extract r, s, v from signature (last byte is recovery id) const r = signature.slice(0, 32); const s = signature.slice(32, 64); const recovery = signature[64]; - + // Create signature in DER format for secp256k1 const sigBytes = new Uint8Array(64); sigBytes.set(r, 0); sigBytes.set(s, 32); - + // Recover the public key from the signature const recoveredPubKey = secp256k1.Signature.fromCompact(sigBytes) .addRecoveryBit(recovery) .recoverPublicKey(messageHash); - + // Return compressed public key with 0x prefix return '0x' + Buffer.from(recoveredPubKey.toRawBytes(true)).toString('hex'); } catch (error) { diff --git a/vmm/src/vmm-cli.py b/vmm/src/vmm-cli.py index b0db62a1..a8373992 100755 --- a/vmm/src/vmm-cli.py +++ b/vmm/src/vmm-cli.py @@ -424,15 +424,31 @@ def get_app_env_encrypt_pub_key(self, app_id: str, kms_url: Optional[str] = None 'GetAppEnvEncryptPubKey', {'app_id': app_id}) # Verify the signature if available - if 'signature' not in response: + if 'signature' not in response and 'signature_v1' not in response: if not self.confirm_untrusted_signer("none"): raise Exception("Aborted due to invalid signature") return response['public_key'] public_key = bytes.fromhex(response['public_key']) - signature = bytes.fromhex(response['signature']) - signer_pubkey = verify_signature(public_key, signature, app_id) + # Prefer signature_v1 (with timestamp) if available + signer_pubkey = None + if 'signature_v1' in response and 'timestamp' in response: + signature_v1 = bytes.fromhex(response['signature_v1']) + timestamp = response['timestamp'] + signer_pubkey = verify_signature_v1(public_key, signature_v1, app_id, timestamp) + if signer_pubkey: + print(f"Verified signature_v1 (with timestamp) from: {signer_pubkey}") + + # Fall back to legacy signature if signature_v1 verification failed or not available + if not signer_pubkey and 'signature' in response: + print("WARNING: Using legacy signature without timestamp protection. " + "Consider upgrading your KMS to support signature_v1.", file=sys.stderr) + signature = bytes.fromhex(response['signature']) + signer_pubkey = verify_signature(public_key, signature, app_id) + if signer_pubkey: + print(f"Verified legacy signature from: {signer_pubkey}") + if signer_pubkey: whitelist = load_whitelist() if whitelist and signer_pubkey not in whitelist: @@ -440,8 +456,6 @@ def get_app_env_encrypt_pub_key(self, app_id: str, kms_url: Optional[str] = None f"WARNING: Signer {signer_pubkey} is not in the trusted whitelist!") if not self.confirm_untrusted_signer(signer_pubkey): raise Exception("Aborted due to untrusted signer") - else: - print(f"Verified signature from: {signer_pubkey}") else: print("WARNING: Could not verify signature!") if not self.confirm_untrusted_signer("unknown"): @@ -1007,9 +1021,52 @@ def parse_disk_size(s: str) -> int: return parse_size(s, "GB") +def verify_signature_v1(public_key: bytes, signature: bytes, app_id: str, timestamp: int) -> Optional[str]: + """ + Verify the v1 signature (with timestamp) of a public key. + + Args: + public_key: The public key bytes to verify + signature: The signature bytes (65 bytes) + app_id: The application ID + timestamp: Unix timestamp in seconds when the response was generated + + Returns: + The compressed public key if valid, None otherwise + """ + if not CRYPTO_AVAILABLE: + raise ImportError( + "Cryptography libraries not available. Please install them with:\n" + "pip install cryptography eth-keys eth-utils" + ) + + if len(signature) != 65: + return None + + # Create the message to verify + # Signs: Keccak256("dstack-env-encrypt-pubkey" + ":" + app_id + timestamp_be_bytes + public_key) + prefix = b"dstack-env-encrypt-pubkey" + if app_id.startswith("0x"): + app_id = app_id[2:] + timestamp_bytes = timestamp.to_bytes(8, byteorder='big') + message = prefix + b":" + bytes.fromhex(app_id) + timestamp_bytes + public_key + + # Hash the message with Keccak-256 + message_hash = keccak(message) + + # Recover the public key from the signature + try: + sig = keys.Signature(signature_bytes=signature) + recovered_key = sig.recover_public_key_from_msg_hash(message_hash) + return '0x' + recovered_key.to_compressed_bytes().hex() + except Exception as e: + print(f"Signature v1 verification failed: {e}", file=sys.stderr) + return None + + def verify_signature(public_key: bytes, signature: bytes, app_id: str) -> Optional[str]: """ - Verify the signature of a public key. + Verify the legacy signature (without timestamp) of a public key. Args: public_key: The public key bytes to verify @@ -1037,6 +1094,7 @@ def verify_signature(public_key: bytes, signature: bytes, app_id: str) -> Option return None # Create the message to verify + # Signs: Keccak256("dstack-env-encrypt-pubkey" + ":" + app_id + public_key) prefix = b"dstack-env-encrypt-pubkey" if app_id.startswith("0x"): app_id = app_id[2:] From 425ce2e5bc6ac29586ad72ef1086bbf48d6a2b08 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 22 Jan 2026 14:25:10 +0000 Subject: [PATCH 2/3] Add timestamp and signature_v1 to VMM proxy for GetAppEnvEncryptPubKey Update vmm_rpc.proto PublicKeyResponse to include the new fields and forward them from KMS response. --- vmm/rpc/proto/vmm_rpc.proto | 7 +++++++ vmm/src/main_service.rs | 2 ++ 2 files changed, 9 insertions(+) diff --git a/vmm/rpc/proto/vmm_rpc.proto b/vmm/rpc/proto/vmm_rpc.proto index 51c3c3dd..d6604ace 100644 --- a/vmm/rpc/proto/vmm_rpc.proto +++ b/vmm/rpc/proto/vmm_rpc.proto @@ -202,7 +202,14 @@ message AppId { // Response with the KMS-backed public key used for env encryption plus audit signature. message PublicKeyResponse { bytes public_key = 1; + // Legacy signature without timestamp (for backward compatibility). + // Signs: Keccak256("dstack-env-encrypt-pubkey" + ":" + app_id + public_key) bytes signature = 2; + // Unix timestamp in seconds when the response was generated. + uint64 timestamp = 3; + // New signature with timestamp to prevent replay attacks. + // Signs: Keccak256("dstack-env-encrypt-pubkey" + ":" + app_id + timestamp_be_bytes + public_key) + bytes signature_v1 = 4; } // Optional VM info payload returned by GetInfo. diff --git a/vmm/src/main_service.rs b/vmm/src/main_service.rs index 67acf604..5a42f57a 100644 --- a/vmm/src/main_service.rs +++ b/vmm/src/main_service.rs @@ -429,6 +429,8 @@ impl VmmRpc for RpcHandler { Ok(PublicKeyResponse { public_key: response.public_key, signature: response.signature, + timestamp: response.timestamp, + signature_v1: response.signature_v1, }) } From 1ca36931e7bca01ce4c6d7539ed4fb78a95b449c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 22 Jan 2026 14:27:13 +0000 Subject: [PATCH 3/3] Regenerate VMM UI protobuf bindings for new PublicKeyResponse fields Add timestamp and signature_v1 fields to the generated JS protobuf code in console_v1.html. --- vmm/src/console_v1.html | 68 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/vmm/src/console_v1.html b/vmm/src/console_v1.html index 965ccaf6..d4a83fe0 100644 --- a/vmm/src/console_v1.html +++ b/vmm/src/console_v1.html @@ -9740,6 +9740,8 @@

Derive VM

* @interface IPublicKeyResponse * @property {Uint8Array|null} [public_key] PublicKeyResponse public_key * @property {Uint8Array|null} [signature] PublicKeyResponse signature + * @property {number|Long|null} [timestamp] PublicKeyResponse timestamp + * @property {Uint8Array|null} [signature_v1] PublicKeyResponse signature_v1 */ /** * Constructs a new PublicKeyResponse. @@ -9769,6 +9771,20 @@

Derive VM

* @instance */ PublicKeyResponse.prototype.signature = $util.newBuffer([]); + /** + * PublicKeyResponse timestamp. + * @member {number|Long} timestamp + * @memberof vmm.PublicKeyResponse + * @instance + */ + PublicKeyResponse.prototype.timestamp = $util.Long ? $util.Long.fromBits(0, 0, true) : 0; + /** + * PublicKeyResponse signature_v1. + * @member {Uint8Array} signature_v1 + * @memberof vmm.PublicKeyResponse + * @instance + */ + PublicKeyResponse.prototype.signature_v1 = $util.newBuffer([]); /** * Creates a new PublicKeyResponse instance using the specified properties. * @function create @@ -9796,6 +9812,10 @@

Derive VM

writer.uint32(/* id 1, wireType 2 =*/ 10).bytes(message.public_key); if (message.signature != null && Object.hasOwnProperty.call(message, "signature")) writer.uint32(/* id 2, wireType 2 =*/ 18).bytes(message.signature); + if (message.timestamp != null && Object.hasOwnProperty.call(message, "timestamp")) + writer.uint32(/* id 3, wireType 0 =*/ 24).uint64(message.timestamp); + if (message.signature_v1 != null && Object.hasOwnProperty.call(message, "signature_v1")) + writer.uint32(/* id 4, wireType 2 =*/ 34).bytes(message.signature_v1); return writer; }; /** @@ -9838,6 +9858,14 @@

Derive VM

message.signature = reader.bytes(); break; } + case 3: { + message.timestamp = reader.uint64(); + break; + } + case 4: { + message.signature_v1 = reader.bytes(); + break; + } default: reader.skipType(tag & 7); break; @@ -9877,6 +9905,12 @@

Derive VM

if (message.signature != null && message.hasOwnProperty("signature")) if (!(message.signature && typeof message.signature.length === "number" || $util.isString(message.signature))) return "signature: buffer expected"; + if (message.timestamp != null && message.hasOwnProperty("timestamp")) + if (!$util.isInteger(message.timestamp) && !(message.timestamp && $util.isInteger(message.timestamp.low) && $util.isInteger(message.timestamp.high))) + return "timestamp: integer|Long expected"; + if (message.signature_v1 != null && message.hasOwnProperty("signature_v1")) + if (!(message.signature_v1 && typeof message.signature_v1.length === "number" || $util.isString(message.signature_v1))) + return "signature_v1: buffer expected"; return null; }; /** @@ -9901,6 +9935,20 @@

Derive VM

$util.base64.decode(object.signature, message.signature = $util.newBuffer($util.base64.length(object.signature)), 0); else if (object.signature.length >= 0) message.signature = object.signature; + if (object.timestamp != null) + if ($util.Long) + (message.timestamp = $util.Long.fromValue(object.timestamp)).unsigned = true; + else if (typeof object.timestamp === "string") + message.timestamp = parseInt(object.timestamp, 10); + else if (typeof object.timestamp === "number") + message.timestamp = object.timestamp; + else if (typeof object.timestamp === "object") + message.timestamp = new $util.LongBits(object.timestamp.low >>> 0, object.timestamp.high >>> 0).toNumber(true); + if (object.signature_v1 != null) + if (typeof object.signature_v1 === "string") + $util.base64.decode(object.signature_v1, message.signature_v1 = $util.newBuffer($util.base64.length(object.signature_v1)), 0); + else if (object.signature_v1.length >= 0) + message.signature_v1 = object.signature_v1; return message; }; /** @@ -9931,11 +9979,31 @@

Derive VM

if (options.bytes !== Array) object.signature = $util.newBuffer(object.signature); } + if ($util.Long) { + var long = new $util.Long(0, 0, true); + object.timestamp = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } + else + object.timestamp = options.longs === String ? "0" : 0; + if (options.bytes === String) + object.signature_v1 = ""; + else { + object.signature_v1 = []; + if (options.bytes !== Array) + object.signature_v1 = $util.newBuffer(object.signature_v1); + } } if (message.public_key != null && message.hasOwnProperty("public_key")) object.public_key = options.bytes === String ? $util.base64.encode(message.public_key, 0, message.public_key.length) : options.bytes === Array ? Array.prototype.slice.call(message.public_key) : message.public_key; if (message.signature != null && message.hasOwnProperty("signature")) object.signature = options.bytes === String ? $util.base64.encode(message.signature, 0, message.signature.length) : options.bytes === Array ? Array.prototype.slice.call(message.signature) : message.signature; + if (message.timestamp != null && message.hasOwnProperty("timestamp")) + if (typeof message.timestamp === "number") + object.timestamp = options.longs === String ? String(message.timestamp) : message.timestamp; + else + object.timestamp = options.longs === String ? $util.Long.prototype.toString.call(message.timestamp) : options.longs === Number ? new $util.LongBits(message.timestamp.low >>> 0, message.timestamp.high >>> 0).toNumber(true) : message.timestamp; + if (message.signature_v1 != null && message.hasOwnProperty("signature_v1")) + object.signature_v1 = options.bytes === String ? $util.base64.encode(message.signature_v1, 0, message.signature_v1.length) : options.bytes === Array ? Array.prototype.slice.call(message.signature_v1) : message.signature_v1; return object; }; /**