From 23a875223fd6dda86bd9de3b931e5f54a82ea038 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:00:47 -0700 Subject: [PATCH 1/8] refactor(guest-agent)!: remove the Verify RPC Checking a signature needs no key material and no attestation. The agent's verdict also came back over the socket unattested, so a caller who believed the TEE was vouching for it was mistaken, and one who did not gained nothing over checking the signature locally. It was inbound attack surface for that non-benefit: attacker-supplied keys and signatures parsed inside the TEE on every call. Sign stays server-side, because it needs a key only the TEE holds. The RPC shipped in v0.5.6..v0.5.9, so this is a breaking change -- an SDK pinned at 0.5.x calling /Verify against a 0.6+ agent gets an unknown-method error. The SDKs verify locally instead; see the following commits. The method name is left documented in the proto so it is not reused for something else later. --- dstack/guest-agent/rpc/proto/agent_rpc.proto | 18 +--- dstack/guest-agent/src/rpc_service.rs | 96 +------------------- 2 files changed, 8 insertions(+), 106 deletions(-) diff --git a/dstack/guest-agent/rpc/proto/agent_rpc.proto b/dstack/guest-agent/rpc/proto/agent_rpc.proto index 369abbd3f..1a3552c5c 100644 --- a/dstack/guest-agent/rpc/proto/agent_rpc.proto +++ b/dstack/guest-agent/rpc/proto/agent_rpc.proto @@ -64,8 +64,11 @@ service DstackGuest { // Sign a payload rpc Sign(SignRequest) returns (SignResponse) {} - // Verify a signature - rpc Verify(VerifyRequest) returns (VerifyResponse) {} + // Removed in v0.6.0: `rpc Verify(VerifyRequest) returns (VerifyResponse)`. + // Signature verification needs no key material and no attestation, and the + // agent's answer arrives over the socket unattested, so a caller gained + // nothing over checking the signature itself. The SDKs now do it locally -- + // see `verify_signature` / `verify_signature_chain`. Do not reuse the name. // Get the guest agent version rpc Version(google.protobuf.Empty) returns (WorkerVersion) {} @@ -328,17 +331,6 @@ message SignResponse { bytes public_key = 3; } -message VerifyRequest { - string algorithm = 1; - bytes data = 2; - bytes signature = 3; - bytes public_key = 4; -} - -message VerifyResponse { - bool valid = 1; -} - message AttestAppKeyRequest { string algorithm = 1; } diff --git a/dstack/guest-agent/src/rpc_service.rs b/dstack/guest-agent/src/rpc_service.rs index 3002e40ca..e3ee8fc35 100644 --- a/dstack/guest-agent/src/rpc_service.rs +++ b/dstack/guest-agent/src/rpc_service.rs @@ -18,13 +18,11 @@ use dstack_guest_agent_rpc::{ AppInfo, AttestAppKeyRequest, AttestResponse, DeriveK256KeyResponse, DeriveKeyArgs, GetKeyArgs, GetKeyResponse, GetQuoteResponse, GetTlsKeyArgs, GetTlsKeyResponse, GpuInfoResponse, HealthResponse, RawQuoteArgs, SignRequest, SignResponse, TdxQuoteArgs, TdxQuoteResponse, - VerifyRequest, VerifyResponse, WorkerVersion, + WorkerVersion, }; use dstack_types::{AppKeys, SysConfig, GPU_ATTESTATION_OUTPUT}; -use ed25519_dalek::ed25519::signature::hazmat::{PrehashSigner, PrehashVerifier}; -use ed25519_dalek::{ - Signer as Ed25519Signer, SigningKey as Ed25519SigningKey, Verifier as Ed25519Verifier, -}; +use ed25519_dalek::ed25519::signature::hazmat::PrehashSigner; +use ed25519_dalek::{Signer as Ed25519Signer, SigningKey as Ed25519SigningKey}; use fs_err as fs; use k256::ecdsa::SigningKey; use or_panic::ResultOrPanic; @@ -445,40 +443,6 @@ impl DstackGuestRpc for InternalRpcHandler { }) } - async fn verify(self, request: VerifyRequest) -> Result { - let algorithm = normalize_algorithm(&request.algorithm); - let valid = match algorithm { - "ed25519" => { - let verifying_key = ed25519_dalek::VerifyingKey::from_bytes( - &request - .public_key - .as_slice() - .try_into() - .ok() - .context("invalid public key")?, - )?; - let signature = ed25519_dalek::Signature::from_slice(&request.signature)?; - verifying_key.verify(&request.data, &signature).is_ok() - } - "secp256k1" => { - let verifying_key = - k256::ecdsa::VerifyingKey::from_sec1_bytes(&request.public_key)?; - let signature = k256::ecdsa::Signature::from_slice(&request.signature)?; - verifying_key.verify(&request.data, &signature).is_ok() - } - "secp256k1_prehashed" => { - let verifying_key = - k256::ecdsa::VerifyingKey::from_sec1_bytes(&request.public_key)?; - let signature = k256::ecdsa::Signature::from_slice(&request.signature)?; - verifying_key - .verify_prehash(&request.data, &signature) - .is_ok() - } - _ => return Err(anyhow::anyhow!("Unsupported algorithm")), - }; - Ok(VerifyResponse { valid }) - } - async fn attest(self, request: RawQuoteArgs) -> Result { let report_data = pad64(&request.report_data).context("Report data is too long")?; self.state.attest_response(report_data) @@ -1028,60 +992,6 @@ pNs85uhOZE8z2jr8Pg== ) } - #[tokio::test] - async fn test_verify_ed25519_success() { - let (state, _guard) = setup_test_state().await; - let handler = InternalRpcHandler { - state: state.clone(), - }; - let data_to_sign = b"test message for ed25519"; - let sign_request = SignRequest { - algorithm: "ed25519".to_string(), - data: data_to_sign.to_vec(), - }; - - let sign_response = handler.sign(sign_request).await.unwrap(); - - let verify_request = VerifyRequest { - algorithm: "ed25519".to_string(), - data: data_to_sign.to_vec(), - signature: sign_response.signature, - public_key: sign_response.public_key, - }; - let handler = InternalRpcHandler { - state: state.clone(), - }; - let verify_response = handler.verify(verify_request).await.unwrap(); - assert!(verify_response.valid); - } - - #[tokio::test] - async fn test_verify_secp256k1_success() { - let (state, _guard) = setup_test_state().await; - let handler = InternalRpcHandler { - state: state.clone(), - }; - let data_to_sign = b"test message for secp256k1"; - let sign_request = SignRequest { - algorithm: "secp256k1".to_string(), - data: data_to_sign.to_vec(), - }; - - let sign_response = handler.sign(sign_request).await.unwrap(); - - let verify_request = VerifyRequest { - algorithm: "secp256k1".to_string(), - data: data_to_sign.to_vec(), - signature: sign_response.signature, - public_key: sign_response.public_key, - }; - let handler = InternalRpcHandler { - state: state.clone(), - }; - let verify_response = handler.verify(verify_request).await.unwrap(); - assert!(verify_response.valid); - } - #[tokio::test] async fn test_sign_ed25519_success() { let (state, _guard) = setup_test_state().await; From 1ce2b180b33da6523ff28831997908b718a2c96f Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:00:57 -0700 Subject: [PATCH 2/8] test(sdk): pin the signature-chain format with shared cross-SDK vectors Four independent ports of one byte-exact format is how this repository has shipped cross-language crypto drift before: the Go compose-hash helper HTML-escaped '<', so it hashed an app-compose differently from every other SDK, and that digest is what gets whitelisted on chain. So the format lives in one committed file, generated from the same primitives KMS and the guest agent actually use -- the RATLS HKDF, the dstack-kms-issued keccak preimage, RFC-6979 deterministic ECDSA -- and every SDK asserts against it. The generator re-derives and diffs on each run, so an intentional format change fails here first and the four SDK suites fail right after. The negative cases matter as much as the positive ones. secp256k1_high_s pins that the malleated (r, n-s) form of a valid signature must be rejected: k256 refuses it, but Python's cryptography and Go's decred secp256k1 accept it unless asked not to. wrong_kms_root_pubkey pins that a self-consistent chain anchored at a foreign root is refused, which is the check the whole chain rests on. --- REUSE.toml | 1 + .../tests/signature_chain_vectors.rs | 222 ++++++++++++++++++ sdk/tests/vectors/signature_chain.json | 70 ++++++ 3 files changed, 293 insertions(+) create mode 100644 dstack/guest-agent/tests/signature_chain_vectors.rs create mode 100644 sdk/tests/vectors/signature_chain.json diff --git a/REUSE.toml b/REUSE.toml index 3be5549c2..9eccd6a05 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -45,6 +45,7 @@ path = [ "tools/sca/examples/hello-c/config.json", "tools/sca/examples/heartbeat/rootfs/etc/heartbeat/interval", "sdk/simulator/*.json", + "sdk/tests/vectors/*.json", "sdk/go/go.sum", "sdk/go/ratls/go.sum", "dstack/kms/dstack-app/builder/shared/builder-pinned-packages.txt", diff --git a/dstack/guest-agent/tests/signature_chain_vectors.rs b/dstack/guest-agent/tests/signature_chain_vectors.rs new file mode 100644 index 000000000..cd20bff96 --- /dev/null +++ b/dstack/guest-agent/tests/signature_chain_vectors.rs @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Generates and pins the cross-SDK signature-chain test vectors. +//! +//! The Rust, Python, Go and JavaScript SDKs each reimplement `verify_signature` +//! and `verify_signature_chain`. Four independent ports of the same byte-exact +//! format is precisely how this project has shipped cross-language digest bugs +//! before -- the Go compose-hash helper HTML-escaped `<`, so it hashed an +//! app-compose differently from every other SDK, and that digest is what gets +//! whitelisted on chain. +//! +//! So the format lives in one committed file, `sdk/tests/vectors/signature_chain.json`, +//! generated here from the same primitives KMS and the guest agent actually use, +//! and every SDK asserts against it. If the chain format ever changes, this test +//! fails first and the SDK suites fail right after. +//! +//! Run `UPDATE_VECTORS=1 cargo test -p dstack-guest-agent --test signature_chain_vectors` +//! to regenerate after an intentional format change. + +use ed25519_dalek::{Signer as _, SigningKey as Ed25519SigningKey}; +use k256::ecdsa::SigningKey; +use serde_json::json; +use sha2::{Digest as _, Sha256}; +use sha3::Keccak256; + +const VECTORS_PATH: &str = "../../sdk/tests/vectors/signature_chain.json"; + +/// Fixed, obviously-fake KMS root scalar. Never a real key. +const KMS_ROOT_SCALAR: [u8; 32] = [ + 0x4b, 0x4d, 0x53, 0x2d, 0x72, 0x6f, 0x6f, 0x74, 0x2d, 0x74, 0x65, 0x73, 0x74, 0x2d, 0x6b, 0x65, + 0x79, 0x2d, 0x64, 0x6f, 0x2d, 0x6e, 0x6f, 0x74, 0x2d, 0x75, 0x73, 0x65, 0x21, 0x21, 0x21, 0x01, +]; +/// 20-byte app id, as `ensure_app_id_len` enforces. +const APP_ID: [u8; 20] = [ + 0xa9, 0x01, 0x9d, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x60, 0x71, 0x82, 0x93, 0xa4, 0xb5, 0xc6, 0xd7, + 0xe8, 0xf9, 0x0a, 0x1b, +]; + +/// `ra_tls::kdf::derive_key` -- HKDF-SHA256, salt "RATLS", info = concat(context_data). +fn derive_key(ikm: &[u8], context: &[&[u8]], len: usize) -> Vec { + ra_tls::kdf::derive_key(ikm, context, len).expect("derive_key") +} + +/// `kms::crypto::sign_message` -- keccak256(prefix ‖ ":" ‖ app_id ‖ message), recoverable. +fn sign_message(key: &SigningKey, prefix: &[u8], appid: &[u8], message: &[u8]) -> Vec { + let digest = + ::new_with_prefix([prefix, b":", appid, message].concat()); + let (sig, recid) = key + .sign_digest_recoverable(digest) + .expect("sign_digest_recoverable"); + let mut out = sig.to_vec(); + out.push(recid.to_byte()); + out +} + +#[test] +fn signature_chain_vectors_are_stable() { + let kms_root = SigningKey::from_bytes(&KMS_ROOT_SCALAR.into()).expect("kms root key"); + let kms_root_pubkey = kms_root.verifying_key().to_sec1_bytes().to_vec(); + + // Link [2]: KMS root signs the derived app root pubkey. Mirrors derive_k256_key(). + let app_root_scalar: [u8; 32] = derive_key(&kms_root.to_bytes(), &[&APP_ID, b"app-key"], 32) + .try_into() + .expect("app root scalar"); + let app_root = SigningKey::from_bytes(&app_root_scalar.into()).expect("app root key"); + let app_root_pubkey = app_root.verifying_key().to_sec1_bytes().to_vec(); + let kms_signature = sign_message(&kms_root, b"dstack-kms-issued", &APP_ID, &app_root_pubkey); + + // Sign() hardcodes path "vms" / purpose "signing". + let path = "vms"; + let purpose = "signing"; + let derived = derive_key(&app_root_scalar, &[path.as_bytes()], 32); + let derived_arr: [u8; 32] = derived.clone().try_into().expect("derived key"); + + let data = b"dstack signature chain test vector".to_vec(); + let prehash: Vec = Sha256::digest(&data).to_vec(); + + let mut cases = Vec::new(); + for algorithm in ["ed25519", "secp256k1", "secp256k1_prehashed"] { + // The signing pubkey, encoded exactly as get_key() hexes it for link [1]. + let (public_key, signature, signed_data) = match algorithm { + "ed25519" => { + let sk = Ed25519SigningKey::from_bytes(&derived_arr); + let pk = sk.verifying_key().to_bytes().to_vec(); + (pk, sk.sign(&data).to_bytes().to_vec(), data.clone()) + } + "secp256k1" => { + let sk = SigningKey::from_slice(&derived).expect("k256 key"); + let pk = sk.verifying_key().to_sec1_bytes().to_vec(); + let sig: k256::ecdsa::Signature = sk.sign(&data); + (pk, sig.to_bytes().to_vec(), data.clone()) + } + "secp256k1_prehashed" => { + use k256::ecdsa::signature::hazmat::PrehashSigner; + let sk = SigningKey::from_slice(&derived).expect("k256 key"); + let pk = sk.verifying_key().to_sec1_bytes().to_vec(); + let sig: k256::ecdsa::Signature = sk.sign_prehash(&prehash).expect("prehash sign"); + (pk, sig.to_bytes().to_vec(), prehash.clone()) + } + _ => unreachable!(), + }; + + // Link [1]: app root signs "{purpose}:{lowerhex(pubkey)}". + let msg = format!("{purpose}:{}", hex::encode(&public_key)); + let app_signature = { + let digest = ::new_with_prefix(msg.as_bytes()); + let (sig, recid) = app_root + .sign_digest_recoverable(digest) + .expect("app root sign"); + let mut out = sig.to_vec(); + out.push(recid.to_byte()); + out + }; + + cases.push(json!({ + "algorithm": algorithm, + // What Sign() hashes over. For secp256k1_prehashed this is the digest itself. + "data": hex::encode(&signed_data), + "public_key": hex::encode(&public_key), + "signature": hex::encode(&signature), + "signature_chain": [ + hex::encode(&signature), + hex::encode(&app_signature), + hex::encode(&kms_signature), + ], + })); + } + + // Negative cases. These pin behaviour that differs between crypto libraries, + // so a port cannot quietly disagree with what the guest agent used to do. + let mut invalid_cases = Vec::new(); + + // 1. High-S malleability. For every ECDSA signature (r, s), the pair (r, n-s) + // is also arithmetically valid. k256 -- and therefore the Sign RPC that used + // to back Verify -- rejects the high-S form. `@noble/curves` rejects it by + // default too, but Python's `cryptography` and Go's decred secp256k1 accept + // it unless the caller checks explicitly. Accepting it would mean a signature + // is not a unique identifier for a signed message. + { + let sk = SigningKey::from_slice(&derived).expect("k256 key"); + let pk = sk.verifying_key().to_sec1_bytes().to_vec(); + let sig: k256::ecdsa::Signature = sk.sign(&data); + let high_s = + k256::ecdsa::Signature::from_scalars(*sig.r(), -*sig.s()).expect("high-s signature"); + assert!( + high_s.normalize_s().is_some(), + "mutated signature must actually be high-S" + ); + invalid_cases.push(json!({ + "name": "secp256k1_high_s", + "reason": "high-S form of an otherwise valid signature; must be rejected", + "algorithm": "secp256k1", + "data": hex::encode(&data), + "public_key": hex::encode(&pk), + "signature": hex::encode(high_s.to_bytes()), + })); + } + + // 2. A valid signature checked against data it does not cover. + { + let sk = SigningKey::from_slice(&derived).expect("k256 key"); + let pk = sk.verifying_key().to_sec1_bytes().to_vec(); + let sig: k256::ecdsa::Signature = sk.sign(&data); + invalid_cases.push(json!({ + "name": "secp256k1_wrong_data", + "reason": "signature is valid, but not over this data", + "algorithm": "secp256k1", + "data": hex::encode(b"not the data that was signed"), + "public_key": hex::encode(&pk), + "signature": hex::encode(sig.to_bytes()), + })); + } + { + let sk = Ed25519SigningKey::from_bytes(&derived_arr); + let pk = sk.verifying_key().to_bytes().to_vec(); + invalid_cases.push(json!({ + "name": "ed25519_wrong_data", + "reason": "signature is valid, but not over this data", + "algorithm": "ed25519", + "data": hex::encode(b"not the data that was signed"), + "public_key": hex::encode(&pk), + "signature": hex::encode(sk.sign(&data).to_bytes()), + })); + } + + // 3. A self-consistent chain that simply is not anchored at our KMS root. + // A verifier that skips the final comparison accepts this, and accepting it + // means the chain proves nothing at all. + let foreign_root = SigningKey::from_bytes(&[0x5au8; 32].into()).expect("foreign root"); + let foreign_pubkey = foreign_root.verifying_key().to_sec1_bytes().to_vec(); + + let vectors = json!({ + "_comment": "Generated by dstack/guest-agent/tests/signature_chain_vectors.rs. \ + Do not edit by hand; run with UPDATE_VECTORS=1 to regenerate.", + "app_id": hex::encode(APP_ID), + "purpose": purpose, + "path": path, + "kms_root_pubkey": hex::encode(&kms_root_pubkey), + "app_root_pubkey": hex::encode(&app_root_pubkey), + "cases": cases, + "invalid_cases": invalid_cases, + "wrong_kms_root_pubkey": hex::encode(&foreign_pubkey), + }); + let rendered = format!("{}\n", serde_json::to_string_pretty(&vectors).unwrap()); + + if std::env::var("UPDATE_VECTORS").is_ok() { + std::fs::write(VECTORS_PATH, &rendered).expect("write vectors"); + return; + } + + let committed = std::fs::read_to_string(VECTORS_PATH).unwrap_or_else(|e| { + panic!("{VECTORS_PATH} missing ({e}); run with UPDATE_VECTORS=1 to generate") + }); + assert_eq!( + committed.trim(), + rendered.trim(), + "signature chain format drifted from the committed cross-SDK vectors; \ + if intentional, regenerate with UPDATE_VECTORS=1 and update all four SDKs" + ); +} diff --git a/sdk/tests/vectors/signature_chain.json b/sdk/tests/vectors/signature_chain.json new file mode 100644 index 000000000..efb9fc662 --- /dev/null +++ b/sdk/tests/vectors/signature_chain.json @@ -0,0 +1,70 @@ +{ + "_comment": "Generated by dstack/guest-agent/tests/signature_chain_vectors.rs. Do not edit by hand; run with UPDATE_VECTORS=1 to regenerate.", + "app_id": "a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b", + "purpose": "signing", + "path": "vms", + "kms_root_pubkey": "0318f228673448772dd4835a82149a9d475db8095234469a8fcc85cfa18b85abbc", + "app_root_pubkey": "03f8669c2bf08c4deeb712b81d456cd1eadbf97872dfc5d5ca916edf605fc5b6fe", + "cases": [ + { + "algorithm": "ed25519", + "data": "64737461636b207369676e617475726520636861696e207465737420766563746f72", + "public_key": "f2c761dfee96542500c1ca5460bb0e327b4acb67d882f5846e21baa8d55d2f5b", + "signature": "b29c21a4aa614b434542e938abe9e48c46369c4d37ecfcdbaae02251c73eff5f1912ada04309445575204a110a6fc74dd73734240a7c6e2bffbb5c9e1bd0b408", + "signature_chain": [ + "b29c21a4aa614b434542e938abe9e48c46369c4d37ecfcdbaae02251c73eff5f1912ada04309445575204a110a6fc74dd73734240a7c6e2bffbb5c9e1bd0b408", + "181498cdcebe6cae41fe1474b3d8af53c7969a2fb20f9f20a5cdcc9d92f5596d500cf9605996ec2e55406666fee630d5882e5e97c2417e079c6f074ca6c7c57301", + "00ac321fbbe6817c5de0720644e936b20046ff1c9226e0558a9c86856e8a1dd06bb7a7efae1cf3ec522553c28627ea96c139d5dc9cdd5a1898c2406c074438d800" + ] + }, + { + "algorithm": "secp256k1", + "data": "64737461636b207369676e617475726520636861696e207465737420766563746f72", + "public_key": "03f6e0f232f5eb6f4b118960a80c3939ea947b6dc727215aba43451910f2741bb8", + "signature": "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4", + "signature_chain": [ + "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4", + "77ae5ea476f00df11938fd1df3a65a4fbd9f92b64c816b9f29ee79a1e4ae74ed43269a36376b4ed838460ef3b56ba553cefa6e63def4a4487386f783f14295a001", + "00ac321fbbe6817c5de0720644e936b20046ff1c9226e0558a9c86856e8a1dd06bb7a7efae1cf3ec522553c28627ea96c139d5dc9cdd5a1898c2406c074438d800" + ] + }, + { + "algorithm": "secp256k1_prehashed", + "data": "18779657704cffe138dcca907960ddc7c4586078c0a7a60b689de8ec0e9f558b", + "public_key": "03f6e0f232f5eb6f4b118960a80c3939ea947b6dc727215aba43451910f2741bb8", + "signature": "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4", + "signature_chain": [ + "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4", + "77ae5ea476f00df11938fd1df3a65a4fbd9f92b64c816b9f29ee79a1e4ae74ed43269a36376b4ed838460ef3b56ba553cefa6e63def4a4487386f783f14295a001", + "00ac321fbbe6817c5de0720644e936b20046ff1c9226e0558a9c86856e8a1dd06bb7a7efae1cf3ec522553c28627ea96c139d5dc9cdd5a1898c2406c074438d800" + ] + } + ], + "invalid_cases": [ + { + "name": "secp256k1_high_s", + "reason": "high-S form of an otherwise valid signature; must be rejected", + "algorithm": "secp256k1", + "data": "64737461636b207369676e617475726520636861696e207465737420766563746f72", + "public_key": "03f6e0f232f5eb6f4b118960a80c3939ea947b6dc727215aba43451910f2741bb8", + "signature": "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc2a37da37585da6583da902408a18b428b9287a4f66f845641d9aa95d78f94df4d" + }, + { + "name": "secp256k1_wrong_data", + "reason": "signature is valid, but not over this data", + "algorithm": "secp256k1", + "data": "6e6f74207468652064617461207468617420776173207369676e6564", + "public_key": "03f6e0f232f5eb6f4b118960a80c3939ea947b6dc727215aba43451910f2741bb8", + "signature": "4753cea786fe882ec17a1f75199d15f43e864286ddf1d599a901e503db787dc25c825c8a7a259a7c256fdbf75e74bd73282737f03fc449f9e627c8b540a161f4" + }, + { + "name": "ed25519_wrong_data", + "reason": "signature is valid, but not over this data", + "algorithm": "ed25519", + "data": "6e6f74207468652064617461207468617420776173207369676e6564", + "public_key": "f2c761dfee96542500c1ca5460bb0e327b4acb67d882f5846e21baa8d55d2f5b", + "signature": "b29c21a4aa614b434542e938abe9e48c46369c4d37ecfcdbaae02251c73eff5f1912ada04309445575204a110a6fc74dd73734240a7c6e2bffbb5c9e1bd0b408" + } + ], + "wrong_kms_root_pubkey": "029c5530e4385ebc41cdaf8257edf9a2baaf8506a4099103211e6ed7382103ed67" +} From 0a76336b7dd7448541ad7c5061e595fe5a0bde9d Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:01:05 -0700 Subject: [PATCH 3/8] feat(sdk/rust): verify signatures and signature chains locally Replaces the client's verify() RPC wrapper with a standalone verify_signature. A pure function of four byte strings had no business hanging off an object that holds a socket connection. Adds verify_signature_chain, which is new capability rather than a port. The old RPC checked one signature against a public key the caller passed in, which proves only that whoever holds that key signed the data -- it says nothing about whose key it is. The chain walks all three links back to a KMS root key the caller supplies: the payload signature, the app root attesting "{purpose}:{hex(pubkey)}", and the KMS root attesting that app root for this app_id. That last comparison is the entire point; without a root the caller independently trusts, a chain is three signatures an attacker could have minted themselves. The docs say so, and say the same about app_id, since AppInfo comes from the CVM being checked. High-S signatures are rejected explicitly rather than left to the backend's default, so this cannot drift from the other three SDKs. --- sdk/rust/Cargo.lock | 28 ++- sdk/rust/Cargo.toml | 6 + sdk/rust/README.md | 64 ++++-- sdk/rust/examples/dstack_client_usage.rs | 13 +- sdk/rust/src/dstack_client.rs | 27 --- sdk/rust/src/lib.rs | 1 + sdk/rust/src/verify.rs | 243 +++++++++++++++++++++++ sdk/rust/tests/test_client.rs | 113 +++++------ sdk/rust/tests/test_verify.rs | 210 ++++++++++++++++++++ sdk/rust/types/src/dstack.rs | 9 - 10 files changed, 578 insertions(+), 136 deletions(-) create mode 100644 sdk/rust/src/verify.rs create mode 100644 sdk/rust/tests/test_verify.rs diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 18a5bc7d1..bbc64a639 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "rustc-hash", "secp256k1 0.31.1", "serde", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -355,7 +355,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "sha3", + "sha3 0.11.0", "syn 2.0.119", "syn-solidity", ] @@ -1528,13 +1528,16 @@ dependencies = [ "bon", "dcap-qvl", "dstack-sdk-types", + "ed25519-dalek", "hex", "http", "http-client-unix-domain-socket", + "k256", "reqwest", "serde", "serde_json", "sha2", + "sha3 0.10.9", "tokio", "x509-parser", ] @@ -2440,6 +2443,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keccak" version = "0.2.0" @@ -3711,6 +3723,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + [[package]] name = "sha3" version = "0.11.0" @@ -3718,7 +3740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak", + "keccak 0.2.0", ] [[package]] diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 0393855a3..0c73affec 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -19,6 +19,9 @@ serde = { version = "1.0.228", default-features = false, features = ["derive"] } serde_json = { version = "1.0.140", default-features = false, features = ["alloc"] } sha2 = { version = "0.10.8", default-features = false } dcap-qvl = "0.3.10" +ed25519-dalek = { version = "2.1.1", default-features = false } +k256 = { version = "0.13.4", default-features = false, features = ["ecdsa"] } +sha3 = { version = "0.10.8", default-features = false } tokio = { version = "1.46.1" } alloy = { version = "1.0.32", default-features = false } http = "1.3.1" @@ -38,6 +41,9 @@ alloy = { workspace = true, features = ["signers", "signer-local"] } anyhow.workspace = true dstack-sdk-types = { workspace = true, default-features = true } bon.workspace = true +ed25519-dalek.workspace = true +k256.workspace = true +sha3.workspace = true hex = { workspace = true, features = ["std"] } http.workspace = true http-client-unix-domain-socket = "0.1.1" diff --git a/sdk/rust/README.md b/sdk/rust/README.md index c859b3e09..45c93f941 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -151,40 +151,64 @@ println!("{:?}", tls.certificate_chain); // Certificate chain ### Sign and Verify -Sign data using TEE-derived keys (not yet released): +Signing happens in the TEE, because it needs a key only the TEE holds. Verifying +does not, so it runs locally in this SDK -- the guest agent's `Verify` RPC was +removed in v0.6.0. Its answer arrived over the socket unattested, so trusting it +was never better than checking the signature yourself. ```rust +use dstack_sdk::verify::{verify_signature, verify_signature_chain, SignatureChain}; + let result = client.sign("ed25519", b"message to sign".to_vec()).await?; -println!("{:?}", result.signature); -println!("{:?}", result.public_key); -// Verify the signature -let valid = client.verify( +// Does this signature check out under this public key? +let valid = verify_signature( "ed25519", - b"message to sign".to_vec(), - result.signature.clone(), - result.public_key.clone() -).await?; -println!("{}", valid.valid); // true + b"message to sign", + &result.decode_signature()?, + &result.decode_public_key()?, +)?; +assert!(valid); ``` **`sign()` Parameters:** -- `algorithm`: `"ed25519"`, `"secp256k1"`, or `"secp256k1_prehashed"` -- `data`: Data to sign +- `algorithm`: `"ed25519"`, `"secp256k1"` (alias `"k256"`), or `"secp256k1_prehashed"` +- `data`: Data to sign (a 32-byte digest for `secp256k1_prehashed`) **`sign()` Returns:** `SignResponse` - `signature`: Signature bytes - `public_key`: Public key bytes -- `signature_chain`: Signatures proving TEE origin +- `signature_chain`: Three signatures linking the signing key back to the KMS root + +**`verify_signature()` Returns** `Result` -- `Ok(false)` when a well-formed +signature does not match, and `Err` when an input is malformed (bad key length, +unknown algorithm, non-canonical high-S signature). A malformed input is a caller +bug, not a verdict. + +#### Verifying the whole chain + +`verify_signature` alone proves only that whoever holds that public key signed the +data. It says nothing about *whose* key it is. `verify_signature_chain` walks all +three links back to a KMS root key you supply: -**`verify()` Parameters:** -- `algorithm`: Algorithm used for signing -- `data`: Original data -- `signature`: Signature to verify -- `public_key`: Public key to verify against +```rust +let info = client.info().await?; +let verified = verify_signature_chain(&SignatureChain::from_sign_response( + "ed25519", + b"message to sign", + &result.decode_public_key()?, + &result.decode_signature_chain()?, + &hex::decode(&info.app_id)?, + &kms_root_pubkey, // you supply this -- see below +)?; +println!("app root key: {}", hex::encode(verified.app_root_pubkey)); +``` -**`verify()` Returns:** `VerifyResponse` -- `valid`: Boolean indicating if signature is valid +`kms_root_pubkey` must come from somewhere you already trust: the `DstackKms` +contract's `kmsInfo().k256Pubkey`, or a value you pinned. Reading it from the same +KMS you are checking against proves nothing -- an attacker who can answer that +query can also mint a self-consistent chain. This comparison is the entire point +of the chain; skip it and the other two links establish nothing. ## Blockchain Integration diff --git a/sdk/rust/examples/dstack_client_usage.rs b/sdk/rust/examples/dstack_client_usage.rs index f0b19ed5c..7e0d46188 100644 --- a/sdk/rust/examples/dstack_client_usage.rs +++ b/sdk/rust/examples/dstack_client_usage.rs @@ -4,6 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 use dstack_sdk::dstack_client::DstackClient; +use dstack_sdk::verify::verify_signature; use dstack_sdk_types::dstack::TlsKeyConfig; #[tokio::main] @@ -111,14 +112,8 @@ async fn main() -> anyhow::Result<()> { let sig_bytes = sign_resp.decode_signature()?; let pub_key_bytes = sign_resp.decode_public_key()?; - let verify_resp = client - .verify( - algorithm, - data_to_sign.clone(), - sig_bytes.clone(), - pub_key_bytes.clone(), - ) - .await?; - println!(" Verification successful: {}", verify_resp.valid); + // Verification is local -- it needs no key material and no round trip. + let valid = verify_signature(algorithm, &data_to_sign, &sig_bytes, &pub_key_bytes)?; + println!(" Verification successful: {valid}"); Ok(()) } diff --git a/sdk/rust/src/dstack_client.rs b/sdk/rust/src/dstack_client.rs index 2f0d31b0f..c6f824eeb 100644 --- a/sdk/rust/src/dstack_client.rs +++ b/sdk/rust/src/dstack_client.rs @@ -21,14 +21,6 @@ struct SignRequest<'a> { data: String, } -#[derive(Debug, Serialize)] -struct VerifyRequest<'a> { - algorithm: &'a str, - data: String, - signature: String, - public_key: String, -} - fn get_endpoint(endpoint: Option<&str>) -> String { if let Some(e) = endpoint { return e.to_string(); @@ -211,23 +203,4 @@ impl DstackClient { let response = serde_json::from_value::(response)?; Ok(response) } - - /// Verifies a payload signature. - pub async fn verify( - &self, - algorithm: &str, - data: Vec, - signature: Vec, - public_key: Vec, - ) -> Result { - let payload = VerifyRequest { - algorithm, - data: hex_encode(data), - signature: hex_encode(signature), - public_key: hex_encode(public_key), - }; - let response = self.send_rpc_request("/Verify", &payload).await?; - let response = serde_json::from_value::(response)?; - Ok(response) - } } diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 0c09e5bae..4ce9dc767 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -6,3 +6,4 @@ pub mod dstack_client; pub mod ethereum; pub mod tappd_client; +pub mod verify; diff --git a/sdk/rust/src/verify.rs b/sdk/rust/src/verify.rs new file mode 100644 index 000000000..bdc048c6f --- /dev/null +++ b/sdk/rust/src/verify.rs @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Local signature and signature-chain verification. +//! +//! Verification needs no key material and no attestation, so it does not belong +//! behind an RPC to the guest agent: the agent's answer arrives over the socket +//! unattested, which is no better than a caller checking the signature itself. +//! The `Verify` RPC these functions replace was removed in v0.6.0. +//! +//! Two levels are available: +//! +//! * [`verify_signature`] checks one signature against a public key you already +//! have. It is the direct replacement for the old RPC and, on its own, proves +//! only that whoever holds that key signed the data. +//! * [`verify_signature_chain`] walks the full chain from a [`SignResponse`] +//! back to a KMS root key **you supply**, which is what actually establishes +//! that the signer was a dstack app under that KMS. + +use anyhow::{bail, Context, Result}; +use k256::ecdsa::signature::hazmat::PrehashVerifier; +use k256::ecdsa::{RecoveryId, Signature as K256Signature, VerifyingKey}; +use sha3::Keccak256; + +/// Domain-separation prefix the KMS signs app root keys under. +const KMS_ISSUED_PREFIX: &[u8] = b"dstack-kms-issued"; +/// `Sign` derives its key at this path with this purpose; both are fixed agent-side. +pub const SIGN_PATH: &str = "vms"; +pub const SIGN_PURPOSE: &str = "signing"; + +/// `k256` and `ed25519` name the same thing; the agent normalized these too. +fn normalize_algorithm(algorithm: &str) -> &str { + match algorithm { + "k256" => "secp256k1", + other => other, + } +} + +fn parse_k256_signature(signature: &[u8]) -> Result { + let sig = K256Signature::from_slice(signature).context("invalid secp256k1 signature")?; + // ECDSA is malleable: (r, n-s) verifies wherever (r, s) does. k256 rejects the + // high-S form, so we must too -- otherwise a signature stops being a unique + // identifier for a signed message, and this SDK would disagree with every + // other dstack component about whether a given blob is valid. + if sig.normalize_s().is_some() { + bail!("non-canonical (high-S) secp256k1 signature"); + } + Ok(sig) +} + +/// Verifies one signature against `public_key`. +/// +/// `algorithm` is `ed25519`, `secp256k1` (alias `k256`), or `secp256k1_prehashed`, +/// where `data` is already a 32-byte digest. Returns `Ok(false)` when the inputs +/// are well-formed but the signature does not check out, and `Err` when they are +/// not well-formed at all (bad key encoding, wrong signature length, unknown +/// algorithm) -- a malformed input is a caller bug, not a verdict. +pub fn verify_signature( + algorithm: &str, + data: &[u8], + signature: &[u8], + public_key: &[u8], +) -> Result { + match normalize_algorithm(algorithm) { + "ed25519" => { + let key_bytes: [u8; 32] = public_key + .try_into() + .ok() + .context("ed25519 public key must be 32 bytes")?; + let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&key_bytes) + .context("invalid ed25519 public key")?; + let signature = ed25519_dalek::Signature::from_slice(signature) + .context("invalid ed25519 signature")?; + Ok(ed25519_dalek::Verifier::verify(&verifying_key, data, &signature).is_ok()) + } + "secp256k1" => { + let verifying_key = VerifyingKey::from_sec1_bytes(public_key) + .context("invalid secp256k1 public key")?; + let signature = parse_k256_signature(signature)?; + // k256's `sign` hashes with SHA-256, so verification must too. + Ok(k256::ecdsa::signature::Verifier::verify(&verifying_key, data, &signature).is_ok()) + } + "secp256k1_prehashed" => { + if data.len() != 32 { + bail!( + "pre-hashed verification requires a 32-byte digest, but received {} bytes", + data.len() + ); + } + let verifying_key = VerifyingKey::from_sec1_bytes(public_key) + .context("invalid secp256k1 public key")?; + let signature = parse_k256_signature(signature)?; + Ok(verifying_key.verify_prehash(data, &signature).is_ok()) + } + other => bail!("unsupported algorithm: {other}"), + } +} + +/// Recovers the compressed public key that produced a 65-byte `r ‖ s ‖ recid` +/// signature over `keccak256(message)`. +fn recover_compressed(message: &[u8], signature: &[u8]) -> Result> { + if signature.len() != 65 { + bail!( + "recoverable signature must be 65 bytes, but received {}", + signature.len() + ); + } + let sig = parse_k256_signature(&signature[..64])?; + let recid = RecoveryId::from_byte(signature[64]) + .with_context(|| format!("invalid recovery id {}", signature[64]))?; + let digest = ::new_with_prefix(message); + let recovered = VerifyingKey::recover_from_digest(digest, &sig, recid) + .context("failed to recover public key")?; + Ok(recovered.to_sec1_bytes().to_vec()) +} + +/// What a verified chain establishes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedChain { + /// The app root public key, recovered from the chain and confirmed to be the + /// one this KMS root signed. Compressed SEC1, 33 bytes. + pub app_root_pubkey: Vec, +} + +/// Inputs to [`verify_signature_chain`]. +/// +/// A struct rather than a positional argument list so that adding an input later +/// does not break callers. +#[derive(Debug, Clone)] +pub struct SignatureChain<'a> { + /// Algorithm the payload was signed with. + pub algorithm: &'a str, + /// The signed payload; a 32-byte digest for `secp256k1_prehashed`. + pub data: &'a [u8], + /// `SignResponse::public_key` -- the key that signed `data`. + pub public_key: &'a [u8], + /// `SignResponse::signature_chain`, exactly 3 elements. + pub signature_chain: &'a [Vec], + /// The 20-byte app identity to hold the chain to. + /// + /// This must be the app id you *expect*, not merely whatever `AppInfo` + /// echoed back -- that comes from the CVM being checked. Comparing a chain + /// against an app id the same CVM supplied proves only that it is + /// self-consistent. + pub app_id: &'a [u8], + /// The KMS root public key you already trust, compressed or uncompressed SEC1. + /// + /// Get it from the `DstackKms` contract (`kmsInfo().k256Pubkey`) or pin it. + /// Reading it from the KMS you are verifying against proves nothing. + pub kms_root_pubkey: &'a [u8], + /// Purpose bound into the app-root link. Always [`SIGN_PURPOSE`] for `Sign`. + pub purpose: &'a str, +} + +impl<'a> SignatureChain<'a> { + /// A chain as produced by the `Sign` RPC, which fixes purpose to `signing`. + pub fn from_sign_response( + algorithm: &'a str, + data: &'a [u8], + public_key: &'a [u8], + signature_chain: &'a [Vec], + app_id: &'a [u8], + kms_root_pubkey: &'a [u8], + ) -> Self { + Self { + algorithm, + data, + public_key, + signature_chain, + app_id, + kms_root_pubkey, + purpose: SIGN_PURPOSE, + } + } +} + +/// Verifies a `Sign` signature chain end to end. +/// +/// Three links, all of which must hold: +/// +/// 1. `chain[0]` is a signature over `data` by `public_key`. +/// 2. `chain[1]` is the app root key attesting `"{purpose}:{hex(public_key)}"`. +/// 3. `chain[2]` is `kms_root_pubkey` attesting that app root key for `app_id`. +/// +/// Link 3 is the one that matters. Without comparing against a KMS root key you +/// independently trust, a chain is just three signatures an attacker could have +/// produced with their own keys. +pub fn verify_signature_chain(chain: &SignatureChain<'_>) -> Result { + if chain.signature_chain.len() != 3 { + bail!( + "signature chain must have 3 elements, but received {}", + chain.signature_chain.len() + ); + } + if chain.app_id.len() != 20 { + bail!( + "app_id must be 20 bytes, but received {}", + chain.app_id.len() + ); + } + + // Link 1: the payload signature. chain[0] *is* that signature; what matters + // is that it checks out under `public_key`, which links 2 and 3 then cover. + if !verify_signature( + chain.algorithm, + chain.data, + &chain.signature_chain[0], + chain.public_key, + ) + .context("failed to check the payload signature")? + { + bail!("payload signature is not valid for the given public key"); + } + + // Link 2: recover the app root key that vouched for the signing key. + let message = format!("{}:{}", chain.purpose, hex::encode(chain.public_key)); + let app_root_pubkey = recover_compressed(message.as_bytes(), &chain.signature_chain[1]) + .context("failed to recover the app root key")?; + + // Link 3: recover the KMS root key that vouched for the app root key, and + // check it is the one we were told to trust. + let kms_message = [ + KMS_ISSUED_PREFIX, + b":", + chain.app_id, + app_root_pubkey.as_slice(), + ] + .concat(); + let recovered_kms = recover_compressed(&kms_message, &chain.signature_chain[2]) + .context("failed to recover the KMS root key")?; + + // Normalize the expected key so callers may pass either SEC1 encoding. + let expected_kms = VerifyingKey::from_sec1_bytes(chain.kms_root_pubkey) + .context("invalid KMS root public key")? + .to_sec1_bytes() + .to_vec(); + if recovered_kms != expected_kms { + bail!("signature chain is not anchored at the expected KMS root key"); + } + + Ok(VerifiedChain { app_root_pubkey }) +} diff --git a/sdk/rust/tests/test_client.rs b/sdk/rust/tests/test_client.rs index 69da1d38f..6bd636c0d 100644 --- a/sdk/rust/tests/test_client.rs +++ b/sdk/rust/tests/test_client.rs @@ -7,6 +7,7 @@ use dcap_qvl::quote::Quote; use dstack_sdk::dstack_client::DstackClient as AsyncDstackClient; +use dstack_sdk::verify::verify_signature; use sha2::{Digest, Sha256}; #[tokio::test] @@ -93,74 +94,6 @@ async fn test_info() { assert!(!info.compose_hash.is_empty()); } -#[tokio::test] -async fn test_async_client_sign_and_verify_ed25519() { - let client = AsyncDstackClient::new(None); - let data_to_sign = b"test message for ed25519".to_vec(); - let algorithm = "ed25519"; - - let sign_resp = client.sign(algorithm, data_to_sign.clone()).await.unwrap(); - assert!(!sign_resp.signature.is_empty()); - assert!(!sign_resp.public_key.is_empty()); - assert_eq!(sign_resp.signature_chain.len(), 3); - - let sig = sign_resp.decode_signature().unwrap(); - let pub_key = sign_resp.decode_public_key().unwrap(); - - let verify_resp = client - .verify( - algorithm, - data_to_sign.clone(), - sig.clone(), - pub_key.clone(), - ) - .await - .unwrap(); - assert!(verify_resp.valid); - - let bad_data = b"wrong message".to_vec(); - let verify_resp_bad = client - .verify(algorithm, bad_data, sig, pub_key) - .await - .unwrap(); - assert!(!verify_resp_bad.valid); -} - -#[tokio::test] -async fn test_async_client_sign_and_verify_secp256k1() { - let client = AsyncDstackClient::new(None); - let data_to_sign = b"test message for secp256k1".to_vec(); - let algorithm = "secp256k1"; - - let sign_resp = client.sign(algorithm, data_to_sign.clone()).await.unwrap(); - let sig = sign_resp.decode_signature().unwrap(); - let pub_key = sign_resp.decode_public_key().unwrap(); - - let verify_resp = client - .verify(algorithm, data_to_sign, sig, pub_key) - .await - .unwrap(); - assert!(verify_resp.valid); -} - -#[tokio::test] -async fn test_async_client_sign_and_verify_secp256k1_prehashed() { - let client = AsyncDstackClient::new(None); - let data_to_sign = b"test message for secp256k1 prehashed"; - let digest = Sha256::digest(data_to_sign).to_vec(); - let algorithm = "secp256k1_prehashed"; - - let sign_resp = client.sign(algorithm, digest.clone()).await.unwrap(); - let sig = sign_resp.decode_signature().unwrap(); - let pub_key = sign_resp.decode_public_key().unwrap(); - - let verify_resp = client - .verify(algorithm, digest.clone(), sig, pub_key) - .await - .unwrap(); - assert!(verify_resp.valid); -} - #[tokio::test] async fn test_async_client_version() { let client = AsyncDstackClient::new(None); @@ -194,3 +127,47 @@ async fn test_async_client_sign_k256_alias() { let resp_secp = client.sign("secp256k1", data.clone()).await.unwrap(); assert_eq!(resp_k256.public_key, resp_secp.public_key); } + +// The Sign RPC is still server-side; only the checking of its result moved into +// the SDK. These replace the round trips that used to call the removed Verify RPC. + +#[tokio::test] +async fn test_sign_then_verify_locally_ed25519() { + let client = AsyncDstackClient::new(None); + let data = b"test message for ed25519".to_vec(); + let resp = client.sign("ed25519", data.clone()).await.unwrap(); + let signature = resp.decode_signature().unwrap(); + let public_key = resp.decode_public_key().unwrap(); + + assert_eq!(resp.signature_chain.len(), 3); + assert!(verify_signature("ed25519", &data, &signature, &public_key).unwrap()); + assert!(!verify_signature("ed25519", b"wrong message", &signature, &public_key).unwrap()); +} + +#[tokio::test] +async fn test_sign_then_verify_locally_secp256k1() { + let client = AsyncDstackClient::new(None); + let data = b"test message for secp256k1".to_vec(); + let resp = client.sign("secp256k1", data.clone()).await.unwrap(); + let signature = resp.decode_signature().unwrap(); + let public_key = resp.decode_public_key().unwrap(); + + assert_eq!(resp.signature_chain.len(), 3); + assert!(verify_signature("secp256k1", &data, &signature, &public_key).unwrap()); + assert!(!verify_signature("secp256k1", b"wrong message", &signature, &public_key).unwrap()); +} + +#[tokio::test] +async fn test_sign_then_verify_locally_secp256k1_prehashed() { + let client = AsyncDstackClient::new(None); + let digest = Sha256::digest(b"test message for prehashed").to_vec(); + let resp = client + .sign("secp256k1_prehashed", digest.clone()) + .await + .unwrap(); + let signature = resp.decode_signature().unwrap(); + let public_key = resp.decode_public_key().unwrap(); + + assert_eq!(resp.signature_chain.len(), 3); + assert!(verify_signature("secp256k1_prehashed", &digest, &signature, &public_key).unwrap()); +} diff --git a/sdk/rust/tests/test_verify.rs b/sdk/rust/tests/test_verify.rs new file mode 100644 index 000000000..f36d7f48b --- /dev/null +++ b/sdk/rust/tests/test_verify.rs @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! Drives the shared cross-SDK vectors in `sdk/tests/vectors/signature_chain.json`. +//! The Python, Go and JavaScript suites assert against the same file, so any port +//! that disagrees about the byte format fails here too. + +use dstack_sdk::verify::{verify_signature, verify_signature_chain, SignatureChain, SIGN_PURPOSE}; +use serde_json::Value; + +fn vectors() -> Value { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tests/vectors/signature_chain.json" + ); + serde_json::from_str(&std::fs::read_to_string(path).expect("read vectors")).expect("parse") +} + +fn unhex(v: &Value) -> Vec { + hex::decode(v.as_str().expect("hex string")).expect("valid hex") +} + +#[test] +fn valid_signatures_verify() { + let v = vectors(); + for case in v["cases"].as_array().unwrap() { + let algorithm = case["algorithm"].as_str().unwrap(); + assert!( + verify_signature( + algorithm, + &unhex(&case["data"]), + &unhex(&case["signature"]), + &unhex(&case["public_key"]), + ) + .unwrap_or_else(|e| panic!("{algorithm}: {e}")), + "{algorithm}: valid signature was rejected" + ); + } +} + +#[test] +fn invalid_signatures_are_rejected() { + let v = vectors(); + for case in v["invalid_cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let verdict = verify_signature( + case["algorithm"].as_str().unwrap(), + &unhex(&case["data"]), + &unhex(&case["signature"]), + &unhex(&case["public_key"]), + ); + // High-S is refused outright rather than reported false, because it is a + // malformed encoding rather than a legitimate signature that fails to match. + match verdict { + Ok(valid) => assert!(!valid, "{name}: should not have verified"), + Err(_) if name == "secp256k1_high_s" => {} + Err(e) => panic!("{name}: unexpected error {e}"), + } + } +} + +#[test] +fn k256_is_an_alias_for_secp256k1() { + let v = vectors(); + let case = v["cases"] + .as_array() + .unwrap() + .iter() + .find(|c| c["algorithm"] == "secp256k1") + .unwrap(); + assert!(verify_signature( + "k256", + &unhex(&case["data"]), + &unhex(&case["signature"]), + &unhex(&case["public_key"]), + ) + .unwrap()); +} + +fn chain_of(case: &Value) -> Vec> { + case["signature_chain"] + .as_array() + .unwrap() + .iter() + .map(unhex) + .collect() +} + +#[test] +fn full_chain_verifies_to_the_kms_root() { + let v = vectors(); + let app_id = unhex(&v["app_id"]); + let kms_root = unhex(&v["kms_root_pubkey"]); + let expected_app_root = unhex(&v["app_root_pubkey"]); + + for case in v["cases"].as_array().unwrap() { + let algorithm = case["algorithm"].as_str().unwrap(); + let data = unhex(&case["data"]); + let public_key = unhex(&case["public_key"]); + let chain = chain_of(case); + let verified = verify_signature_chain(&SignatureChain::from_sign_response( + algorithm, + &data, + &public_key, + &chain, + &app_id, + &kms_root, + )) + .unwrap_or_else(|e| panic!("{algorithm}: {e}")); + assert_eq!( + verified.app_root_pubkey, expected_app_root, + "{algorithm}: recovered the wrong app root key" + ); + } +} + +#[test] +fn chain_anchored_at_a_foreign_kms_root_is_rejected() { + let v = vectors(); + let app_id = unhex(&v["app_id"]); + let wrong_root = unhex(&v["wrong_kms_root_pubkey"]); + let case = &v["cases"].as_array().unwrap()[0]; + let data = unhex(&case["data"]); + let public_key = unhex(&case["public_key"]); + let chain = chain_of(case); + + let err = verify_signature_chain(&SignatureChain::from_sign_response( + case["algorithm"].as_str().unwrap(), + &data, + &public_key, + &chain, + &app_id, + &wrong_root, + )) + .expect_err("a chain not anchored at our KMS root must be rejected"); + assert!( + err.to_string().contains("not anchored"), + "unexpected error: {err}" + ); +} + +#[test] +fn chain_for_a_different_app_id_is_rejected() { + let v = vectors(); + let kms_root = unhex(&v["kms_root_pubkey"]); + let case = &v["cases"].as_array().unwrap()[0]; + let data = unhex(&case["data"]); + let public_key = unhex(&case["public_key"]); + let chain = chain_of(case); + let mut app_id = unhex(&v["app_id"]); + app_id[0] ^= 0xff; + + assert!(verify_signature_chain(&SignatureChain::from_sign_response( + case["algorithm"].as_str().unwrap(), + &data, + &public_key, + &chain, + &app_id, + &kms_root, + )) + .is_err()); +} + +#[test] +fn tampered_payload_breaks_the_chain() { + let v = vectors(); + let app_id = unhex(&v["app_id"]); + let kms_root = unhex(&v["kms_root_pubkey"]); + let case = &v["cases"].as_array().unwrap()[0]; + let public_key = unhex(&case["public_key"]); + let chain = chain_of(case); + let tampered = b"a different payload entirely".to_vec(); + + assert!(verify_signature_chain(&SignatureChain::from_sign_response( + case["algorithm"].as_str().unwrap(), + &tampered, + &public_key, + &chain, + &app_id, + &kms_root, + )) + .is_err()); +} + +#[test] +fn malformed_inputs_error_rather_than_report_false() { + assert!(verify_signature("rsa", b"x", &[0; 64], &[0; 32]).is_err()); + assert!(verify_signature("ed25519", b"x", &[0; 64], &[0; 31]).is_err()); + // A prehashed digest must be exactly 32 bytes. + let v = vectors(); + let case = v["cases"] + .as_array() + .unwrap() + .iter() + .find(|c| c["algorithm"] == "secp256k1_prehashed") + .unwrap(); + assert!(verify_signature( + "secp256k1_prehashed", + b"short", + &unhex(&case["signature"]), + &unhex(&case["public_key"]), + ) + .is_err()); +} + +#[test] +fn sign_purpose_is_the_agent_side_constant() { + assert_eq!(SIGN_PURPOSE, "signing"); +} diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack.rs index 3c2ced1fc..18144e08b 100644 --- a/sdk/rust/types/src/dstack.rs +++ b/sdk/rust/types/src/dstack.rs @@ -235,15 +235,6 @@ impl SignResponse { } } -/// Response from a Verify request -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] -#[cfg_attr(feature = "borsh_schema", derive(BorshSchema))] -pub struct VerifyResponse { - /// Whether the signature is valid - pub valid: bool, -} - /// Response from a Version request #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] From 170ed5060fef6dd75826b99bb5c612dc27610375 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:01:15 -0700 Subject: [PATCH 4/8] feat(sdk/python): verify signatures and signature chains locally Mirrors the Rust SDK: verify_signature replaces the removed verify() RPC wrapper, and verify_signature_chain walks the chain back to a caller-supplied KMS root key. Both drive the shared vectors. No new dependencies -- cryptography covers Ed25519 and SECP256K1 (raw r||s converted to DER via encode_dss_signature), eth-keys covers the recoverable signatures in links two and three. cryptography does not enforce low-S, so the malleated (r, n-s) form of a valid signature is refused by an explicit check here rather than by the library. Without it this SDK would have accepted signatures the guest agent rejected. --- sdk/python/README.md | 60 ++++- sdk/python/src/dstack_sdk/__init__.py | 5 +- sdk/python/src/dstack_sdk/dstack_client.py | 36 --- sdk/python/src/dstack_sdk/verify.py | 241 ++++++++++++++++++++ sdk/python/tests/test_client.py | 107 +++------ sdk/python/tests/test_verify.py | 246 +++++++++++++++++++++ 6 files changed, 574 insertions(+), 121 deletions(-) create mode 100644 sdk/python/src/dstack_sdk/verify.py create mode 100644 sdk/python/tests/test_verify.py diff --git a/sdk/python/README.md b/sdk/python/README.md index d793435a6..716f8cad3 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -168,29 +168,66 @@ When any of the 0.5.7-only options is set, the SDK probes `Version` first and ra ### Sign and Verify -Sign data using TEE-derived keys: +Signing happens in the TEE, because it needs a key only the TEE holds. Verifying +does not, so it runs locally in this SDK — the guest agent's `Verify` RPC was +removed in v0.6.0. Its answer arrived over the socket unattested, so trusting it +was never better than checking the signature yourself. ```python +from dstack_sdk import verify_signature, verify_signature_chain + result = client.sign('ed25519', b'message to sign') -print(result.signature) -print(result.public_key) -# Verify the signature -valid = client.verify('ed25519', b'message to sign', result.signature, result.public_key) -print(valid.valid) # True +# Does this signature check out under this public key? +valid = verify_signature( + 'ed25519', + b'message to sign', + result.decode_signature(), + result.decode_public_key(), +) +assert valid is True ``` **`sign()` Parameters:** -- `algorithm`: `'ed25519'`, `'secp256k1'`, or `'secp256k1_prehashed'` +- `algorithm`: `'ed25519'`, `'secp256k1'` (alias `'k256'`), or `'secp256k1_prehashed'` - `data`: Data to sign (`bytes` or `str`). For `secp256k1_prehashed`, must be a 32-byte digest. **`sign()` Returns:** `SignResponse` - `signature`: Hex-encoded signature - `public_key`: Hex-encoded public key -- `signature_chain`: Signatures proving TEE origin +- `signature_chain`: Three signatures linking the signing key back to the KMS root + +**`verify_signature()` Returns:** `bool` — `False` when a well-formed signature +does not match, and it *raises* `ValueError` when an input is malformed (bad key +length, wrong signature length, unknown algorithm, non-canonical high-S +signature). A malformed input is a caller bug, not a verdict. + +#### Verifying the whole chain + +`verify_signature` alone proves only that whoever holds that public key signed +the data. It says nothing about *whose* key it is. `verify_signature_chain` +walks all three links back to a KMS root key you supply: + +```python +info = client.info() +app_root_pubkey = verify_signature_chain( + 'ed25519', + b'message to sign', + result.decode_public_key(), + result.decode_signature_chain(), + bytes.fromhex(info.app_id), + kms_root_pubkey, # you supply this — see below +) +print(app_root_pubkey.hex()) # compressed SEC1, 33 bytes +``` + +It returns the app root public key and raises `ValueError` on any failure. -**`verify()` Returns:** `VerifyResponse` -- `valid`: Boolean indicating if the signature is valid +`kms_root_pubkey` must come from somewhere you already trust: the `DstackKms` +contract's `kmsInfo().k256Pubkey`, or a value you pinned. Reading it from the +same KMS you are checking against proves nothing — an attacker who can answer +that query can also mint a self-consistent chain. This comparison is the entire +point of the chain; skip it and the other two links establish nothing. ### Diagnostics @@ -315,9 +352,10 @@ hash_value = get_compose_hash(app_compose_dict) | Feature | Required dstack OS | |---|---| | `get_key`, `get_quote`, `get_tls_key` (legacy fields), `info` (legacy fields) | 0.3+ | -| `attest`, `sign` / `verify`, `is_reachable` | 0.5.0+ (sign/verify require server build with the feature) | +| `attest`, `sign`, `is_reachable` | 0.5.0+ (`sign` requires a server build with the feature) | | `version`, `algorithm='ed25519'` on `get_key`, `info.cloud_vendor` / `cloud_product`, `not_before` / `not_after` / `with_app_info` on `get_tls_key` | 0.5.7+ | | `verify_env_encrypt_public_key` (signature_v1 with timestamp) | Requires KMS build that emits `signature_v1`; legacy variant remains available | +| `verify_signature`, `verify_signature_chain` | Any — verification is local and needs no guest agent | Calls that require 0.5.7-only fields probe the `Version` RPC first and raise a clear `RuntimeError` on older guest agents. diff --git a/sdk/python/src/dstack_sdk/__init__.py b/sdk/python/src/dstack_sdk/__init__.py index e93c6009f..3f9bc4cdb 100644 --- a/sdk/python/src/dstack_sdk/__init__.py +++ b/sdk/python/src/dstack_sdk/__init__.py @@ -15,7 +15,6 @@ from .dstack_client import SignResponse from .dstack_client import TappdClient from .dstack_client import TcbInfo -from .dstack_client import VerifyResponse from .dstack_client import VersionResponse from .encrypt_env_vars import EnvVar from .encrypt_env_vars import encrypt_env_vars @@ -24,6 +23,8 @@ from .get_compose_hash import DockerConfig from .get_compose_hash import Requirements from .get_compose_hash import get_compose_hash +from .verify import verify_signature +from .verify import verify_signature_chain from .verify_env_encrypt_public_key import verify_env_encrypt_public_key from .verify_env_encrypt_public_key import verify_env_encrypt_public_key_legacy @@ -51,6 +52,8 @@ "AppCompose", "DockerConfig", "Requirements", + "verify_signature", + "verify_signature_chain", "verify_env_encrypt_public_key", "verify_env_encrypt_public_key_legacy", ] diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client.py index cd1caadc0..96b40e577 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client.py @@ -176,10 +176,6 @@ def decode_public_key(self) -> bytes: return bytes.fromhex(self.public_key) -class VerifyResponse(BaseModel): - valid: bool - - class VersionResponse(BaseModel): version: str rev: str @@ -514,27 +510,6 @@ async def sign(self, algorithm: str, data: str | bytes) -> SignResponse: result = await self._send_rpc_request("Sign", payload) return SignResponse(**result) - async def verify( - self, - algorithm: str, - data: str | bytes, - signature: str | bytes, - public_key: str | bytes, - ) -> VerifyResponse: - """Verify a signature.""" - data_bytes = data.encode() if isinstance(data, str) else data - sig_bytes = signature.encode() if isinstance(signature, str) else signature - pk_bytes = public_key.encode() if isinstance(public_key, str) else public_key - - payload = { - "algorithm": algorithm, - "data": binascii.hexlify(data_bytes).decode(), - "signature": binascii.hexlify(sig_bytes).decode(), - "public_key": binascii.hexlify(pk_bytes).decode(), - } - result = await self._send_rpc_request("Verify", payload) - return VerifyResponse(**result) - async def version(self) -> VersionResponse: """Query the guest-agent version. @@ -629,17 +604,6 @@ def sign(self, algorithm: str, data: str | bytes) -> SignResponse: """Signs data using a derived key.""" raise NotImplementedError - @call_async - def verify( - self, - algorithm: str, - data: str | bytes, - signature: str | bytes, - public_key: str | bytes, - ) -> VerifyResponse: - """Verify a signature.""" - raise NotImplementedError - @call_async def version(self) -> VersionResponse: """Query the guest-agent version.""" diff --git a/sdk/python/src/dstack_sdk/verify.py b/sdk/python/src/dstack_sdk/verify.py new file mode 100644 index 000000000..a278ab6aa --- /dev/null +++ b/sdk/python/src/dstack_sdk/verify.py @@ -0,0 +1,241 @@ +# SPDX-FileCopyrightText: © 2026 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +"""Local signature and signature-chain verification. + +Verification needs no key material and no attestation, so it does not belong +behind an RPC to the guest agent: the agent's answer arrives over the socket +unattested, which is no better than a caller checking the signature itself. The +``Verify`` RPC these functions replace was removed in v0.6.0. + +Two levels are available: + +* :func:`verify_signature` checks one signature against a public key you + already have. It is the direct replacement for the old RPC and, on its own, + proves only that whoever holds that key signed the data. +* :func:`verify_signature_chain` walks the full chain from a ``SignResponse`` + back to a KMS root key **you supply**, which is what actually establishes + that the signer was a dstack app under that KMS. +""" + +from typing import Sequence + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import utils +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography.hazmat.primitives.serialization import PublicFormat +from eth_keys import keys +from eth_utils import keccak + +__all__ = ["verify_signature", "verify_signature_chain", "SIGN_PATH", "SIGN_PURPOSE"] + +#: Domain-separation prefix the KMS signs app root keys under. +_KMS_ISSUED_PREFIX = b"dstack-kms-issued" +_SEPARATOR = b":" + +#: ``Sign`` derives its key at this path with this purpose; both are fixed agent-side. +SIGN_PATH = "vms" +SIGN_PURPOSE = "signing" + +#: Order of the secp256k1 group. Signatures with ``s`` above half of this are +#: the malleable "high-S" form. +_SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +_SECP256K1_HALF_ORDER = _SECP256K1_ORDER // 2 + + +def _normalize_algorithm(algorithm: str) -> str: + """``k256`` and ``secp256k1`` name the same thing; the agent normalized these too.""" + return "secp256k1" if algorithm == "k256" else algorithm + + +def _parse_k256_signature(signature: bytes) -> tuple[int, int]: + """Split a raw 64-byte ``r || s`` signature, rejecting the high-S form. + + ECDSA is malleable: ``(r, n - s)`` verifies wherever ``(r, s)`` does. The + Rust SDK's k256 backend rejects the high-S form, so we must too -- otherwise + a signature stops being a unique identifier for a signed message, and this + SDK would disagree with every other dstack component about whether a given + blob is valid. ``cryptography`` accepts high-S happily, hence the explicit + check here. + """ + if len(signature) != 64: + raise ValueError( + f"secp256k1 signature must be 64 raw bytes (r || s), but received {len(signature)}" + ) + r = int.from_bytes(signature[:32], "big") + s = int.from_bytes(signature[32:], "big") + if r == 0 or r >= _SECP256K1_ORDER or s == 0 or s >= _SECP256K1_ORDER: + raise ValueError("invalid secp256k1 signature: r or s out of range") + if s > _SECP256K1_HALF_ORDER: + raise ValueError("non-canonical (high-S) secp256k1 signature") + return r, s + + +def _load_k256_public_key(public_key: bytes) -> ec.EllipticCurvePublicKey: + """Load a SEC1 secp256k1 key, compressed (33 bytes) or uncompressed (65).""" + try: + return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), public_key) + except ValueError as exc: + raise ValueError(f"invalid secp256k1 public key: {exc}") from exc + + +def _compress(public_key: ec.EllipticCurvePublicKey) -> bytes: + return public_key.public_bytes(Encoding.X962, PublicFormat.CompressedPoint) + + +def verify_signature( + algorithm: str, + data: bytes, + signature: bytes, + public_key: bytes, +) -> bool: + """Verify one signature against ``public_key``. + + ``algorithm`` is ``ed25519``, ``secp256k1`` (alias ``k256``), or + ``secp256k1_prehashed``, where ``data`` is already a 32-byte digest. + + Returns ``False`` when the inputs are well-formed but the signature does not + check out, and raises when they are not well-formed at all (bad key + encoding, wrong signature length, unknown algorithm) -- a malformed input is + a caller bug, not a verdict. + """ + normalized = _normalize_algorithm(algorithm) + + if normalized == "ed25519": + if len(public_key) != 32: + raise ValueError( + f"ed25519 public key must be 32 bytes, but received {len(public_key)}" + ) + if len(signature) != 64: + raise ValueError( + f"ed25519 signature must be 64 bytes, but received {len(signature)}" + ) + try: + ed_key = Ed25519PublicKey.from_public_bytes(public_key) + except Exception as exc: # noqa: BLE001 - re-raised as a caller-facing error + raise ValueError(f"invalid ed25519 public key: {exc}") from exc + try: + ed_key.verify(signature, data) + return True + except InvalidSignature: + return False + + if normalized in ("secp256k1", "secp256k1_prehashed"): + prehashed = normalized == "secp256k1_prehashed" + if prehashed and len(data) != 32: + raise ValueError( + "pre-hashed verification requires a 32-byte digest, " + f"but received {len(data)} bytes" + ) + k256_key = _load_k256_public_key(public_key) + r, s = _parse_k256_signature(signature) + # k256's `sign` hashes with SHA-256, so verification must too. + algo = ( + ec.ECDSA(utils.Prehashed(hashes.SHA256())) + if prehashed + else ec.ECDSA(hashes.SHA256()) + ) + try: + k256_key.verify(utils.encode_dss_signature(r, s), data, algo) + return True + except InvalidSignature: + return False + + raise ValueError(f"unsupported algorithm: {algorithm}") + + +def _recover_compressed(message: bytes, signature: bytes) -> bytes: + """Recover the public key behind a recoverable signature, compressed. + + The signature is 65 bytes, ``r || s || recid``, over ``keccak256(message)``. + """ + if len(signature) != 65: + raise ValueError( + f"recoverable signature must be 65 bytes, but received {len(signature)}" + ) + # Rejects high-S and out-of-range r/s before we hand anything to eth_keys. + _parse_k256_signature(signature[:64]) + recid = signature[64] + if recid > 3: + raise ValueError(f"invalid recovery id {recid}") + if recid > 1: + # eth_keys only models v in {0, 1}; recid 2 and 3 mean r overflowed the + # curve order, which dstack signers never produce. Say so plainly rather + # than letting eth_keys fail with a validation error about `vrs`. + raise ValueError(f"unsupported recovery id {recid}: only 0 and 1 are supported") + try: + recovered = keys.Signature( + signature_bytes=signature + ).recover_public_key_from_msg_hash(keccak(message)) + except Exception as exc: # noqa: BLE001 - re-raised as a caller-facing error + raise ValueError(f"failed to recover public key: {exc}") from exc + return recovered.to_compressed_bytes() + + +def verify_signature_chain( + algorithm: str, + data: bytes, + public_key: bytes, + signature_chain: Sequence[bytes], + app_id: bytes, + kms_root_pubkey: bytes, + purpose: str = SIGN_PURPOSE, +) -> bytes: + """Verify a ``Sign`` signature chain end to end. + + Three links, all of which must hold: + + 1. ``signature_chain[0]`` is a signature over ``data`` by ``public_key``. + 2. ``signature_chain[1]`` is the app root key attesting + ``"{purpose}:{hex(public_key)}"``. + 3. ``signature_chain[2]`` is ``kms_root_pubkey`` attesting that app root key + for ``app_id``. + + Link 3 is the one that matters. Without comparing against a KMS root key you + independently trust, a chain is just three signatures an attacker could have + produced with their own keys. Get that key from the ``DstackKms`` contract + (``kmsInfo().k256Pubkey``) or pin it; reading it from the KMS you are + verifying against proves nothing. + + ``app_id`` must likewise be the app id you *expect*, not merely whatever + ``AppInfo`` echoed back -- that comes from the CVM being checked. Comparing a + chain against an app id the same CVM supplied proves only that it is + self-consistent. + + Returns the app root public key (compressed SEC1, 33 bytes) on success, and + raises on any failure. + """ + if len(signature_chain) != 3: + raise ValueError( + f"signature chain must have 3 elements, but received {len(signature_chain)}" + ) + if len(app_id) != 20: + raise ValueError(f"app_id must be 20 bytes, but received {len(app_id)}") + + # Link 1: the payload signature. chain[0] *is* that signature; what matters + # is that it checks out under `public_key`, which links 2 and 3 then cover. + if not verify_signature(algorithm, data, signature_chain[0], public_key): + raise ValueError("payload signature is not valid for the given public key") + + # Link 2: recover the app root key that vouched for the signing key. + message = f"{purpose}:{public_key.hex()}".encode() + app_root_pubkey = _recover_compressed(message, signature_chain[1]) + + # Link 3: recover the KMS root key that vouched for the app root key, and + # check it is the one we were told to trust. + kms_message = _KMS_ISSUED_PREFIX + _SEPARATOR + app_id + app_root_pubkey + recovered_kms = _recover_compressed(kms_message, signature_chain[2]) + + # Normalize the expected key so callers may pass either SEC1 encoding. + try: + expected_kms = _compress(_load_k256_public_key(kms_root_pubkey)) + except ValueError as exc: + raise ValueError(f"invalid KMS root public key: {exc}") from exc + if recovered_kms != expected_kms: + raise ValueError("signature chain is not anchored at the expected KMS root key") + + return app_root_pubkey diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 119561e80..80724a22c 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -19,8 +19,8 @@ from dstack_sdk import GpuInfoResponse from dstack_sdk import SignResponse from dstack_sdk import TappdClient -from dstack_sdk import VerifyResponse from dstack_sdk import VersionResponse +from dstack_sdk import verify_signature from dstack_sdk.dstack_client import InfoResponse from dstack_sdk.dstack_client import TcbInfo @@ -277,51 +277,35 @@ def test_non_unix_socket_endpoints(): SIGN_BAD_DATA = b"This is not the original message" -def test_sync_sign_verify_ed25519(): +def test_sync_sign_then_verify_locally_ed25519(): client = DstackClient() algo = "ed25519" sign_resp = client.sign(algo, SIGN_TEST_DATA) assert isinstance(sign_resp, SignResponse) assert len(sign_resp.decode_signature()) > 0 assert len(sign_resp.decode_public_key()) > 0 - assert len(sign_resp.signature_chain) > 0 + assert len(sign_resp.signature_chain) == 3 - verify_resp = client.verify( - algo, - SIGN_TEST_DATA, - sign_resp.decode_signature(), - sign_resp.decode_public_key(), - ) - assert isinstance(verify_resp, VerifyResponse) - assert verify_resp.valid is True - - verify_bad = client.verify( - algo, SIGN_BAD_DATA, sign_resp.decode_signature(), sign_resp.decode_public_key() - ) - assert verify_bad.valid is False + signature = sign_resp.decode_signature() + public_key = sign_resp.decode_public_key() + assert verify_signature(algo, SIGN_TEST_DATA, signature, public_key) is True + assert verify_signature(algo, SIGN_BAD_DATA, signature, public_key) is False -def test_sync_sign_verify_secp256k1(): +def test_sync_sign_then_verify_locally_secp256k1(): client = DstackClient() algo = "secp256k1" sign_resp = client.sign(algo, SIGN_TEST_DATA) assert isinstance(sign_resp, SignResponse) + assert len(sign_resp.signature_chain) == 3 - verify_resp = client.verify( - algo, - SIGN_TEST_DATA, - sign_resp.decode_signature(), - sign_resp.decode_public_key(), - ) - assert verify_resp.valid is True - - verify_bad = client.verify( - algo, SIGN_BAD_DATA, sign_resp.decode_signature(), sign_resp.decode_public_key() - ) - assert verify_bad.valid is False + signature = sign_resp.decode_signature() + public_key = sign_resp.decode_public_key() + assert verify_signature(algo, SIGN_TEST_DATA, signature, public_key) is True + assert verify_signature(algo, SIGN_BAD_DATA, signature, public_key) is False -def test_sync_sign_verify_secp256k1_prehashed(): +def test_sync_sign_then_verify_locally_secp256k1_prehashed(): client = DstackClient() algo = "secp256k1_prehashed" digest = hashlib.sha256(SIGN_TEST_DATA).digest() @@ -329,17 +313,14 @@ def test_sync_sign_verify_secp256k1_prehashed(): sign_resp = client.sign(algo, digest) assert isinstance(sign_resp, SignResponse) + assert len(sign_resp.signature_chain) == 3 - verify_resp = client.verify( - algo, digest, sign_resp.decode_signature(), sign_resp.decode_public_key() - ) - assert verify_resp.valid is True + signature = sign_resp.decode_signature() + public_key = sign_resp.decode_public_key() + assert verify_signature(algo, digest, signature, public_key) is True bad_digest = hashlib.sha256(SIGN_BAD_DATA).digest() - verify_bad = client.verify( - algo, bad_digest, sign_resp.decode_signature(), sign_resp.decode_public_key() - ) - assert verify_bad.valid is False + assert verify_signature(algo, bad_digest, signature, public_key) is False def test_sync_sign_prehashed_length_error(): @@ -351,7 +332,7 @@ def test_sync_sign_prehashed_length_error(): @pytest.mark.asyncio -async def test_async_sign_verify_ed25519(): +async def test_async_sign_then_verify_locally_ed25519(): client = AsyncDstackClient() algo = "ed25519" sign_resp = await client.sign(algo, SIGN_TEST_DATA) @@ -359,43 +340,27 @@ async def test_async_sign_verify_ed25519(): assert len(sign_resp.decode_signature()) > 0 assert len(sign_resp.decode_public_key()) > 0 - verify_resp = await client.verify( - algo, - SIGN_TEST_DATA, - sign_resp.decode_signature(), - sign_resp.decode_public_key(), - ) - assert verify_resp.valid is True - - verify_bad = await client.verify( - algo, SIGN_BAD_DATA, sign_resp.decode_signature(), sign_resp.decode_public_key() - ) - assert verify_bad.valid is False + signature = sign_resp.decode_signature() + public_key = sign_resp.decode_public_key() + assert verify_signature(algo, SIGN_TEST_DATA, signature, public_key) is True + assert verify_signature(algo, SIGN_BAD_DATA, signature, public_key) is False @pytest.mark.asyncio -async def test_async_sign_verify_secp256k1(): +async def test_async_sign_then_verify_locally_secp256k1(): client = AsyncDstackClient() algo = "secp256k1" sign_resp = await client.sign(algo, SIGN_TEST_DATA) assert isinstance(sign_resp, SignResponse) - verify_resp = await client.verify( - algo, - SIGN_TEST_DATA, - sign_resp.decode_signature(), - sign_resp.decode_public_key(), - ) - assert verify_resp.valid is True - - verify_bad = await client.verify( - algo, SIGN_BAD_DATA, sign_resp.decode_signature(), sign_resp.decode_public_key() - ) - assert verify_bad.valid is False + signature = sign_resp.decode_signature() + public_key = sign_resp.decode_public_key() + assert verify_signature(algo, SIGN_TEST_DATA, signature, public_key) is True + assert verify_signature(algo, SIGN_BAD_DATA, signature, public_key) is False @pytest.mark.asyncio -async def test_async_sign_verify_secp256k1_prehashed(): +async def test_async_sign_then_verify_locally_secp256k1_prehashed(): client = AsyncDstackClient() algo = "secp256k1_prehashed" digest = hashlib.sha256(SIGN_TEST_DATA).digest() @@ -403,16 +368,12 @@ async def test_async_sign_verify_secp256k1_prehashed(): sign_resp = await client.sign(algo, digest) assert isinstance(sign_resp, SignResponse) - verify_resp = await client.verify( - algo, digest, sign_resp.decode_signature(), sign_resp.decode_public_key() - ) - assert verify_resp.valid is True + signature = sign_resp.decode_signature() + public_key = sign_resp.decode_public_key() + assert verify_signature(algo, digest, signature, public_key) is True bad_digest = hashlib.sha256(SIGN_BAD_DATA).digest() - verify_bad = await client.verify( - algo, bad_digest, sign_resp.decode_signature(), sign_resp.decode_public_key() - ) - assert verify_bad.valid is False + assert verify_signature(algo, bad_digest, signature, public_key) is False @pytest.mark.asyncio diff --git a/sdk/python/tests/test_verify.py b/sdk/python/tests/test_verify.py new file mode 100644 index 000000000..76f93d993 --- /dev/null +++ b/sdk/python/tests/test_verify.py @@ -0,0 +1,246 @@ +# SPDX-FileCopyrightText: © 2026 Phala Network +# +# SPDX-License-Identifier: Apache-2.0 + +"""Drives the shared cross-SDK vectors in ``sdk/tests/vectors/signature_chain.json``. + +The Rust, Go and JavaScript suites assert against the same file, so any port that +disagrees about the byte format fails here too. +""" + +import json +from pathlib import Path + +import pytest + +from dstack_sdk import verify_signature +from dstack_sdk import verify_signature_chain +from dstack_sdk.verify import SIGN_PURPOSE + +VECTORS_PATH = ( + Path(__file__).resolve().parents[2] / "tests" / "vectors" / "signature_chain.json" +) + + +def _vectors() -> dict: + return json.loads(VECTORS_PATH.read_text()) + + +VECTORS = _vectors() +CASES = VECTORS["cases"] +INVALID_CASES = VECTORS["invalid_cases"] +APP_ID = bytes.fromhex(VECTORS["app_id"]) +KMS_ROOT = bytes.fromhex(VECTORS["kms_root_pubkey"]) +WRONG_KMS_ROOT = bytes.fromhex(VECTORS["wrong_kms_root_pubkey"]) +APP_ROOT = bytes.fromhex(VECTORS["app_root_pubkey"]) + + +def _case(algorithm: str) -> dict: + return next(c for c in CASES if c["algorithm"] == algorithm) + + +def _chain(case: dict) -> list[bytes]: + return [bytes.fromhex(sig) for sig in case["signature_chain"]] + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c["algorithm"]) +def test_valid_signatures_verify(case): + assert ( + verify_signature( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes.fromhex(case["signature"]), + bytes.fromhex(case["public_key"]), + ) + is True + ) + + +@pytest.mark.parametrize("case", INVALID_CASES, ids=lambda c: c["name"]) +def test_invalid_signatures_are_rejected(case): + args = ( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes.fromhex(case["signature"]), + bytes.fromhex(case["public_key"]), + ) + if case["name"] == "secp256k1_high_s": + # High-S is refused outright rather than reported false, because it is a + # malformed encoding rather than a legitimate signature that fails to match. + with pytest.raises(ValueError, match="high-S"): + verify_signature(*args) + else: + assert verify_signature(*args) is False + + +def test_k256_is_an_alias_for_secp256k1(): + case = _case("secp256k1") + assert ( + verify_signature( + "k256", + bytes.fromhex(case["data"]), + bytes.fromhex(case["signature"]), + bytes.fromhex(case["public_key"]), + ) + is True + ) + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c["algorithm"]) +def test_full_chain_verifies_to_the_kms_root(case): + app_root = verify_signature_chain( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes.fromhex(case["public_key"]), + _chain(case), + APP_ID, + KMS_ROOT, + ) + assert app_root == APP_ROOT + assert len(app_root) == 33 + + +def test_chain_accepts_an_uncompressed_kms_root(): + """Callers may pass either SEC1 encoding of the key they pinned.""" + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives.serialization import Encoding + from cryptography.hazmat.primitives.serialization import PublicFormat + + uncompressed = ec.EllipticCurvePublicKey.from_encoded_point( + ec.SECP256K1(), KMS_ROOT + ).public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) + assert len(uncompressed) == 65 + + case = CASES[0] + assert ( + verify_signature_chain( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes.fromhex(case["public_key"]), + _chain(case), + APP_ID, + uncompressed, + ) + == APP_ROOT + ) + + +def test_chain_anchored_at_a_foreign_kms_root_is_rejected(): + case = CASES[0] + with pytest.raises(ValueError, match="not anchored"): + verify_signature_chain( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes.fromhex(case["public_key"]), + _chain(case), + APP_ID, + WRONG_KMS_ROOT, + ) + + +def test_chain_for_a_different_app_id_is_rejected(): + case = CASES[0] + tampered_app_id = bytes([APP_ID[0] ^ 0xFF]) + APP_ID[1:] + with pytest.raises(ValueError): + verify_signature_chain( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes.fromhex(case["public_key"]), + _chain(case), + tampered_app_id, + KMS_ROOT, + ) + + +def test_tampered_payload_breaks_the_chain(): + case = CASES[0] + with pytest.raises(ValueError): + verify_signature_chain( + case["algorithm"], + b"a different payload entirely", + bytes.fromhex(case["public_key"]), + _chain(case), + APP_ID, + KMS_ROOT, + ) + + +def test_tampered_public_key_breaks_the_chain(): + """Swapping the signing key invalidates link 1 and the app-root link's message.""" + case = _case("secp256k1") + public_key = bytearray(bytes.fromhex(case["public_key"])) + public_key[-1] ^= 0xFF + with pytest.raises(ValueError): + verify_signature_chain( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes(public_key), + _chain(case), + APP_ID, + KMS_ROOT, + ) + + +def test_chain_requires_three_links(): + case = CASES[0] + with pytest.raises(ValueError, match="3 elements"): + verify_signature_chain( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes.fromhex(case["public_key"]), + _chain(case)[:2], + APP_ID, + KMS_ROOT, + ) + + +def test_chain_requires_a_20_byte_app_id(): + case = CASES[0] + with pytest.raises(ValueError, match="20 bytes"): + verify_signature_chain( + case["algorithm"], + bytes.fromhex(case["data"]), + bytes.fromhex(case["public_key"]), + _chain(case), + APP_ID[:19], + KMS_ROOT, + ) + + +def test_malformed_inputs_error_rather_than_report_false(): + with pytest.raises(ValueError): + verify_signature("rsa", b"x", bytes(64), bytes(32)) + with pytest.raises(ValueError): + verify_signature("ed25519", b"x", bytes(64), bytes(31)) + with pytest.raises(ValueError): + verify_signature("ed25519", b"x", bytes(63), bytes(32)) + # A prehashed digest must be exactly 32 bytes. + case = _case("secp256k1_prehashed") + with pytest.raises(ValueError, match="32-byte digest"): + verify_signature( + "secp256k1_prehashed", + b"short", + bytes.fromhex(case["signature"]), + bytes.fromhex(case["public_key"]), + ) + # A raw r || s signature is 64 bytes; DER or truncated blobs are caller bugs. + secp = _case("secp256k1") + with pytest.raises(ValueError, match="64 raw bytes"): + verify_signature( + "secp256k1", + bytes.fromhex(secp["data"]), + bytes.fromhex(secp["signature"])[:63], + bytes.fromhex(secp["public_key"]), + ) + with pytest.raises(ValueError, match="public key"): + verify_signature( + "secp256k1", + bytes.fromhex(secp["data"]), + bytes.fromhex(secp["signature"]), + bytes(33), + ) + + +def test_sign_purpose_is_the_agent_side_constant(): + assert SIGN_PURPOSE == "signing" + assert VECTORS["purpose"] == SIGN_PURPOSE From 82698b962dfdf9507aa67b3ff5d82248f5a59459 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:01:15 -0700 Subject: [PATCH 5/8] feat(sdk/go): verify signatures and signature chains locally Mirrors the Rust SDK: VerifySignature replaces the removed Verify() RPC wrapper, and VerifySignatureChain walks the chain back to a caller-supplied KMS root key. Both drive the shared vectors. No new module dependencies -- crypto/ed25519 from the standard library, and the decred secp256k1 package already vendored for the env-encrypt-pubkey verifier, whose keccak256 and recovery helpers are reused rather than duplicated. decred's ECDSA does not enforce low-S, so high-S is refused explicitly via ModNScalar.IsOverHalfOrder, which is exactly k256's predicate. One deliberate divergence is documented in the source: crypto/ed25519 offers no way to ask whether a public key is a canonical point, so a malformed ed25519 key is a false verdict here where Rust raises. Both refuse the signature. --- sdk/go/README.md | 75 +++++++ sdk/go/dstack/client.go | 26 --- sdk/go/dstack/client_test.go | 37 ++-- sdk/go/dstack/verify.go | 263 ++++++++++++++++++++++++ sdk/go/dstack/verify_test.go | 383 +++++++++++++++++++++++++++++++++++ 5 files changed, 746 insertions(+), 38 deletions(-) create mode 100644 sdk/go/dstack/verify.go create mode 100644 sdk/go/dstack/verify_test.go diff --git a/sdk/go/README.md b/sdk/go/README.md index 26f6be562..dd99b248b 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -662,6 +662,81 @@ if err != nil { fmt.Println("Configuration hash:", hash) ``` +### Signature Verification + +Signatures produced by `client.Sign()` are verified **locally**. Verification +needs no key material and no attestation, so it does not belong behind an RPC to +the guest agent: the agent's answer would arrive over the socket unattested, +which is no better than checking the signature yourself. The `Verify` RPC these +functions replace was removed in v0.6.0. + +#### `VerifySignature(algorithm string, data, signature, publicKey []byte) (bool, error)` + +Checks one signature against a public key you already have. `algorithm` is +`ed25519`, `secp256k1` (alias `k256`), or `secp256k1_prehashed`, where `data` is +already a 32-byte digest. secp256k1 public keys are SEC1 (compressed or +uncompressed) and signatures are raw 64-byte `r || s`, not DER. + +Returns `(false, nil)` when the inputs are well-formed but the signature does not +check out, and a non-nil error when they are not well-formed at all (bad key +encoding, wrong signature length, unknown algorithm, non-canonical high-S +signature) — a malformed input is a caller bug, not a verdict. + +```go +signResp, err := client.Sign(ctx, "secp256k1", payload) +if err != nil { + log.Fatal(err) +} + +valid, err := dstack.VerifySignature("secp256k1", payload, signResp.Signature, signResp.PublicKey) +if err != nil { + log.Fatalf("malformed signature input: %v", err) +} +fmt.Println("signature valid:", valid) +``` + +On its own this proves only that whoever holds `signResp.PublicKey` signed the +data. To establish that the signer was a dstack app, verify the chain. + +#### `VerifySignatureChain(input SignatureChainInput) ([]byte, error)` + +Walks the full chain from a `SignResponse` back to a KMS root key **you supply**, +and returns the app root public key (compressed SEC1, 33 bytes). Three links must +all hold: + +1. `SignatureChain[0]` is a signature over `Data` by `PublicKey`. +2. `SignatureChain[1]` is the app root key attesting `"{purpose}:{hex(PublicKey)}"`. +3. `SignatureChain[2]` is `KMSRootPubKey` attesting that app root key for `AppID`. + +Link 3 is the one that matters. Without comparing against a KMS root key you +independently trust, a chain is just three signatures an attacker could have +produced with their own keys. Get the root from the `DstackKms` contract +(`kmsInfo().k256Pubkey`) or pin it. Reading it from the KMS you are verifying +against proves nothing. + +```go +info, err := client.Info(ctx) +if err != nil { + log.Fatal(err) +} +appID, _ := hex.DecodeString(strings.TrimPrefix(info.AppID, "0x")) +kmsRoot, _ := hex.DecodeString("03...") // pinned, or read from the DstackKms contract + +appRootPubKey, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: "secp256k1", + Data: payload, + PublicKey: signResp.PublicKey, + SignatureChain: signResp.SignatureChain, + AppID: appID, + KMSRootPubKey: kmsRoot, + // Purpose defaults to dstack.SignPurpose ("signing"), which is what Sign uses. +}) +if err != nil { + log.Fatalf("signature chain rejected: %v", err) +} +fmt.Printf("app root key: %x\n", appRootPubKey) +``` + ### KMS Public Key Verification Verify the authenticity of encryption public keys provided by KMS APIs: diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client.go index c962d6d1d..8985cae34 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client.go @@ -634,32 +634,6 @@ func (c *DstackClient) Sign(ctx context.Context, algorithm string, data []byte) }, nil } -type VerifyResponse struct { - Valid bool `json:"valid"` -} - -// Verifies a payload. -func (c *DstackClient) Verify(ctx context.Context, algorithm string, data []byte, signature []byte, publicKey []byte) (*VerifyResponse, error) { - payload := map[string]interface{}{ - "algorithm": algorithm, - "data": hex.EncodeToString(data), - "signature": hex.EncodeToString(signature), - "public_key": hex.EncodeToString(publicKey), - } - - respData, err := c.sendRPCRequest(ctx, "/Verify", payload) - if err != nil { - return nil, err - } - - var response VerifyResponse - if err := json.Unmarshal(respData, &response); err != nil { - return nil, fmt.Errorf("failed to unmarshal verify response: %w", err) - } - - 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) diff --git a/sdk/go/dstack/client_test.go b/sdk/go/dstack/client_test.go index 0a396d848..0ca8d1185 100644 --- a/sdk/go/dstack/client_test.go +++ b/sdk/go/dstack/client_test.go @@ -454,22 +454,24 @@ func TestSignAndVerifyEd25519(t *testing.T) { t.Error("expected Signature to be the same as SignatureChain[0]") } - verifyResp, err := client.Verify(context.Background(), algorithm, dataToSign, signResp.Signature, signResp.PublicKey) + // Verification is local: it needs no key material, so the SDK checks the + // signature itself rather than asking the agent for an unattested verdict. + valid, err := dstack.VerifySignature(algorithm, dataToSign, signResp.Signature, signResp.PublicKey) if err != nil { - t.Fatalf("Verify() error = %v", err) + t.Fatalf("VerifySignature() error = %v", err) } - if !verifyResp.Valid { + if !valid { t.Error("expected verification to be valid") } badData := []byte("wrong message") - verifyResp, err = client.Verify(context.Background(), algorithm, badData, signResp.Signature, signResp.PublicKey) + valid, err = dstack.VerifySignature(algorithm, badData, signResp.Signature, signResp.PublicKey) if err != nil { - t.Fatalf("Verify() with bad data error = %v", err) + t.Fatalf("VerifySignature() with bad data error = %v", err) } - if verifyResp.Valid { + if valid { t.Error("expected verification with bad data to be invalid") } } @@ -494,14 +496,20 @@ func TestSignAndVerifySecp256k1(t *testing.T) { t.Errorf("expected signature chain to have 3 elements, got %d", len(signResp.SignatureChain)) } - verifyResp, err := client.Verify(context.Background(), algorithm, dataToSign, signResp.Signature, signResp.PublicKey) + valid, err := dstack.VerifySignature(algorithm, dataToSign, signResp.Signature, signResp.PublicKey) if err != nil { - t.Fatalf("Verify() error = %v", err) + t.Fatalf("VerifySignature() error = %v", err) } - if !verifyResp.Valid { + if !valid { t.Error("expected verification to be valid") } + + // The chain is what actually ties the signing key back to a KMS root; a bare + // signature only proves whoever holds signResp.PublicKey signed the payload. + if !bytes.Equal(signResp.Signature, signResp.SignatureChain[0]) { + t.Error("expected Signature to be the same as SignatureChain[0]") + } } func TestSignAndVerifySecp256k1Prehashed(t *testing.T) { @@ -519,15 +527,20 @@ func TestSignAndVerifySecp256k1Prehashed(t *testing.T) { t.Error("expected signature to not be empty") } - verifyResp, err := client.Verify(context.Background(), algorithm, digest[:], signResp.Signature, signResp.PublicKey) + valid, err := dstack.VerifySignature(algorithm, digest[:], signResp.Signature, signResp.PublicKey) if err != nil { - t.Fatalf("Verify() error = %v", err) + t.Fatalf("VerifySignature() error = %v", err) } - if !verifyResp.Valid { + if !valid { t.Error("expected verification to be valid") } + // A pre-hashed digest must be exactly 32 bytes on the verifying side too. + if _, err := dstack.VerifySignature(algorithm, dataToSign, signResp.Signature, signResp.PublicKey); err == nil { + t.Error("expected VerifySignature to reject a non-digest payload for secp256k1_prehashed") + } + // Test invalid digest length for signing invalidDigest := []byte{1, 2, 3} _, err = client.Sign(context.Background(), algorithm, invalidDigest) diff --git a/sdk/go/dstack/verify.go b/sdk/go/dstack/verify.go new file mode 100644 index 000000000..cacd1c315 --- /dev/null +++ b/sdk/go/dstack/verify.go @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// Local signature and signature-chain verification. +// +// Verification needs no key material and no attestation, so it does not belong +// behind an RPC to the guest agent: the agent's answer arrives over the socket +// unattested, which is no better than a caller checking the signature itself. +// The `Verify` RPC these functions replace was removed in v0.6.0. +// +// Two levels are available: +// +// - VerifySignature checks one signature against a public key you already +// have. It is the direct replacement for the old RPC and, on its own, proves +// only that whoever holds that key signed the data. +// - VerifySignatureChain walks the full chain from a SignResponse back to a +// KMS root key **you supply**, which is what actually establishes that the +// signer was a dstack app under that KMS. + +package dstack + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "fmt" + + secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + secp256k1ecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" +) + +// kmsIssuedPrefix is the domain-separation prefix the KMS signs app root keys under. +const kmsIssuedPrefix = "dstack-kms-issued" + +// Sign derives its key at this path with this purpose; both are fixed agent-side. +const ( + SignPath = "vms" + SignPurpose = "signing" +) + +// normalizeAlgorithm maps `k256` onto `secp256k1`; they name the same thing and +// the agent normalized these too. +func normalizeAlgorithm(algorithm string) string { + if algorithm == "k256" { + return "secp256k1" + } + return algorithm +} + +// parseK256Signature decodes a raw 64-byte `r ‖ s` signature. +func parseK256Signature(signature []byte) (*secp256k1ecdsa.Signature, error) { + if len(signature) != 64 { + return nil, fmt.Errorf("secp256k1 signature must be 64 bytes, but received %d", len(signature)) + } + + var r, s secp256k1.ModNScalar + if overflow := r.SetByteSlice(signature[:32]); overflow { + return nil, fmt.Errorf("invalid secp256k1 signature: r is not in the group order") + } + if overflow := s.SetByteSlice(signature[32:64]); overflow { + return nil, fmt.Errorf("invalid secp256k1 signature: s is not in the group order") + } + if r.IsZero() || s.IsZero() { + return nil, fmt.Errorf("invalid secp256k1 signature: r and s must both be non-zero") + } + // ECDSA is malleable: (r, n-s) verifies wherever (r, s) does. The Rust SDK's + // k256 backend rejects the high-S form, so we must too -- otherwise a + // signature stops being a unique identifier for a signed message, and this + // SDK would disagree with every other dstack component about whether a given + // blob is valid. The decred library accepts high-S, so the check is ours. + if s.IsOverHalfOrder() { + return nil, fmt.Errorf("non-canonical (high-S) secp256k1 signature") + } + return secp256k1ecdsa.NewSignature(&r, &s), nil +} + +// VerifySignature verifies one signature against publicKey. +// +// algorithm is `ed25519`, `secp256k1` (alias `k256`), or `secp256k1_prehashed`, +// where data is already a 32-byte digest. Returns (false, nil) when the inputs +// are well-formed but the signature does not check out, and a non-nil error when +// they are not well-formed at all (bad key encoding, wrong signature length, +// unknown algorithm) -- a malformed input is a caller bug, not a verdict. +func VerifySignature(algorithm string, data []byte, signature []byte, publicKey []byte) (bool, error) { + switch normalizeAlgorithm(algorithm) { + case "ed25519": + if len(publicKey) != ed25519.PublicKeySize { + return false, fmt.Errorf("ed25519 public key must be %d bytes, but received %d", + ed25519.PublicKeySize, len(publicKey)) + } + if len(signature) != ed25519.SignatureSize { + return false, fmt.Errorf("ed25519 signature must be %d bytes, but received %d", + ed25519.SignatureSize, len(signature)) + } + // Divergence from the Rust SDK, deliberate and harmless: ed25519_dalek + // rejects a non-canonical or low-order point encoding as a malformed + // key, where crypto/ed25519 offers no way to ask and simply reports + // such a key as a failed verification. Both refuse the signature; only + // the error-versus-verdict shape differs, and aligning it would mean + // taking on an extra dependency just to decode the point. + return ed25519.Verify(ed25519.PublicKey(publicKey), data, signature), nil + + case "secp256k1": + pubKey, err := secp256k1.ParsePubKey(publicKey) + if err != nil { + return false, fmt.Errorf("invalid secp256k1 public key: %w", err) + } + sig, err := parseK256Signature(signature) + if err != nil { + return false, err + } + // The agent signs with SHA-256, so verification must hash the same way. + digest := sha256.Sum256(data) + return sig.Verify(digest[:], pubKey), nil + + case "secp256k1_prehashed": + if len(data) != 32 { + return false, fmt.Errorf( + "pre-hashed verification requires a 32-byte digest, but received %d bytes", len(data)) + } + pubKey, err := secp256k1.ParsePubKey(publicKey) + if err != nil { + return false, fmt.Errorf("invalid secp256k1 public key: %w", err) + } + sig, err := parseK256Signature(signature) + if err != nil { + return false, err + } + return sig.Verify(data, pubKey), nil + + default: + return false, fmt.Errorf("unsupported algorithm: %s", algorithm) + } +} + +// recoverCompressed recovers the compressed public key that produced a 65-byte +// `r ‖ s ‖ recid` signature over keccak256(message). +func recoverCompressed(message []byte, signature []byte) ([]byte, error) { + if len(signature) != 65 { + return nil, fmt.Errorf("recoverable signature must be 65 bytes, but received %d", len(signature)) + } + // Applies the same canonicality rules as a plain signature, including high-S. + if _, err := parseK256Signature(signature[:64]); err != nil { + return nil, err + } + if signature[64] > 3 { + return nil, fmt.Errorf("invalid recovery id %d", signature[64]) + } + + recovered, err := recoverCompressedPublicKey(message, signature) + if err != nil { + return nil, fmt.Errorf("failed to recover public key: %w", err) + } + if recovered == nil { + return nil, fmt.Errorf("failed to recover public key") + } + // recoverCompressedPublicKey hands back `0x`-prefixed hex; the chain compares raw bytes. + raw, err := hex.DecodeString(string(recovered[2:])) + if err != nil { + return nil, fmt.Errorf("failed to decode the recovered public key: %w", err) + } + return raw, nil +} + +// SignatureChainInput carries the inputs to VerifySignatureChain. +// +// A struct rather than a positional argument list so that adding an input later +// does not break callers. +type SignatureChainInput struct { + // Algorithm the payload was signed with. + Algorithm string + // Data is the signed payload; a 32-byte digest for `secp256k1_prehashed`. + Data []byte + // PublicKey is SignResponse.PublicKey -- the key that signed Data. + PublicKey []byte + // SignatureChain is SignResponse.SignatureChain, exactly 3 elements. + SignatureChain [][]byte + // AppID is the 20-byte app identity to hold the chain to. + // + // This must be the app id you *expect*, not merely whatever InfoResponse + // echoed back -- that comes from the CVM being checked. Comparing a chain + // against an app id the same CVM supplied proves only that it is + // self-consistent. + AppID []byte + // KMSRootPubKey is the KMS root public key you already trust, compressed or + // uncompressed SEC1. + // + // Get it from the DstackKms contract (`kmsInfo().k256Pubkey`) or pin it. + // Reading it from the KMS you are verifying against proves nothing. + KMSRootPubKey []byte + // Purpose bound into the app-root link. Defaults to SignPurpose when empty, + // which is what the Sign RPC always uses. + Purpose string +} + +// VerifySignatureChain verifies a Sign signature chain end to end and returns +// the app root public key (compressed SEC1, 33 bytes). +// +// Three links, all of which must hold: +// +// 1. SignatureChain[0] is a signature over Data by PublicKey. +// 2. SignatureChain[1] is the app root key attesting "{purpose}:{hex(PublicKey)}". +// 3. SignatureChain[2] is KMSRootPubKey attesting that app root key for AppID. +// +// Link 3 is the one that matters. Without comparing against a KMS root key you +// independently trust, a chain is just three signatures an attacker could have +// produced with their own keys. +func VerifySignatureChain(input SignatureChainInput) ([]byte, error) { + if len(input.SignatureChain) != 3 { + return nil, fmt.Errorf("signature chain must have 3 elements, but received %d", + len(input.SignatureChain)) + } + if len(input.AppID) != 20 { + return nil, fmt.Errorf("app_id must be 20 bytes, but received %d", len(input.AppID)) + } + + purpose := input.Purpose + if purpose == "" { + purpose = SignPurpose + } + + // Link 1: the payload signature. SignatureChain[0] *is* that signature; what + // matters is that it checks out under PublicKey, which links 2 and 3 cover. + valid, err := VerifySignature(input.Algorithm, input.Data, input.SignatureChain[0], input.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to check the payload signature: %w", err) + } + if !valid { + return nil, fmt.Errorf("payload signature is not valid for the given public key") + } + + // Link 2: recover the app root key that vouched for the signing key. + message := fmt.Sprintf("%s:%s", purpose, hex.EncodeToString(input.PublicKey)) + appRootPubKey, err := recoverCompressed([]byte(message), input.SignatureChain[1]) + if err != nil { + return nil, fmt.Errorf("failed to recover the app root key: %w", err) + } + + // Link 3: recover the KMS root key that vouched for the app root key, and + // check it is the one we were told to trust. + kmsMessage := make([]byte, 0, len(kmsIssuedPrefix)+1+len(input.AppID)+len(appRootPubKey)) + kmsMessage = append(kmsMessage, kmsIssuedPrefix...) + kmsMessage = append(kmsMessage, ':') + kmsMessage = append(kmsMessage, input.AppID...) + kmsMessage = append(kmsMessage, appRootPubKey...) + recoveredKMS, err := recoverCompressed(kmsMessage, input.SignatureChain[2]) + if err != nil { + return nil, fmt.Errorf("failed to recover the KMS root key: %w", err) + } + + // Normalize the expected key so callers may pass either SEC1 encoding. + expectedKMS, err := secp256k1.ParsePubKey(input.KMSRootPubKey) + if err != nil { + return nil, fmt.Errorf("invalid KMS root public key: %w", err) + } + if !bytes.Equal(recoveredKMS, expectedKMS.SerializeCompressed()) { + return nil, fmt.Errorf("signature chain is not anchored at the expected KMS root key") + } + + return appRootPubKey, nil +} diff --git a/sdk/go/dstack/verify_test.go b/sdk/go/dstack/verify_test.go new file mode 100644 index 000000000..b54323696 --- /dev/null +++ b/sdk/go/dstack/verify_test.go @@ -0,0 +1,383 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// Drives the shared cross-SDK vectors in `sdk/tests/vectors/signature_chain.json`. +// The Rust, Python and JavaScript suites assert against the same file, so any port +// that disagrees about the byte format fails here too. + +package dstack_test + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" + "strings" + "testing" + + secp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + + "github.com/Dstack-TEE/dstack/sdk/go/dstack" +) + +const vectorsPath = "../../tests/vectors/signature_chain.json" + +type vectorCase struct { + Algorithm string `json:"algorithm"` + Data string `json:"data"` + PublicKey string `json:"public_key"` + Signature string `json:"signature"` + SignatureChain []string `json:"signature_chain"` + Name string `json:"name"` + Reason string `json:"reason"` +} + +type vectorFile struct { + AppID string `json:"app_id"` + Purpose string `json:"purpose"` + Path string `json:"path"` + KMSRootPubKey string `json:"kms_root_pubkey"` + AppRootPubKey string `json:"app_root_pubkey"` + WrongKMSRootPubKey string `json:"wrong_kms_root_pubkey"` + Cases []vectorCase `json:"cases"` + InvalidCases []vectorCase `json:"invalid_cases"` +} + +func vectors(t *testing.T) vectorFile { + t.Helper() + raw, err := os.ReadFile(vectorsPath) + if err != nil { + t.Fatalf("read vectors: %v", err) + } + var v vectorFile + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("parse vectors: %v", err) + } + if len(v.Cases) == 0 || len(v.InvalidCases) == 0 { + t.Fatalf("vectors file has no cases") + } + return v +} + +func unhex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("invalid hex %q: %v", s, err) + } + return b +} + +func chainOf(t *testing.T, c vectorCase) [][]byte { + t.Helper() + chain := make([][]byte, len(c.SignatureChain)) + for i, s := range c.SignatureChain { + chain[i] = unhex(t, s) + } + return chain +} + +func caseWithAlgorithm(t *testing.T, v vectorFile, algorithm string) vectorCase { + t.Helper() + for _, c := range v.Cases { + if c.Algorithm == algorithm { + return c + } + } + t.Fatalf("no vector case for algorithm %q", algorithm) + return vectorCase{} +} + +func TestVerifySignatureValidVectors(t *testing.T) { + v := vectors(t) + for _, c := range v.Cases { + valid, err := dstack.VerifySignature( + c.Algorithm, unhex(t, c.Data), unhex(t, c.Signature), unhex(t, c.PublicKey)) + if err != nil { + t.Fatalf("%s: %v", c.Algorithm, err) + } + if !valid { + t.Errorf("%s: valid signature was rejected", c.Algorithm) + } + } +} + +func TestVerifySignatureInvalidVectors(t *testing.T) { + v := vectors(t) + for _, c := range v.InvalidCases { + valid, err := dstack.VerifySignature( + c.Algorithm, unhex(t, c.Data), unhex(t, c.Signature), unhex(t, c.PublicKey)) + // High-S is refused outright rather than reported false, because it is a + // malformed encoding rather than a legitimate signature that fails to match. + if err != nil { + if c.Name != "secp256k1_high_s" { + t.Errorf("%s: unexpected error %v", c.Name, err) + } + continue + } + if valid { + t.Errorf("%s: should not have verified (%s)", c.Name, c.Reason) + } + } +} + +func TestVerifySignatureHighSIsRejected(t *testing.T) { + v := vectors(t) + var found bool + for _, c := range v.InvalidCases { + if c.Name != "secp256k1_high_s" { + continue + } + found = true + valid, err := dstack.VerifySignature( + c.Algorithm, unhex(t, c.Data), unhex(t, c.Signature), unhex(t, c.PublicKey)) + if err == nil { + t.Fatalf("high-S signature was accepted as an encoding (valid=%v)", valid) + } + if !strings.Contains(err.Error(), "high-S") { + t.Errorf("unexpected error for high-S: %v", err) + } + } + if !found { + t.Fatal("vectors file no longer pins the secp256k1_high_s case") + } +} + +func TestVerifySignatureK256IsAnAliasForSecp256k1(t *testing.T) { + v := vectors(t) + c := caseWithAlgorithm(t, v, "secp256k1") + valid, err := dstack.VerifySignature("k256", unhex(t, c.Data), unhex(t, c.Signature), unhex(t, c.PublicKey)) + if err != nil { + t.Fatalf("k256 alias: %v", err) + } + if !valid { + t.Error("k256 alias did not verify a valid secp256k1 signature") + } +} + +func TestVerifySignatureMalformedInputsErrorRatherThanReportFalse(t *testing.T) { + if _, err := dstack.VerifySignature("rsa", []byte("x"), make([]byte, 64), make([]byte, 32)); err == nil { + t.Error("expected an error for an unknown algorithm") + } + if _, err := dstack.VerifySignature("ed25519", []byte("x"), make([]byte, 64), make([]byte, 31)); err == nil { + t.Error("expected an error for a 31-byte ed25519 public key") + } + if _, err := dstack.VerifySignature("ed25519", []byte("x"), make([]byte, 63), make([]byte, 32)); err == nil { + t.Error("expected an error for a 63-byte ed25519 signature") + } + + v := vectors(t) + c := caseWithAlgorithm(t, v, "secp256k1_prehashed") + // A prehashed digest must be exactly 32 bytes. + if _, err := dstack.VerifySignature( + "secp256k1_prehashed", []byte("short"), unhex(t, c.Signature), unhex(t, c.PublicKey)); err == nil { + t.Error("expected an error for a prehashed digest that is not 32 bytes") + } + // A secp256k1 signature must be raw 64-byte r||s, not DER. + if _, err := dstack.VerifySignature( + "secp256k1", []byte("x"), make([]byte, 70), unhex(t, c.PublicKey)); err == nil { + t.Error("expected an error for a 70-byte secp256k1 signature") + } + // The public key must be SEC1. + if _, err := dstack.VerifySignature( + "secp256k1", []byte("x"), unhex(t, c.Signature), make([]byte, 33)); err == nil { + t.Error("expected an error for a malformed secp256k1 public key") + } +} + +func TestVerifySignatureAcceptsUncompressedSecp256k1Keys(t *testing.T) { + v := vectors(t) + c := caseWithAlgorithm(t, v, "secp256k1") + compressed := unhex(t, c.PublicKey) + if len(compressed) != 33 { + t.Fatalf("expected a 33-byte compressed key in the vectors, got %d", len(compressed)) + } + parsed, err := secp256k1.ParsePubKey(compressed) + if err != nil { + t.Fatalf("parse compressed key: %v", err) + } + uncompressed := parsed.SerializeUncompressed() + valid, err := dstack.VerifySignature(c.Algorithm, unhex(t, c.Data), unhex(t, c.Signature), uncompressed) + if err != nil { + t.Fatalf("uncompressed key: %v", err) + } + if !valid { + t.Error("a valid signature was rejected under the uncompressed SEC1 key") + } +} + +func TestVerifySignatureChainVerifiesToTheKMSRoot(t *testing.T) { + v := vectors(t) + appID := unhex(t, v.AppID) + kmsRoot := unhex(t, v.KMSRootPubKey) + expectedAppRoot := unhex(t, v.AppRootPubKey) + + for _, c := range v.Cases { + appRoot, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chainOf(t, c), + AppID: appID, + KMSRootPubKey: kmsRoot, + }) + if err != nil { + t.Fatalf("%s: %v", c.Algorithm, err) + } + if !bytes.Equal(appRoot, expectedAppRoot) { + t.Errorf("%s: recovered the wrong app root key: got %x, want %x", + c.Algorithm, appRoot, expectedAppRoot) + } + } +} + +func TestVerifySignatureChainExplicitPurposeMatchesTheDefault(t *testing.T) { + v := vectors(t) + c := v.Cases[0] + appRoot, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chainOf(t, c), + AppID: unhex(t, v.AppID), + KMSRootPubKey: unhex(t, v.KMSRootPubKey), + Purpose: v.Purpose, + }) + if err != nil { + t.Fatalf("explicit purpose %q: %v", v.Purpose, err) + } + if !bytes.Equal(appRoot, unhex(t, v.AppRootPubKey)) { + t.Error("explicit purpose recovered a different app root key than the default") + } + + // A different purpose recovers some other key, which the KMS never signed. + if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chainOf(t, c), + AppID: unhex(t, v.AppID), + KMSRootPubKey: unhex(t, v.KMSRootPubKey), + Purpose: "encryption", + }); err == nil { + t.Error("expected a chain bound to a different purpose to be rejected") + } +} + +func TestVerifySignatureChainAnchoredAtAForeignKMSRootIsRejected(t *testing.T) { + v := vectors(t) + c := v.Cases[0] + _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chainOf(t, c), + AppID: unhex(t, v.AppID), + KMSRootPubKey: unhex(t, v.WrongKMSRootPubKey), + }) + if err == nil { + t.Fatal("a chain not anchored at our KMS root must be rejected") + } + if !strings.Contains(err.Error(), "not anchored") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestVerifySignatureChainForADifferentAppIDIsRejected(t *testing.T) { + v := vectors(t) + c := v.Cases[0] + appID := unhex(t, v.AppID) + appID[0] ^= 0xff + + if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chainOf(t, c), + AppID: appID, + KMSRootPubKey: unhex(t, v.KMSRootPubKey), + }); err == nil { + t.Fatal("a chain issued for a different app_id must be rejected") + } +} + +func TestVerifySignatureChainTamperedPayloadBreaksTheChain(t *testing.T) { + v := vectors(t) + c := v.Cases[0] + if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: []byte("a different payload entirely"), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chainOf(t, c), + AppID: unhex(t, v.AppID), + KMSRootPubKey: unhex(t, v.KMSRootPubKey), + }); err == nil { + t.Fatal("a chain over a tampered payload must be rejected") + } +} + +func TestVerifySignatureChainMalformedInputs(t *testing.T) { + v := vectors(t) + c := v.Cases[0] + chain := chainOf(t, c) + + if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chain[:2], + AppID: unhex(t, v.AppID), + KMSRootPubKey: unhex(t, v.KMSRootPubKey), + }); err == nil { + t.Error("expected an error for a chain with fewer than 3 elements") + } + + if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chain, + AppID: make([]byte, 19), + KMSRootPubKey: unhex(t, v.KMSRootPubKey), + }); err == nil { + t.Error("expected an error for an app_id that is not 20 bytes") + } + + if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: chain, + AppID: unhex(t, v.AppID), + KMSRootPubKey: make([]byte, 33), + }); err == nil { + t.Error("expected an error for a malformed KMS root public key") + } + + // A recoverable link must be 65 bytes. + shortLink := [][]byte{chain[0], chain[1][:64], chain[2]} + if _, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ + Algorithm: c.Algorithm, + Data: unhex(t, c.Data), + PublicKey: unhex(t, c.PublicKey), + SignatureChain: shortLink, + AppID: unhex(t, v.AppID), + KMSRootPubKey: unhex(t, v.KMSRootPubKey), + }); err == nil { + t.Error("expected an error for a 64-byte recoverable signature") + } +} + +func TestVerifySignPurposeIsTheAgentSideConstant(t *testing.T) { + if dstack.SignPurpose != "signing" { + t.Errorf("SignPurpose = %q, want \"signing\"", dstack.SignPurpose) + } + if dstack.SignPath != "vms" { + t.Errorf("SignPath = %q, want \"vms\"", dstack.SignPath) + } + v := vectors(t) + if v.Purpose != dstack.SignPurpose || v.Path != dstack.SignPath { + t.Errorf("vectors disagree about the agent-side constants: purpose=%q path=%q", v.Purpose, v.Path) + } +} From 2a25809e626acfdaca8cb2891671180dbead9f43 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:01:24 -0700 Subject: [PATCH 6/8] feat(sdk/js): verify signatures and signature chains locally Mirrors the Rust SDK: verifySignature replaces the removed verify() RPC wrapper, and verifySignatureChain walks the chain back to a caller-supplied KMS root key. Both drive the shared vectors. @noble/curves and @noble/hashes move from optional peer dependencies to real dependencies. verify.ts is reachable from the main entry point and needs them unconditionally, and node:crypto is not an option: it cannot verify a pre-hashed ECDSA digest, which secp256k1_prehashed requires. bun.lock is realigned with the new categorization. noble already defaults to lowS: true, which happens to match k256, but the flag is passed explicitly and high-S is detected up front with hasHighS() so a future change to that default cannot silently start accepting malleated signatures the other three SDKs reject. --- sdk/js/README.md | 46 +++- sdk/js/bun.lock | 7 +- sdk/js/package.json | 19 +- sdk/js/src/__tests__/index.test.ts | 37 ++-- sdk/js/src/__tests__/verify.test.ts | 251 ++++++++++++++++++++++ sdk/js/src/index.ts | 37 +--- sdk/js/src/verify.ts | 316 ++++++++++++++++++++++++++++ sdk/js/tsup.config.ts | 1 + 8 files changed, 635 insertions(+), 79 deletions(-) create mode 100644 sdk/js/src/__tests__/verify.test.ts create mode 100644 sdk/js/src/verify.ts diff --git a/sdk/js/README.md b/sdk/js/README.md index 80f04dd68..563cfa317 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -1,23 +1,21 @@ # @phala/dstack-sdk -JavaScript / TypeScript client for the dstack guest agent. Derive deterministic keys, generate TDX attestation quotes, issue TLS certificates, sign / verify payloads, and encrypt environment variables for KMS-managed deployments — all against the guest agent socket inside a confidential VM (CVM). +JavaScript / TypeScript client for the dstack guest agent. Derive deterministic keys, generate TDX attestation quotes, issue TLS certificates, sign payloads, and encrypt environment variables for KMS-managed deployments — all against the guest agent socket inside a confidential VM (CVM). Signature verification runs locally in your process, not over the socket. ## Installation ```bash -npm install @phala/dstack-sdk @noble/hashes +npm install @phala/dstack-sdk ``` -`@noble/hashes` is the only required peer dependency (used by the core for sha256 / sha384). Install the matching peer when you import a submodule: +`@noble/hashes` and `@noble/curves` ship as regular dependencies — the core needs them for hashing and for local signature verification. Install the matching peer when you import a blockchain submodule: | Import path | Extra peer dependency | | --- | --- | | `@phala/dstack-sdk/viem` | `viem` | | `@phala/dstack-sdk/solana` | `@solana/web3.js` | -| `@phala/dstack-sdk/encrypt-env-vars` | `@noble/curves` | -| `@phala/dstack-sdk/verify-env-encrypt-public-key` | `@noble/curves` | -> **Breaking change in 0.5.8.** Prior releases listed `@solana/web3.js`, `viem`, and `@noble/curves` under `optionalDependencies`, so npm installed them automatically. They are now opt-in peers — install them yourself when you use the corresponding submodule. +> **Breaking change in 0.5.8.** Prior releases listed `@solana/web3.js` and `viem` under `optionalDependencies`, so npm installed them automatically. They are now opt-in peers — install them yourself when you use the corresponding submodule. Node 18+ supported. Tested through Node 24. @@ -144,13 +142,39 @@ res.signature_chain // Uint8Array[] — proves the signing key came from this T Algorithms: `ed25519`, `secp256k1`, `secp256k1_prehashed`. Requires guest agent ≥ 0.5.7. -### `verify(algorithm, data, signature, publicKey)` +### `verifySignature(algorithm, data, signature, publicKey)` + +Verification needs no key material and no attestation, so it runs locally rather than through the agent — an agent's answer would arrive over the socket unattested anyway. The `Verify` RPC that used to back `client.verify()` was removed in dstack 0.6.0. ```typescript -const ok = await client.verify('ed25519', 'hello dstack', res.signature, res.public_key) -ok.valid // boolean +import { verifySignature } from '@phala/dstack-sdk' + +const data = new TextEncoder().encode('hello dstack') +verifySignature('ed25519', data, res.signature, res.public_key) // boolean ``` +`data`, `signature` and `publicKey` are `Uint8Array`s. `secp256k1` (alias `k256`) takes a SEC1 public key — compressed or uncompressed — and a raw 64-byte `r || s` signature over SHA-256 of the data; `secp256k1_prehashed` takes the 32-byte digest directly. Malformed input (bad key length, wrong signature length, unknown algorithm, non-canonical high-S signature) throws; a well-formed signature that simply does not match returns `false`. + +### `verifySignatureChain(input)` + +On its own, `verifySignature` only proves that whoever holds that public key signed the data. `verifySignatureChain` walks the whole chain from a `sign()` response back to a KMS root key **you supply**, which is what establishes that the signer was a dstack app under that KMS. + +```typescript +import { verifySignatureChain } from '@phala/dstack-sdk' + +const info = await client.info() +const appRootPubKey = verifySignatureChain({ + algorithm: 'ed25519', + data, + publicKey: res.public_key, + signatureChain: res.signature_chain, + appId: Buffer.from(info.app_id.replace(/^0x/, ''), 'hex'), + kmsRootPubKey, // compressed or uncompressed SEC1, from a source you trust +}) +``` + +Returns the app root public key (compressed SEC1, 33 bytes) or throws. Get `kmsRootPubKey` from the `DstackKms` contract (`kmsInfo().k256Pubkey`) or pin it in your build — reading it from the KMS you are verifying against proves nothing. + ## Diagnostics ### `isReachable()` @@ -253,7 +277,9 @@ Verify functions return the signer's compressed public key (hex) on success, or | Feature | Minimum guest agent | | --- | --- | | `getKey`, `getTlsKey`, `getQuote`, `info` | 0.3.x | -| `attest`, `sign`, `verify`, `version`, ed25519 keys, `info.cloud_vendor` / `cloud_product`, `getTlsKey` `notBefore` / `notAfter` / `withAppInfo` | 0.5.7 | +| `attest`, `sign`, `version`, ed25519 keys, `info.cloud_vendor` / `cloud_product`, `getTlsKey` `notBefore` / `notAfter` / `withAppInfo` | 0.5.7 | + +`verifySignature` and `verifySignatureChain` run locally and have no guest agent requirement. They replace `client.verify()`, whose `Verify` RPC was removed in dstack 0.6.0. The SDK's release versions track guest agent versions — `0.5.8-x` targets dstack 0.5.7+. diff --git a/sdk/js/bun.lock b/sdk/js/bun.lock index 8a9836ad3..8039f9216 100644 --- a/sdk/js/bun.lock +++ b/sdk/js/bun.lock @@ -4,9 +4,11 @@ "workspaces": { "": { "name": "@phala/dstack-sdk", - "devDependencies": { + "dependencies": { "@noble/curves": "^1.8.1", "@noble/hashes": "^1.6.1", + }, + "devDependencies": { "@solana/web3.js": "^1.98.4", "@types/node": "latest", "tsup": "^8.5.1", @@ -15,13 +17,10 @@ "vitest": "^3.2.4", }, "peerDependencies": { - "@noble/curves": "^1.8.1", - "@noble/hashes": "^1.6.1", "@solana/web3.js": "^1.98.4", "viem": "^2.43.3", }, "optionalPeers": [ - "@noble/curves", "@solana/web3.js", "viem", ], diff --git a/sdk/js/package.json b/sdk/js/package.json index cab3f5be6..17d0ec253 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -53,6 +53,14 @@ }, "import": "./dist/verify-env-encrypt-public-key.mjs", "require": "./dist/verify-env-encrypt-public-key.js" + }, + "./verify": { + "types": { + "import": "./dist/verify.d.mts", + "require": "./dist/verify.d.ts" + }, + "import": "./dist/verify.mjs", + "require": "./dist/verify.js" } }, "engines": { @@ -80,9 +88,11 @@ }, "author": "Leechael Yim", "license": "Apache-2.0", - "devDependencies": { + "dependencies": { "@noble/curves": "^1.8.1", - "@noble/hashes": "^1.6.1", + "@noble/hashes": "^1.6.1" + }, + "devDependencies": { "@solana/web3.js": "^1.98.4", "@types/node": "latest", "tsup": "^8.5.1", @@ -91,15 +101,10 @@ "vitest": "^3.2.4" }, "peerDependencies": { - "@noble/curves": "^1.8.1", - "@noble/hashes": "^1.6.1", "@solana/web3.js": "^1.98.4", "viem": "^2.43.3" }, "peerDependenciesMeta": { - "@noble/curves": { - "optional": true - }, "@solana/web3.js": { "optional": true }, diff --git a/sdk/js/src/__tests__/index.test.ts b/sdk/js/src/__tests__/index.test.ts index 15b8d4508..c5cc3fa6d 100644 --- a/sdk/js/src/__tests__/index.test.ts +++ b/sdk/js/src/__tests__/index.test.ts @@ -4,7 +4,7 @@ import { expect, describe, it, vi } from 'vitest' import crypto from 'crypto' // Added for prehashed test -import { DstackClient, TappdClient } from '../index' +import { DstackClient, TappdClient, verifySignature } from '../index' describe('DstackClient', () => { it('should able to derive key in TappdClient', async () => { @@ -183,8 +183,9 @@ describe('DstackClient', () => { const client = new DstackClient() const testData = 'Test message for signing' const badData = 'This is not the original message' + const encode = (text: string) => new TextEncoder().encode(text) - it('should sign and verify with ed25519', async () => { + it('should sign with ed25519 and verify locally', async () => { const algorithm = 'ed25519' const signResp = await client.sign(algorithm, testData) @@ -196,16 +197,14 @@ describe('DstackClient', () => { expect(signResp.signature_chain.length).toBeGreaterThan(0) // Should have at least the signature itself expect(signResp.signature_chain[0]).toBeInstanceOf(Uint8Array) - // Verify success - const verifyResp = await client.verify(algorithm, testData, signResp.signature, signResp.public_key) - expect(verifyResp).toHaveProperty('valid', true) + // Verification is local: it needs no key material, so there is no RPC for it. + expect(verifySignature(algorithm, encode(testData), signResp.signature, signResp.public_key)).toBe(true) // Verify failure (bad data) - const verifyRespBadData = await client.verify(algorithm, badData, signResp.signature, signResp.public_key) - expect(verifyRespBadData).toHaveProperty('valid', false) + expect(verifySignature(algorithm, encode(badData), signResp.signature, signResp.public_key)).toBe(false) }) - it('should sign and verify with secp256k1', async () => { + it('should sign with secp256k1 and verify locally', async () => { const algorithm = 'secp256k1' const signResp = await client.sign(algorithm, testData) @@ -213,18 +212,13 @@ describe('DstackClient', () => { expect(signResp.public_key).toBeInstanceOf(Uint8Array) expect(signResp.signature_chain.length).toBeGreaterThan(0) - // Verify success - const verifyResp = await client.verify(algorithm, testData, signResp.signature, signResp.public_key) - expect(verifyResp).toHaveProperty('valid', true) - - // Verify failure (bad data) - const verifyRespBadData = await client.verify(algorithm, badData, signResp.signature, signResp.public_key) - expect(verifyRespBadData).toHaveProperty('valid', false) + expect(verifySignature(algorithm, encode(testData), signResp.signature, signResp.public_key)).toBe(true) + expect(verifySignature(algorithm, encode(badData), signResp.signature, signResp.public_key)).toBe(false) }) - it('should sign and verify with secp256k1_prehashed', async () => { + it('should sign with secp256k1_prehashed and verify locally', async () => { const algorithm = 'secp256k1_prehashed' - const digest = crypto.createHash('sha256').update(testData).digest() + const digest = new Uint8Array(crypto.createHash('sha256').update(testData).digest()) expect(digest.length).toBe(32) // Ensure it's 32 bytes const signResp = await client.sign(algorithm, digest) @@ -232,14 +226,11 @@ describe('DstackClient', () => { expect(signResp.signature).toBeInstanceOf(Uint8Array) expect(signResp.public_key).toBeInstanceOf(Uint8Array) - // Verify success - const verifyResp = await client.verify(algorithm, digest, signResp.signature, signResp.public_key) - expect(verifyResp).toHaveProperty('valid', true) + expect(verifySignature(algorithm, digest, signResp.signature, signResp.public_key)).toBe(true) // Verify failure (bad digest) - const badDigest = crypto.createHash('sha256').update(badData).digest() - const verifyRespBadData = await client.verify(algorithm, badDigest, signResp.signature, signResp.public_key) - expect(verifyRespBadData).toHaveProperty('valid', false) + const badDigest = new Uint8Array(crypto.createHash('sha256').update(badData).digest()) + expect(verifySignature(algorithm, badDigest, signResp.signature, signResp.public_key)).toBe(false) }) it('should throw error when signing secp256k1_prehashed with incorrect data length', async () => { diff --git a/sdk/js/src/__tests__/verify.test.ts b/sdk/js/src/__tests__/verify.test.ts new file mode 100644 index 000000000..2b6ecb1fe --- /dev/null +++ b/sdk/js/src/__tests__/verify.test.ts @@ -0,0 +1,251 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// Drives the shared cross-SDK vectors in `sdk/tests/vectors/signature_chain.json`. +// The Rust, Python and Go suites assert against the same file, so any port that +// disagrees about the byte format fails here too. + +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { secp256k1 } from '@noble/curves/secp256k1' +import { expect, describe, it } from 'vitest' +import { verifySignature, verifySignatureChain, SIGN_PURPOSE } from '../verify' + +interface Case { + algorithm: string + data: string + public_key: string + signature: string + signature_chain: string[] +} + +interface InvalidCase { + name: string + reason: string + algorithm: string + data: string + public_key: string + signature: string +} + +interface Vectors { + app_id: string + purpose: string + path: string + kms_root_pubkey: string + app_root_pubkey: string + wrong_kms_root_pubkey: string + cases: Case[] + invalid_cases: InvalidCase[] +} + +const vectors: Vectors = JSON.parse( + readFileSync( + fileURLToPath( + new URL('../../../tests/vectors/signature_chain.json', import.meta.url), + ), + 'utf8', + ), +) + +function unhex(hex: string): Uint8Array { + return new Uint8Array(Buffer.from(hex, 'hex')) +} + +function hex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('hex') +} + +function caseFor(algorithm: string): Case { + const found = vectors.cases.find((c) => c.algorithm === algorithm) + if (!found) throw new Error(`no vector for ${algorithm}`) + return found +} + +function chainOf(testCase: Case) { + return { + algorithm: testCase.algorithm, + data: unhex(testCase.data), + publicKey: unhex(testCase.public_key), + signatureChain: testCase.signature_chain.map(unhex), + appId: unhex(vectors.app_id), + kmsRootPubKey: unhex(vectors.kms_root_pubkey), + } +} + +describe('verifySignature', () => { + it('accepts every valid vector', () => { + expect(vectors.cases.length).toBeGreaterThan(0) + for (const c of vectors.cases) { + expect( + verifySignature( + c.algorithm, + unhex(c.data), + unhex(c.signature), + unhex(c.public_key), + ), + `${c.algorithm}: valid signature was rejected`, + ).toBe(true) + } + }) + + it('rejects every invalid vector', () => { + expect(vectors.invalid_cases.length).toBeGreaterThan(0) + for (const c of vectors.invalid_cases) { + const verify = () => + verifySignature( + c.algorithm, + unhex(c.data), + unhex(c.signature), + unhex(c.public_key), + ) + if (c.name === 'secp256k1_high_s') { + // High-S is refused outright rather than reported false, because it is a + // malformed encoding rather than a legitimate signature that fails to match. + expect(verify, c.name).toThrow(/high-S/) + } else { + expect(verify(), `${c.name}: should not have verified`).toBe(false) + } + } + }) + + it('treats k256 as an alias for secp256k1', () => { + const c = caseFor('secp256k1') + expect( + verifySignature( + 'k256', + unhex(c.data), + unhex(c.signature), + unhex(c.public_key), + ), + ).toBe(true) + }) + + it('accepts an uncompressed SEC1 public key', () => { + const c = caseFor('secp256k1') + // 0x03-prefixed compressed key from the vectors, expanded to 65 bytes. + const compressed = unhex(c.public_key) + expect(compressed.length).toBe(33) + const uncompressed = secp256k1.ProjectivePoint.fromHex(compressed).toRawBytes(false) + expect(uncompressed.length).toBe(65) + expect( + verifySignature( + c.algorithm, + unhex(c.data), + unhex(c.signature), + uncompressed, + ), + ).toBe(true) + }) + + it('throws on malformed inputs rather than reporting false', () => { + expect(() => + verifySignature('rsa', new Uint8Array([1]), new Uint8Array(64), new Uint8Array(32)), + ).toThrow(/unsupported algorithm/) + expect(() => + verifySignature('ed25519', new Uint8Array([1]), new Uint8Array(64), new Uint8Array(31)), + ).toThrow(/32 bytes/) + expect(() => + verifySignature('ed25519', new Uint8Array([1]), new Uint8Array(63), new Uint8Array(32)), + ).toThrow(/64 bytes/) + + // A prehashed digest must be exactly 32 bytes. + const prehashed = caseFor('secp256k1_prehashed') + expect(() => + verifySignature( + 'secp256k1_prehashed', + new TextEncoder().encode('short'), + unhex(prehashed.signature), + unhex(prehashed.public_key), + ), + ).toThrow(/32-byte digest/) + + // Raw 64-byte r || s only; DER is not accepted. + const secp = caseFor('secp256k1') + expect(() => + verifySignature( + 'secp256k1', + unhex(secp.data), + unhex(secp.signature).slice(0, 63), + unhex(secp.public_key), + ), + ).toThrow(/64 raw bytes/) + expect(() => + verifySignature( + 'secp256k1', + unhex(secp.data), + unhex(secp.signature), + unhex(secp.public_key).slice(0, 32), + ), + ).toThrow(/public key/) + }) +}) + +describe('verifySignatureChain', () => { + it('verifies every vector up to the KMS root', () => { + for (const c of vectors.cases) { + const appRoot = verifySignatureChain(chainOf(c)) + expect(appRoot.length).toBe(33) + expect(hex(appRoot), `${c.algorithm}: recovered the wrong app root key`).toBe( + vectors.app_root_pubkey, + ) + } + }) + + it('defaults purpose to the agent-side signing constant', () => { + expect(SIGN_PURPOSE).toBe('signing') + expect(vectors.purpose).toBe(SIGN_PURPOSE) + const c = vectors.cases[0] + expect(hex(verifySignatureChain({ ...chainOf(c), purpose: SIGN_PURPOSE }))).toBe( + vectors.app_root_pubkey, + ) + }) + + it('rejects a chain anchored at a foreign KMS root', () => { + const c = vectors.cases[0] + expect(() => + verifySignatureChain({ + ...chainOf(c), + kmsRootPubKey: unhex(vectors.wrong_kms_root_pubkey), + }), + ).toThrow(/not anchored/) + }) + + it('rejects a chain issued for a different app id', () => { + const c = vectors.cases[0] + const appId = unhex(vectors.app_id) + appId[0] ^= 0xff + expect(() => verifySignatureChain({ ...chainOf(c), appId })).toThrow() + }) + + it('rejects a tampered payload', () => { + const c = vectors.cases[0] + expect(() => + verifySignatureChain({ + ...chainOf(c), + data: new TextEncoder().encode('a different payload entirely'), + }), + ).toThrow() + }) + + it('rejects a tampered purpose', () => { + const c = vectors.cases[0] + expect(() => + verifySignatureChain({ ...chainOf(c), purpose: 'encryption' }), + ).toThrow(/not anchored/) + }) + + it('rejects malformed chain shapes', () => { + const c = vectors.cases[0] + expect(() => + verifySignatureChain({ + ...chainOf(c), + signatureChain: c.signature_chain.slice(0, 2).map(unhex), + }), + ).toThrow(/3 elements/) + expect(() => + verifySignatureChain({ ...chainOf(c), appId: unhex(vectors.app_id).slice(0, 19) }), + ).toThrow(/20 bytes/) + }) +}) diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index cc82bf2d9..dc9f64e8e 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -7,6 +7,8 @@ import { send_rpc_request } from './send-rpc-request' export { getComposeHash } from './get-compose-hash' export { verifyEnvEncryptPublicKey, verifyEnvEncryptPublicKeyLegacy } from './verify-env-encrypt-public-key' export type { VerifyOptions } from './verify-env-encrypt-public-key' +export { verifySignature, verifySignatureChain, SIGN_PATH, SIGN_PURPOSE } from './verify' +export type { SignatureChainInput } from './verify' export interface GetTlsKeyResponse { __name__: Readonly<'GetTlsKeyResponse'> @@ -32,12 +34,6 @@ export interface SignResponse { public_key: Uint8Array } -export interface VerifyResponse { - __name__: Readonly<'VerifyResponse'> - - valid: boolean -} - export type Hex = `${string}` @@ -373,35 +369,6 @@ export class DstackClient { }); } - /** - * Verifies a payload signature. - * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") - * @param data The data that was signed. - * @param signature The signature to verify. - * @param publicKey The public key to use for verification. - * @returns A VerifyResponse indicating if the signature is valid. - */ - async verify( - algorithm: string, - data: string | Buffer | Uint8Array, - signature: string | Buffer | Uint8Array, - publicKey: string | Buffer | Uint8Array - ): Promise { - const payload = JSON.stringify({ - algorithm: algorithm, - data: to_hex(data), - signature: to_hex(signature), - public_key: to_hex(publicKey) - }); - - const result = await send_rpc_request<{ valid: boolean }>(this.endpoint, '/Verify', payload); - - return Object.freeze({ - ...result, - __name__: 'VerifyResponse', - }); - } - // // Legacy methods for backward compatibility with a warning to notify users about migrating to new methods. // These methods don't mean fully compatible as past, but we keep them here until next major version. diff --git a/sdk/js/src/verify.ts b/sdk/js/src/verify.ts new file mode 100644 index 000000000..542bf0a95 --- /dev/null +++ b/sdk/js/src/verify.ts @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Local signature and signature-chain verification. + * + * Verification needs no key material and no attestation, so it does not belong + * behind an RPC to the guest agent: the agent's answer arrives over the socket + * unattested, which is no better than a caller checking the signature itself. + * The `Verify` RPC these functions replace was removed in v0.6.0. + * + * Two levels are available: + * + * - {@link verifySignature} checks one signature against a public key you + * already have. It is the direct replacement for the old RPC and, on its own, + * proves only that whoever holds that key signed the data. + * - {@link verifySignatureChain} walks the full chain from a `SignResponse` + * back to a KMS root key **you supply**, which is what actually establishes + * that the signer was a dstack app under that KMS. + */ + +import { ed25519 } from "@noble/curves/ed25519" +import { secp256k1 } from "@noble/curves/secp256k1" +import { sha256 } from "@noble/hashes/sha256" +import { keccak_256 } from "@noble/hashes/sha3" + +/** Domain-separation prefix the KMS signs app root keys under. */ +const KMS_ISSUED_PREFIX = "dstack-kms-issued:" + +/** `Sign` derives its key at this path with this purpose; both are fixed agent-side. */ +export const SIGN_PATH = "vms" +export const SIGN_PURPOSE = "signing" + +/** `k256` and `secp256k1` name the same thing; the agent normalized these too. */ +function normalizeAlgorithm(algorithm: string): string { + return algorithm === "k256" ? "secp256k1" : algorithm +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +type K256Signature = ReturnType + +function parseK256Signature(signature: Uint8Array): K256Signature { + if (signature.length !== 64) { + throw new Error( + `invalid secp256k1 signature: expected 64 raw bytes (r || s), but received ${signature.length}`, + ) + } + let sig: K256Signature + try { + sig = secp256k1.Signature.fromCompact(signature) + } catch (error) { + throw new Error(`invalid secp256k1 signature: ${describe(error)}`) + } + // ECDSA is malleable: (r, n-s) verifies wherever (r, s) does. Rust's k256 + // rejects the high-S form, so we must too -- otherwise a signature stops + // being a unique identifier for a signed message, and this SDK would disagree + // with every other dstack component about whether a given blob is valid. + // A high-S signature is a malformed encoding rather than a signature that + // legitimately fails to match, so it throws instead of returning false. + if (sig.hasHighS()) { + throw new Error("non-canonical (high-S) secp256k1 signature") + } + return sig +} + +/** Parses a SEC1 public key, compressed (33 bytes) or uncompressed (65 bytes). */ +function parseK256PublicKey(publicKey: Uint8Array) { + if (publicKey.length !== 33 && publicKey.length !== 65) { + throw new Error( + `invalid secp256k1 public key: expected 33 or 65 SEC1 bytes, but received ${publicKey.length}`, + ) + } + try { + const point = secp256k1.ProjectivePoint.fromHex(publicKey) + point.assertValidity() + return point + } catch (error) { + throw new Error(`invalid secp256k1 public key: ${describe(error)}`) + } +} + +/** + * Verifies one signature against `publicKey`. + * + * `algorithm` is `ed25519`, `secp256k1` (alias `k256`), or + * `secp256k1_prehashed`, where `data` is already a 32-byte digest. Returns + * `false` when the inputs are well-formed but the signature does not check out, + * and throws when they are not well-formed at all (bad key encoding, wrong + * signature length, unknown algorithm) -- a malformed input is a caller bug, + * not a verdict. + */ +export function verifySignature( + algorithm: string, + data: Uint8Array, + signature: Uint8Array, + publicKey: Uint8Array, +): boolean { + switch (normalizeAlgorithm(algorithm)) { + case "ed25519": { + if (publicKey.length !== 32) { + throw new Error( + `ed25519 public key must be 32 bytes, but received ${publicKey.length}`, + ) + } + if (signature.length !== 64) { + throw new Error( + `ed25519 signature must be 64 bytes, but received ${signature.length}`, + ) + } + try { + ed25519.ExtendedPoint.fromHex(publicKey) + } catch (error) { + throw new Error(`invalid ed25519 public key: ${describe(error)}`) + } + try { + return ed25519.verify(signature, data, publicKey) + } catch { + // Past the encoding checks above, anything left is a failed match. + return false + } + } + case "secp256k1": { + const point = parseK256PublicKey(publicKey) + const sig = parseK256Signature(signature) + // The agent signs with k256's `sign`, which hashes with SHA-256, so + // verification must hash the payload the same way. + return verifyPrehashed(sha256(data), sig, point) + } + case "secp256k1_prehashed": { + if (data.length !== 32) { + throw new Error( + `pre-hashed verification requires a 32-byte digest, but received ${data.length} bytes`, + ) + } + const point = parseK256PublicKey(publicKey) + const sig = parseK256Signature(signature) + return verifyPrehashed(data, sig, point) + } + default: + throw new Error(`unsupported algorithm: ${algorithm}`) + } +} + +function verifyPrehashed( + digest: Uint8Array, + signature: K256Signature, + publicKey: ReturnType, +): boolean { + try { + // `lowS` is noble's default today, but state it explicitly: a future change + // to that default must not silently start accepting malleated signatures + // that Rust's k256 rejects. High-S has already thrown by this point; this + // keeps the two layers from drifting apart. + return secp256k1.verify( + signature.toCompactRawBytes(), + digest, + publicKey.toRawBytes(true), + { lowS: true }, + ) + } catch { + // Past the encoding checks above, anything left is a failed match. + return false + } +} + +/** + * Recovers the compressed public key that produced a 65-byte `r || s || recid` + * signature over `keccak256(message)`. + */ +function recoverCompressed( + message: Uint8Array, + signature: Uint8Array, +): Uint8Array { + if (signature.length !== 65) { + throw new Error( + `recoverable signature must be 65 bytes, but received ${signature.length}`, + ) + } + const sig = parseK256Signature(signature.slice(0, 64)) + const recid = signature[64] + // Raw recovery ids, not the +27 form Ethereum wire formats use. + if (recid > 3) { + throw new Error(`invalid recovery id ${recid}`) + } + try { + return sig + .addRecoveryBit(recid) + .recoverPublicKey(keccak_256(message)) + .toRawBytes(true) + } catch (error) { + throw new Error(`failed to recover public key: ${describe(error)}`) + } +} + +/** + * Inputs to {@link verifySignatureChain}. + * + * An options object rather than a positional argument list so that adding an + * input later does not break callers. + */ +export interface SignatureChainInput { + /** Algorithm the payload was signed with. */ + algorithm: string + /** The signed payload; a 32-byte digest for `secp256k1_prehashed`. */ + data: Uint8Array + /** `SignResponse.public_key` -- the key that signed `data`. */ + publicKey: Uint8Array + /** `SignResponse.signature_chain`, exactly 3 elements. */ + signatureChain: Uint8Array[] + /** + * The 20-byte app identity to hold the chain to. + * + * This must be the app id you *expect*, not merely whatever `InfoResponse` + * echoed back -- that comes from the CVM being checked. Comparing a chain + * against an app id the same CVM supplied proves only that it is + * self-consistent. + */ + appId: Uint8Array + /** + * The KMS root public key you already trust, compressed or uncompressed SEC1. + * + * Get it from the `DstackKms` contract (`kmsInfo().k256Pubkey`) or pin it. + * Reading it from the KMS you are verifying against proves nothing. + */ + kmsRootPubKey: Uint8Array + /** Purpose bound into the app-root link. Always {@link SIGN_PURPOSE} for `Sign`. */ + purpose?: string +} + +/** + * Verifies a `Sign` signature chain end to end. + * + * Three links, all of which must hold: + * + * 1. `signatureChain[0]` is a signature over `data` by `publicKey`. + * 2. `signatureChain[1]` is the app root key attesting `"{purpose}:{hex(publicKey)}"`. + * 3. `signatureChain[2]` is `kmsRootPubKey` attesting that app root key for `appId`. + * + * Link 3 is the one that matters. Without comparing against a KMS root key you + * independently trust, a chain is just three signatures an attacker could have + * produced with their own keys. + * + * @returns the app root public key, compressed SEC1 (33 bytes), recovered from + * the chain and confirmed to be the one this KMS root signed. + * @throws if any link fails. + */ +export function verifySignatureChain(input: SignatureChainInput): Uint8Array { + const { + algorithm, + data, + publicKey, + signatureChain, + appId, + kmsRootPubKey, + purpose = SIGN_PURPOSE, + } = input + + if (signatureChain.length !== 3) { + throw new Error( + `signature chain must have 3 elements, but received ${signatureChain.length}`, + ) + } + if (appId.length !== 20) { + throw new Error(`appId must be 20 bytes, but received ${appId.length}`) + } + + // Link 1: the payload signature. signatureChain[0] *is* that signature; what + // matters is that it checks out under `publicKey`, which links 2 and 3 cover. + if (!verifySignature(algorithm, data, signatureChain[0], publicKey)) { + throw new Error("payload signature is not valid for the given public key") + } + + // Link 2: recover the app root key that vouched for the signing key. + const message = new TextEncoder().encode( + `${purpose}:${bytesToHex(publicKey)}`, + ) + const appRootPubKey = recoverCompressed(message, signatureChain[1]) + + // Link 3: recover the KMS root key that vouched for the app root key, and + // check it is the one we were told to trust. + const kmsMessage = concat( + new TextEncoder().encode(KMS_ISSUED_PREFIX), + appId, + appRootPubKey, + ) + const recoveredKms = recoverCompressed(kmsMessage, signatureChain[2]) + + // Normalize the expected key so callers may pass either SEC1 encoding. + const expectedKms = parseK256PublicKey(kmsRootPubKey).toRawBytes(true) + if (bytesToHex(recoveredKms) !== bytesToHex(expectedKms)) { + throw new Error( + "signature chain is not anchored at the expected KMS root key", + ) + } + + return appRootPubKey +} diff --git a/sdk/js/tsup.config.ts b/sdk/js/tsup.config.ts index 189631ac7..e72661034 100644 --- a/sdk/js/tsup.config.ts +++ b/sdk/js/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ "src/encrypt-env-vars.ts", "src/get-compose-hash.ts", "src/verify-env-encrypt-public-key.ts", + "src/verify.ts", ], format: ["cjs", "esm"], dts: true, From cdb7c3b567744c4227854c843f70acc23cb25a59 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:01:24 -0700 Subject: [PATCH 7/8] docs: record the Verify RPC removal and local verification The endpoint reference gains a note on why /Verify is gone rather than a silent gap, and the sections after it are renumbered. Drops the "not yet released" annotation from Sign and Verify in the curl and Rust references. It was stale: both shipped in v0.5.6. Leaving it would have made this removal look free when it is a breaking change. Also fixes the Sign response example, which was missing a comma and so was not valid JSON. --- CHANGELOG.md | 5 +++++ sdk/curl/api.md | 48 ++++++++++-------------------------------------- 2 files changed, 15 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc12db890..45aa253bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- sdk: `verify_signature` and `verify_signature_chain` in all four SDKs, replacing the removed guest-agent `Verify` RPC. `verify_signature_chain` is new capability rather than a port: it walks all three links of a `Sign` signature chain -- payload signature, the app root key attesting `"{purpose}:{hex(pubkey)}"`, and the KMS root attesting that app root for this `app_id` -- and requires the chain to anchor at a KMS root public key **the caller supplies**. That anchor has to come from somewhere independently trusted (the `DstackKms` contract's `kmsInfo().k256Pubkey`, or a pinned value); read it from the KMS being checked and an attacker who can answer that query can also mint a self-consistent chain. The four ports are pinned against one committed set of test vectors, `sdk/tests/vectors/signature_chain.json`, generated from the real KMS and guest-agent primitives -- this repo has shipped cross-language crypto drift twice already - sdk: `AppCompose` in the Go SDK gained `init_script`, `storage_fs`, `swap_size`, `event_log_version`, `port_policy` and `verity_volumes`, and `Requirements` gained `gpu_policy` in the Go and Python SDKs - shared API authentication (`dstack-api-auth`) protecting the full VMM HTTP/pRPC/UI surface and unifying Gateway/KMS admin auth: bearer/`X-Admin-Token`/HTTP Basic/bcrypt htpasswd, constant-time verification (#796) - gateway: `Admin.Status` reports `health_gating`, so an operator can see whether this node's health polling is switched on. With it off, instances that opted in sit at `unknown` forever and are all in rotation, which is otherwise indistinguishable on the dashboard from being held out pending a first answer @@ -39,6 +40,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - guest-agent: `Worker.GetAttestationForAppKey` is replaced by `Worker.AttestAppKey`. The old method returned a `GetQuoteResponse`, so restricting `GetQuote` to Intel TDX left it unable to answer anywhere else — and the external listener with no way to attest an app key at all, since `Attest` is on the internal socket and an external caller could not use it anyway, not knowing the app key's public key until the agent derives it. `AttestAppKey` takes the same request and returns an `AttestResponse`, on every platform. This is a breaking change to an RPC present since v0.5.7; it ships no SDK method and has no known callers +### Removed +- guest-agent: the `Verify` RPC (`/Verify`), present since v0.5.6. Checking a signature needs no key material and no attestation, and the agent's verdict arrived over the socket unattested -- a caller who believed the TEE was vouching for it was mistaken, and one who did not gained nothing over checking the signature locally. It was also inbound attack surface, parsing attacker-supplied keys and signatures inside the TEE for no benefit. `Sign` stays server-side, because it needs a key only the TEE holds. **Breaking:** an SDK pinned at 0.5.x that calls `/Verify` against a 0.6+ guest agent gets an unknown-method error; update to an SDK that verifies locally. The client-side `verify()` method is gone from the Rust, Python, Go and JavaScript SDKs, replaced by the standalone `verify_signature` above -- it never needed a client connection in the first place. Verification also became stricter in one respect: non-canonical high-S secp256k1 signatures are now rejected explicitly everywhere. `k256` accepted only the canonical form, so the Rust agent already behaved this way, but a naive port to Python or Go would have silently accepted both `(r, s)` and `(r, n-s)` for the same message + + ## [0.5.5] - 2025-10-20 ### Added diff --git a/sdk/curl/api.md b/sdk/curl/api.md index a8b91f085..be19180f7 100644 --- a/sdk/curl/api.md +++ b/sdk/curl/api.md @@ -182,7 +182,7 @@ curl --unix-socket /var/run/dstack.sock http://dstack/Info The `cloud_vendor` and `cloud_product` fields report the detected cloud platform. -### 5. Sign (not yet released) +### 5. Sign Signs a payload. @@ -214,47 +214,19 @@ curl --unix-socket /var/run/dstack.sock -X POST \ "", "", "" - ] + ], "public_key": "" } ``` -### 6. Verify (not yet released) - -Verifies a signature. - -**Endpoint:** `/Verify` - -**Request Parameters:** - -| Field | Type | Description | Example | -|-------|------|-------------|----------| -| `algorithm` | string | `ed25519`, `secp256k1_prehashed` or `secp256k1`| `ed25519` | -| `data` | string | Hex-encoded payload data | `deadbeef` | -| `signature` | string | Hex-encoded signature | `deadbeef` | -| `public_key` | string | Hex-encoded public key | `deadbeef` | - -**Example:** -```bash -curl --unix-socket /var/run/dstack.sock -X POST \ - http://dstack/Verify \ - -H 'Content-Type: application/json' \ - -d '{ - "algorithm": "ed25519", - "data": "deadbeef", - "signature": "deadbeef", - "public_key": "deadbeef" - }' -``` - -**Response:** -```json -{ - "valid": "" -} -``` +> **Removed in v0.6.0:** there was a `/Verify` endpoint here. Checking a signature +> needs no key material and no attestation, and the agent's answer came back over +> the socket unattested, so a caller gained nothing over checking the signature +> itself. Verification now lives in the SDKs (`verify_signature` / +> `verify_signature_chain`), which can also walk the `signature_chain` back to a +> KMS root key the caller independently trusts -- something this endpoint never did. -### 7. Attest +### 6. Attest Generates a versioned attestation with the given report data. Returns a dstack-defined attestation format that supports different attestation modes across platforms. You can submit the returned `attestation` directly to the verifier `/verify` endpoint. @@ -288,7 +260,7 @@ curl --unix-socket /var/run/dstack.sock http://dstack/Attest?report_data=0000000 } ``` -### 8. GPU Info +### 7. GPU Info Returns GPU information collected during boot. Currently, this includes the complete JSON output produced by NVIDIA `nvattest`. From 0e2563678099d219239164116091af33e93bb1bb Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 23 Aug 2026 19:15:43 -0700 Subject: [PATCH 8/8] docs(sdk): stop the chain-verification examples demonstrating the anti-pattern Every README told the reader that app_id must be the value they expect rather than whatever AppInfo reported, then showed an example passing client.info()'s app_id straight into the verifier. The Go one put "pinned, or read from the DstackKms contract" on the KMS root and left the app id unguarded on the line above it. Examples get copied verbatim into production far more often than the prose above them gets read, so both anchors are now literals the caller owns, and each example says explicitly what it is not doing and why feeding AppInfo back in proves only that the CVM agrees with itself. Also records, in the vector generator, that recovery ids 2 and 3 are the one behaviour the shared vectors cannot pin: Rust, Go and JavaScript accept 0..3 while Python rejects 2 and 3 because eth_keys only models v in {0, 1}. Reaching either needs an r that wrapped the curve order, so the divergence is unreachable rather than latent -- but it should not live only in a review thread. --- .../guest-agent/tests/signature_chain_vectors.rs | 9 +++++++++ sdk/go/README.md | 14 +++++++++----- sdk/js/README.md | 16 +++++++++++++--- sdk/python/README.md | 15 ++++++++++++--- sdk/rust/README.md | 15 ++++++++++++--- 5 files changed, 55 insertions(+), 14 deletions(-) diff --git a/dstack/guest-agent/tests/signature_chain_vectors.rs b/dstack/guest-agent/tests/signature_chain_vectors.rs index cd20bff96..24cd7a40b 100644 --- a/dstack/guest-agent/tests/signature_chain_vectors.rs +++ b/dstack/guest-agent/tests/signature_chain_vectors.rs @@ -18,6 +18,15 @@ //! //! Run `UPDATE_VECTORS=1 cargo test -p dstack-guest-agent --test signature_chain_vectors` //! to regenerate after an intentional format change. +//! +//! What these vectors deliberately do NOT pin: the handling of recovery ids 2 +//! and 3 in the two recoverable chain links. Rust, Go and JavaScript accept the +//! full 0..3 range; Python rejects 2 and 3, because `eth_keys` only models v in +//! {0, 1}. Reaching either case requires an ECDSA nonce whose `r` wrapped the +//! curve order, which happens with probability around 2^-128 and which no +//! dstack signer has ever produced -- so there is no way to generate a fixture +//! for it here, and the divergence is unreachable rather than latent. Recorded +//! so the next person does not have to rediscover it. use ed25519_dalek::{Signer as _, SigningKey as Ed25519SigningKey}; use k256::ecdsa::SigningKey; diff --git a/sdk/go/README.md b/sdk/go/README.md index dd99b248b..bda275b24 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -715,11 +715,8 @@ produced with their own keys. Get the root from the `DstackKms` contract against proves nothing. ```go -info, err := client.Info(ctx) -if err != nil { - log.Fatal(err) -} -appID, _ := hex.DecodeString(strings.TrimPrefix(info.AppID, "0x")) +// Both anchors come from you, not from the CVM being checked. +appID, _ := hex.DecodeString("a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b") kmsRoot, _ := hex.DecodeString("03...") // pinned, or read from the DstackKms contract appRootPubKey, err := dstack.VerifySignatureChain(dstack.SignatureChainInput{ @@ -737,6 +734,13 @@ if err != nil { fmt.Printf("app root key: %x\n", appRootPubKey) ``` +Note what the example does *not* do: it never passes `info.AppID` from +`client.Info()` straight through. That value is reported by the very CVM being +verified, so a chain checked against it proves only that the CVM is +self-consistent with itself. Use the app id you registered on chain, and if you +want `Info` in the picture, compare it against that value rather than trusting +it. + ### KMS Public Key Verification Verify the authenticity of encryption public keys provided by KMS APIs: diff --git a/sdk/js/README.md b/sdk/js/README.md index 563cfa317..7594e3eb1 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -162,17 +162,27 @@ On its own, `verifySignature` only proves that whoever holds that public key sig ```typescript import { verifySignatureChain } from '@phala/dstack-sdk' -const info = await client.info() +// Both anchors come from you, not from the CVM being checked. +const expectedAppId = Buffer.from('a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b', 'hex') +const kmsRootPubKey = Buffer.from('03...', 'hex') // pinned, or read from DstackKms + const appRootPubKey = verifySignatureChain({ algorithm: 'ed25519', data, publicKey: res.public_key, signatureChain: res.signature_chain, - appId: Buffer.from(info.app_id.replace(/^0x/, ''), 'hex'), - kmsRootPubKey, // compressed or uncompressed SEC1, from a source you trust + appId: expectedAppId, + kmsRootPubKey, }) ``` +Note what the example does *not* do: it never passes `info.app_id` from +`client.info()` straight through. That value is reported by the very CVM being +verified, so a chain checked against it proves only that the CVM is +self-consistent with itself. Use the app id you registered on chain, and if you +want `info` in the picture, compare it against that value rather than trusting +it. + Returns the app root public key (compressed SEC1, 33 bytes) or throws. Get `kmsRootPubKey` from the `DstackKms` contract (`kmsInfo().k256Pubkey`) or pin it in your build — reading it from the KMS you are verifying against proves nothing. ## Diagnostics diff --git a/sdk/python/README.md b/sdk/python/README.md index 716f8cad3..8788a7269 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -209,18 +209,27 @@ the data. It says nothing about *whose* key it is. `verify_signature_chain` walks all three links back to a KMS root key you supply: ```python -info = client.info() +# Both anchors come from you, not from the CVM being checked. +expected_app_id = bytes.fromhex('a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b') +kms_root_pubkey = bytes.fromhex('03...') # pinned, or read from DstackKms + app_root_pubkey = verify_signature_chain( 'ed25519', b'message to sign', result.decode_public_key(), result.decode_signature_chain(), - bytes.fromhex(info.app_id), - kms_root_pubkey, # you supply this — see below + expected_app_id, + kms_root_pubkey, ) print(app_root_pubkey.hex()) # compressed SEC1, 33 bytes ``` +Note what the example does *not* do: it never passes `client.info().app_id` +straight through. That value is reported by the very CVM being verified, so a +chain checked against it proves only that the CVM is self-consistent with +itself. Use the app id you registered on chain, and if you want `AppInfo` in the +picture, compare it against that value rather than trusting it. + It returns the app root public key and raises `ValueError` on any failure. `kms_root_pubkey` must come from somewhere you already trust: the `DstackKms` diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 45c93f941..2f00b0cec 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -192,18 +192,27 @@ data. It says nothing about *whose* key it is. `verify_signature_chain` walks al three links back to a KMS root key you supply: ```rust -let info = client.info().await?; +// Both anchors come from you, not from the CVM being checked. +let expected_app_id = hex::decode("a9019d1b2c3d4e5f60718293a4b5c6d7e8f90a1b")?; +let kms_root_pubkey = hex::decode("03...")?; // pinned, or read from DstackKms + let verified = verify_signature_chain(&SignatureChain::from_sign_response( "ed25519", b"message to sign", &result.decode_public_key()?, &result.decode_signature_chain()?, - &hex::decode(&info.app_id)?, - &kms_root_pubkey, // you supply this -- see below + &expected_app_id, + &kms_root_pubkey, )?; println!("app root key: {}", hex::encode(verified.app_root_pubkey)); ``` +Note what the example does *not* do: it never passes `client.info().app_id` +straight through. That value is reported by the very CVM being verified, so a +chain checked against it proves only that the CVM is self-consistent with +itself. Use the app id you registered on chain, and if you want `AppInfo` in the +picture, compare it against that value rather than trusting it. + `kms_root_pubkey` must come from somewhere you already trust: the `DstackKms` contract's `kmsInfo().k256Pubkey`, or a value you pinned. Reading it from the same KMS you are checking against proves nothing -- an attacker who can answer that