Access TEE features from your Python application running inside dstack. Derive deterministic keys, request attestations, and issue TLS certificates—all backed by hardware security.
dstack 0.6.0 split the guest agent API into two surfaces on one socket, and the agent picks between them by URL path alone. This SDK mirrors both, and nothing more — neither client translates calls to the other.
| Client | Surface | Paths | What it is |
|---|---|---|---|
DstackClient / AsyncDstackClient — same classes as DstackClientV1 / AsyncDstackClientV1 |
dstack.guest.v1 |
/v1/GetKey |
The default. Where every new capability lands |
DstackClientV0 / AsyncDstackClientV0 |
the frozen v0.5.11 API | /GetKey, equivalently /v0/GetKey |
Legacy. Frozen: no new method, no new field, ever |
Use the unsuffixed name in new code. The V1 names are the same classes, spelled
out for code that wants the surface visible at a glance.
Warning
v1 derives different key material than v0. DstackClient.get_key('x', 'secp256k1')
and DstackClientV0.get_key('x') return different private keys. The v1 KDF binds
the algorithm and its own context tag alongside the domain, which is the point of
the new derivation, not a defect: v0 ignored the algorithm, so one secret served
two curves. There is no compatibility mode and no flag that brings the old bytes
back. An application that has published or committed to material derived from a v0
key must migrate deliberately — derive the v1 key, re-establish whatever depends on
the old one under it, and only then cut over.
Code that used the unsuffixed client for v0 calls fails loudly on upgrade
rather than silently deriving different keys, because the v1 method signatures
differ and get_key requires algorithm explicitly. To stay on the frozen
surface, switch to DstackClientV0.
The v1 surface serves only what genuinely needs the TEE, so it has no sign, no
verify, no emit_event, no get_quote and no gpu_info. See
docs/guest-api-v1.md for the normative spec, and
Legacy (v0, frozen) for the surface those methods live on.
pip install dstack-sdkBlockchain helpers are optional extras, needed only for the v0-era chain adapters (Blockchain helpers):
| Extra | Pulls in | Use when |
|---|---|---|
dstack-sdk[ethereum] |
eth-account |
You sign Ethereum transactions |
dstack-sdk[solana] |
solders |
You sign Solana transactions |
dstack-sdk[all] |
both | You need both |
Aliases [eth] and [sol] are accepted for convenience.
from dstack_sdk import DstackClient
client = DstackClient()
key = client.get_key('storage-encryption', 'secp256k1') # algorithm is required
attestation = client.attest(b'my-app-state')
info = client.info()The client automatically connects to /var/run/dstack.sock. For local
development with the simulator:
client = DstackClient('http://localhost:8090')
# or export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090Every v1 method requires a 0.6.0+ guest agent: older agents have no /v1 mount
and answer HTTP 404.
DstackClient speaks dstack.guest.v1 at /v1/<Method>. Six methods, and
deliberately no more — v1 serves only what genuinely needs the TEE. In the
examples below, client = DstackClient().
Derives deterministic keys bound to your application's identity (app_id). The
same domain always produces the same key for your app, and different apps get
different keys for the same domain.
key = client.get_key('storage-encryption', 'secp256k1')
print(key.key) # 32 raw bytes
print(key.public_key) # SEC1 compressed (33 B), or 32 B for ed25519
print(key.signature_chain) # two links: app root, then KMS rootEvery field the proto declares bytes is bytes here, hex only on the wire.
public_key in particular is what the v1 key claim commits to, and the claim is
built over raw bytes — passing a hex string would build it over 66 ASCII
characters and produce a chain that silently never verifies.
Parameters:
domain: Any string. This replaces v0'spathpluspurpose; in v0 onlypathreached the KDF andpurposewas merely echoed into the chain claim. Derivation is flat — two domains give unrelated keys, anda/bis not a child ofa.algorithm: Exactly'secp256k1'or'ed25519'. Required. There is no default and nok256alias, because in v0 a typo silently produced a key of the wrong type under a name the caller thought meant something else. An empty value is rejected client-side.
Returns: GetKeyResponseV1 with key: bytes, public_key: bytes and
signature_chain: list[bytes].
The sole CVM attestation entry point in v1. The dstack attestation format
already carries the quote and the event log, so v0's TDX-only get_quote has
nothing left to add.
report_data is bytes, never str. v0 accepted a str and UTF-8 encoded it,
so attest("deadbeef") committed to the eight ASCII characters rather than the
four bytes they spell, with no error to say so; v1 makes you write
value.encode() or bytes.fromhex(value) at the call site, where it is visible
which one was meant. attest_gpu's nonce is bytes only for the same reason.
result = client.attest(b'user:alice:nonce123')
print(result.attestation) # bytes
with_gpu = client.attest(b'user:alice:nonce123', include_boottime_gpu_evidence=True)
for bundle in with_gpu.boottime_gpu_evidence:
print(bundle.vendor, bundle.format, bundle.evidence)report_data is 1–64 bytes and is zero-padded on the right to 64.
boottime_gpu_evidence is a list of GpuEvidenceBundleV1 — the same model
attest_gpu() returns, so one bundle parser serves both methods. It is empty
unless the flag was set and the guest has boot-time output; there is no
sentinel. Boot-time bundles carry format='nvidia-nvattest-boottime-json-v1',
against attest_gpu()'s 'nvidia-nvattest-collect-evidence-json-v1'.
Boot-time evidence is not bound to report_data — nvattest ran at boot
against its own nonce. Bind it by replaying the runtime event log and comparing
sha256 of bundle.evidence against evidence_sha256 in the measured
gpu-attestation event. evidence is the nvattest output byte for byte; do not
parse and re-serialize before hashing, since key order and whitespace change the
digest.
Samples the GPU now, against a nonce you choose — which is what
boottime_gpu_evidence cannot tell you, since that is a record written at boot.
Use it after anything that may have reinitialised the GPU.
result = client.attest_gpu(os.urandom(32)) # exactly 32 bytes
for bundle in result.bundles:
print(bundle.vendor, bundle.format, bundle.evidence)Select a verifier using each bundle's vendor and format, then check the
signature, certificate chain, measurements, and the nonce embedded in the
evidence. evidence is opaque vendor-native bytes, hex-encoded on the wire — do
not assume UTF-8 or JSON. It does not by itself bind the GPU to this CVM; only
TDISP/TEE-IO device binding closes that gap.
v0 called this get_tls_key(), which named the by-product rather than the
request. The private key is freshly generated per call and is not derived from
the app identity: two calls with the same arguments return two unrelated keys.
cert = client.issue_cert(
subject='api.example.com',
alt_names=['localhost'],
usage_ra_tls=True, # Embed the attestation in the certificate
usage_server_auth=True,
usage_client_auth=False,
with_app_info=True,
not_before=1700000000, # seconds since UNIX epoch
not_after=1800000000,
)
print(cert.key) # PEM private key
print(cert.certificate_chain) # PEM chain, leaf firstReturns: IssueCertResponseV1 with a PEM key and a leaf-first
certificate_chain.
info = client.info()
print(info.app_id, info.app_name, info.compose_hash)
print(info.instance_id, info.device_id)
print(info.os_image_hash, info.mr_aggregated)
print(info.app_compose, info.vm_config, info.key_provider_info)
print(info.cloud_vendor, info.cloud_product)Identity and configuration — not attestation. Nothing here is evidence: it
arrives over a local socket with no quote behind it. That is why there is no
tcb_info and no app_cert. The measurement registers and the event log belong
to attest(), which returns them quote-backed. compose_hash, os_image_hash
and mr_aggregated are here because they identify which application and image
this is, and a relying party still confirms them against an attestation.
app_compose is the verbatim deployed document and compose_hash is sha256
over exactly those bytes — do not parse and re-serialize before hashing.
app_id, instance_id, compose_hash, device_id, os_image_hash and
mr_aggregated are bytes, matching the proto. Call .hex() to print one.
client.version() # VersionResponseV1(version, rev)AsyncDstackClient has the identical surface, with every method a coroutine:
import asyncio
from dstack_sdk import AsyncDstackClient
async def main():
client = AsyncDstackClient()
info = await client.info()
key = await client.get_key('backup-signing', 'ed25519')
# Run requests concurrently
keys = await asyncio.gather(
client.get_key('user/alice', 'secp256k1'),
client.get_key('user/bob', 'secp256k1'),
)
asyncio.run(main())AsyncDstackClient accepts the same constructor as DstackClient plus
use_sync_http: bool = False for callers that need to issue sync HTTP from
within an async context. The same holds for the v0 clients.
Neither is a v1 method. Signing happens wherever the key is, and get_key()
already hands you the key, so a round trip to the agent adds nothing; verifying
needs no key at all, and an agent's answer arrives over the socket unattested,
so a relying party gains nothing over checking the signature itself with a
standard library.
from cryptography.hazmat.primitives.asymmetric import ed25519
key = client.get_key('signing/messages', 'ed25519')
signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(key.key)
signature = signing_key.sign(b'message to sign')To verify a v1 signature chain, follow
docs/guest-api-v1.md, which is the normative
spec: it gives the claim encoding, the link order, and — the part that actually
establishes anything — the requirement that the KMS root public key come from
somewhere you already trust (the DstackKms contract's kmsInfo().k256Pubkey,
or a value you pinned), never from the CVM being checked.
These utilities are for deployment scripts, not runtime SDK operations, and are the same for either client.
The KMS returns a fresh X25519 public key (with a secp256k1 signature) that you encrypt secrets against before submitting them with your deployment. Always verify the signer before trusting the key:
from dstack_sdk import (
encrypt_env_vars,
verify_env_encrypt_public_key,
EnvVar,
)
# `public_key`, `signature_v1`, `timestamp` come from KMS /GetAppEnvEncryptPubKey.
signer = verify_env_encrypt_public_key(
public_key=public_key_bytes,
signature=signature_v1_bytes,
app_id=app_id_hex,
timestamp=timestamp,
)
if signer is None:
raise RuntimeError('invalid KMS env-encrypt public key')
# Always compare the recovered signer against a known-good KMS signer
# address, obtained out-of-band from the DstackKms contract or your
# deployment configuration. Without this check, an attacker could sign
# their own env-encrypt key and the verification above would still pass.
EXPECTED_KMS_SIGNER = '0x...' # replace with your known KMS signer address
if signer != EXPECTED_KMS_SIGNER:
raise RuntimeError(
f'unexpected KMS signer: got {signer}, '
f'expected {EXPECTED_KMS_SIGNER}'
)
env_vars = [
EnvVar(key='DATABASE_URL', value='postgresql://...'),
EnvVar(key='API_KEY', value='secret'),
]
encrypted = await encrypt_env_vars(env_vars, public_key_hex)
# encrypt_env_vars_sync(...) is also available for non-async callers.verify_env_encrypt_public_key returns the recovered compressed secp256k1 signer (0x-prefixed hex) on success, or None for any failure (bad length, expired/future timestamp, malformed app_id, invalid signature). The default max_age_seconds is 300; pass a larger value if your deployment workflow legitimately holds the response longer.
verify_env_encrypt_public_key_legacy remains available only for deployments that explicitly support older KMS builds without signature_v1. It does not provide timestamp replay protection and should not be used for new deployments.
from dstack_sdk import get_compose_hash
hash_value = get_compose_hash(app_compose_dict)| Feature | Required dstack OS |
|---|---|
Every DstackClient (v1) method |
0.6.0+ — older agents have no /v1 mount and answer HTTP 404 |
V0 get_key, get_quote, get_tls_key (legacy fields), info (legacy fields) |
0.3+ |
V0 attest, sign, verify, is_reachable |
0.5.0+ (sign requires a server build with the feature) |
V0 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+ |
V0 emit_event |
Removed in 0.6.0: the agent always fails it |
verify_env_encrypt_public_key (signature_v1 with timestamp) |
Requires KMS build that emits signature_v1; legacy variant remains available |
V0 calls that require 0.5.7-only fields probe the Version RPC first and raise a clear RuntimeError on older guest agents. The v1 client never probes: it requires a 0.6 agent outright.
For local development without TDX hardware, use the simulator:
git clone https://github.com/Dstack-TEE/dstack.git
cd dstack/sdk/simulator
./build.sh
./dstack-simulatorThen set the endpoint:
export DSTACK_SIMULATOR_ENDPOINT=http://localhost:8090Install dev dependencies, then run the tests and the format/lint checks with PDM:
cd sdk/python
pdm install --dev
pdm run test
pdm run checkmake install / make test wrap the same commands and additionally assert that
DSTACK_SIMULATOR_ENDPOINT and TAPPD_SIMULATOR_ENDPOINT are set.
Everything below is the frozen v0.5.11 surface. It still works and is still
served, but it gains no method and no field. Reach for it when you must keep the
v0 key derivation, or when you need sign / verify, which v1 does not serve.
Import it by its explicit name — the unsuffixed DstackClient now means v1:
from dstack_sdk import DstackClientV0, AsyncDstackClientV0
v0 = DstackClientV0()In the examples below, v0 = DstackClientV0().
get_key() derives deterministic keys bound to your application's identity (app_id). The same path always produces the same key for your app, but different apps get different keys even with the same path.
# Derive keys by path
eth_key = v0.get_key('wallet/ethereum')
btc_key = v0.get_key('wallet/bitcoin')
# Use path to separate keys
mainnet_key = v0.get_key('wallet/eth/mainnet')
testnet_key = v0.get_key('wallet/eth/testnet')
# Use a different signature algorithm (requires dstack OS >= 0.5.7)
ed_key = v0.get_key('signing/key', algorithm='ed25519')Parameters:
path(optional): Key derivation path. Defaults to""(root).purpose(optional): Included in the signature chain message; does not affect the derived key.algorithm(optional):'secp256k1'(default) or'ed25519'. For compatibility, this selects how the same derived 32-byte material is interpreted; it does not domain-separate the derivation. Use algorithm-specific paths when independent keys are required.
Returns: GetKeyResponse
key: Hex-encoded private keysignature_chain: Signatures proving the key was derived in a genuine TEEdecode_key()/decode_signature_chain(): Helpers that returnbytes
get_quote() creates a TDX quote proving your code runs in a genuine TEE.
It needs Intel TDX: without it the call fails, and on GCP Confidential VMs it
returns the TDX quote alone, leaving out the vTPM quote GCP's verification also
binds. Call attest() in both cases.
quote = v0.get_quote(b'user:alice:nonce123')
print(quote.event_log)Parameters:
report_data: Up to 64 bytes (bytesorstr). Shorter inputs are padded with zeros; longer inputs should be hashed first (e.g., SHA-256).
Returns: GetQuoteResponse
quote: Hex-encoded TDX quoteevent_log: JSON string of measured eventsdecode_quote()/decode_event_log(): Helpers
attest() returns a versioned attestation payload that newer verifier APIs can dispatch on without sniffing the quote format.
result = v0.attest(b'user:alice:nonce123')
print(result.attestation) # hex string
print(result.decode_attestation()) # bytesreport_data is the only argument. GPU evidence — boot-time and on-demand alike —
is a v1 capability; see attest() and attest_gpu().
info = v0.info()
print(info.app_id)
print(info.instance_id)
print(info.tcb_info)
print(info.cloud_vendor, info.cloud_product) # 0.5.7+Returns: InfoResponse
app_id,instance_id,app_name,device_idtcb_info: TCB measurements (MRTD, RTMRs, event log, compose hash, ...)compose_hash: Hash of the app configurationapp_cert: Application certificate (PEM)key_provider_info: Key management configurationcloud_vendor/cloud_product: Cloud provider strings (empty on older OS)
get_tls_key() creates fresh TLS certificates. Unlike get_key(), each call generates a new random key.
tls = v0.get_tls_key(
subject='api.example.com',
alt_names=['localhost'],
usage_ra_tls=True, # Embed attestation in certificate
# 0.5.7+ options below:
not_before=1700000000, # seconds since UNIX epoch
not_after=1800000000,
with_app_info=True,
)
print(tls.key) # PEM private key
print(tls.certificate_chain) # Certificate chainParameters:
subject(optional): Certificate Common Name (e.g., domain name)alt_names(optional): Subject Alternative Namesusage_ra_tls(optional): Embed TDX quote in a certificate extension (defaultFalse)usage_server_auth(optional): Enable for server authentication (defaultTrue)usage_client_auth(optional): Enable for client authentication (defaultFalse)not_before/not_after(optional, kw-only): Validity window in seconds since UNIX epoch. Requires dstack OS >= 0.5.7.with_app_info(optional, kw-only): Embed app identity into the certificate. Requires dstack OS >= 0.5.7.
When any of the 0.5.7-only options is set, the SDK probes Version first and raises RuntimeError on older guest agents that lack it.
Returns: GetTlsKeyResponse
key: PEM-encoded private keycertificate_chain: List of PEM certificatesas_uint8array(max_length=None): Returns the DER-encoded private key bytes (handy when feeding key material into low-level crypto libraries)
Both are frozen v0 RPCs and neither has a v1 counterpart.
result = v0.sign('ed25519', b'message to sign')
verdict = v0.verify(
'ed25519',
b'message to sign',
result.decode_signature(),
result.decode_public_key(),
)
assert verdict.valid is Truesign() Parameters:
algorithm:'ed25519','secp256k1'(alias'k256'), or'secp256k1_prehashed'data: Data to sign (bytesorstr). Forsecp256k1_prehashed, must be a 32-byte digest.
sign() Returns: SignResponse
signature: Hex-encoded signaturepublic_key: Hex-encoded public keysignature_chain: Three signatures linking the signing key back to the KMS root
verify() Returns: VerifyResponse with a single valid: bool. It reports
only whether that one signature matches that one public key — it says nothing
about whose key it is, and it does not walk the signature chain.
Earlier drafts of this SDK shipped local verify_signature and
verify_signature_chain helpers. They are gone; verify locally per
docs/guest-api-v1.md.
v0.version() # VersionResponse(version, rev) — raises on OS < 0.5.7
v0.is_reachable() # Quick connectivity probe; never raisesAsyncDstackClientV0 has the identical surface, with every method a coroutine:
import asyncio
from dstack_sdk import AsyncDstackClientV0
async def main():
v0 = AsyncDstackClientV0()
info = await v0.info()
key = await v0.get_key('wallet/eth')
# Run requests concurrently
keys = await asyncio.gather(
v0.get_key('user/alice'),
v0.get_key('user/bob'),
)
asyncio.run(main())emit_event() is still on the client, but the agent now fails every call:
runtime RTMR3 events are system-owned and an app can no longer extend them. The
method remains so that a caller written against 0.5.x gets the agent's own
explanation rather than a 404. attest_gpu() and gpu_info() never shipped on
this surface and are not here; they live on the v1 client.
The chain adapters are v0-era. dstack_sdk.ethereum and dstack_sdk.solana
take a v0 GetKeyResponse or GetTlsKeyResponse, and that is the only shape
they take: v1 has no chain-related surface. get_key() returns key material,
and what an application builds out of those bytes is its own business.
from dstack_sdk import DstackClientV0
from dstack_sdk.ethereum import to_account_secure
from dstack_sdk.solana import to_keypair_secure
v0 = DstackClientV0()
account = to_account_secure(v0.get_key('wallet/ethereum'))
print(account.address)
keypair = to_keypair_secure(
v0.get_key('wallet/solana', purpose='mainnet', algorithm='ed25519')
)
print(keypair.pubkey())to_account_secure / to_keypair_secure hash the full key material with
SHA-256 before deriving. The legacy to_account() / to_keypair() use raw key
bytes and are kept only for backward compatibility.
TappdClient only ever spoke v0, so replace it with DstackClientV0 —
not with the unsuffixed name, which is now v1 and derives different keys:
# Before
from dstack_sdk import TappdClient
client = TappdClient()
# After
from dstack_sdk import DstackClientV0
v0 = DstackClientV0()Method changes:
derive_key()→get_tls_key()for TLS certificatestdx_quote()→get_quote()(raw data only, no hash algorithms)- Socket path:
/var/run/tappd.sock→/var/run/dstack.sock
v1 is a separate surface, not an upgrade path — read the key warning at the top before switching anything that holds state.
| v0 | v1 | Note |
|---|---|---|
get_tls_key() |
issue_cert() |
Renamed; same behaviour |
get_key(path, purpose) |
get_key(domain, algorithm) |
Merged into one KDF input; algorithm is now required |
get_quote() |
attest() |
The TDX-only channel is subsumed |
info().tcb_info |
— | Measurements are typed fields; the rest belongs to attest() |
info().app_cert |
— | A dashboard artifact; it proved nothing |
sign() |
— | Sign locally with the key get_key() returns |
verify() |
— | Verify locally, per docs/guest-api-v1.md |
emit_event() |
— | RTMR3 is system-owned as of 0.6.0 |
gpu_info() |
attest() with include_boottime_gpu_evidence=True |
Same bytes, now on the attestation call, in attest_gpu()'s bundle shape |
Apache License 2.0