From 61cbefdc760453952334f6a0102e48426d3d57f6 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Tue, 10 Mar 2026 12:51:03 +0000 Subject: [PATCH 1/3] feat: add dstack TEE support for confidential deployment - Key provider: Auto/Static/Dstack key resolution with Unix socket client - Column encryption: AES-256-GCM with version-byte prefix for gradual migration - Attestation endpoint: GET /attestation returns TDX quote in TEE mode - Docker: CARGO_FEATURES build arg + compose files for 3 dstack topologies - Feature-gated: `cargo build --features dstack` enables TEE support --- Cargo.lock | 13 +- Cargo.toml | 5 + Dockerfile | 7 +- docker-compose.dstack-full.yaml | 40 ++++++ docker-compose.dstack-postgres.yaml | 27 ++++ docker-compose.dstack.yaml | 31 +++++ src/config.rs | 35 ++++- src/dstack.rs | 142 ++++++++++++++++++++ src/lib.rs | 88 ++++++++++++- src/routes/attestation.rs | 63 +++++++++ src/routes/mod.rs | 7 +- src/tee.rs | 41 ++++++ tinycloud-core/Cargo.toml | 2 + tinycloud-core/src/db.rs | 80 +++++++++--- tinycloud-core/src/encryption.rs | 166 ++++++++++++++++++++++++ tinycloud-core/src/lib.rs | 2 + tinycloud-core/src/models/delegation.rs | 11 +- tinycloud-core/src/models/invocation.rs | 11 +- 18 files changed, 736 insertions(+), 35 deletions(-) create mode 100644 docker-compose.dstack-full.yaml create mode 100644 docker-compose.dstack-postgres.yaml create mode 100644 docker-compose.dstack.yaml create mode 100644 src/dstack.rs create mode 100644 src/routes/attestation.rs create mode 100644 src/tee.rs create mode 100644 tinycloud-core/src/encryption.rs diff --git a/Cargo.lock b/Cargo.lock index bb211d5..516c59a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9176,7 +9176,7 @@ dependencies = [ [[package]] name = "tinycloud" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "aws-config", @@ -9201,6 +9201,7 @@ dependencies = [ "serde_with 3.16.1", "tempfile", "thiserror 2.0.18", + "time", "tinycloud-core", "tinycloud-lib", "tokio", @@ -9214,8 +9215,9 @@ dependencies = [ [[package]] name = "tinycloud-core" -version = "1.0.0" +version = "1.1.0" dependencies = [ + "aes-gcm", "arrow", "async-std", "dashmap", @@ -9224,6 +9226,7 @@ dependencies = [ "libp2p", "multihash-derive 0.9.1", "pin-project", + "rand 0.8.5", "rusqlite", "sea-orm", "sea-orm-migration", @@ -9242,7 +9245,7 @@ dependencies = [ [[package]] name = "tinycloud-lib" -version = "1.0.0" +version = "1.1.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -9266,7 +9269,7 @@ dependencies = [ [[package]] name = "tinycloud-sdk-rs" -version = "1.0.0" +version = "1.1.0" dependencies = [ "async-trait", "hex", @@ -9284,7 +9287,7 @@ dependencies = [ [[package]] name = "tinycloud-sdk-wasm" -version = "1.0.0" +version = "1.1.0" dependencies = [ "aes-gcm", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 6022a51..79f2cfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_with = { version = "3.0", features = ["hex"] } thiserror = "2.0" +time.workspace = true tempfile = "3" tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } tokio-stream = { version = "0.1", features = ["fs"] } @@ -46,6 +47,10 @@ features = ["sqlite", "postgres", "mysql", "tokio"] [dependencies.tinycloud-lib] path = "tinycloud-lib/" +[features] +default = [] +dstack = [] + [workspace] members = [ diff --git a/Dockerfile b/Dockerfile index add4e95..f4e1979 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ # Build argument to select runtime base (default scratch) ARG RUNTIME_BASE=scratch +# Optional: pass "dstack" to enable TEE support +ARG CARGO_FEATURES="" FROM rust:alpine AS chef RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static g++ @@ -21,15 +23,16 @@ COPY ./scripts/ ./scripts/ RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder +ARG CARGO_FEATURES="" COPY --from=planner /app/recipe.json recipe.json RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ - cargo chef cook --release --recipe-path recipe.json + cargo chef cook --release --recipe-path recipe.json ${CARGO_FEATURES:+--features $CARGO_FEATURES} COPY --from=planner /app/ ./ RUN chmod +x ./scripts/init-tinycloud-data.sh && ./scripts/init-tinycloud-data.sh RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ - cargo build --release --bin tinycloud && \ + cargo build --release --bin tinycloud ${CARGO_FEATURES:+--features $CARGO_FEATURES} && \ cp /app/target/release/tinycloud /app/tinycloud RUN addgroup -g 1000 tinycloud && adduser -u 1000 -G tinycloud -s /bin/sh -D tinycloud diff --git a/docker-compose.dstack-full.yaml b/docker-compose.dstack-full.yaml new file mode 100644 index 0000000..f4092cd --- /dev/null +++ b/docker-compose.dstack-full.yaml @@ -0,0 +1,40 @@ +# dstack deployment - Topology 3: Full Confidential (Postgres in CVM) +# Both tinycloud-node and Postgres run inside the same CVM. +# Maximum confidentiality - no external database exposure. + +services: + tinycloud: + image: ghcr.io/tinycloudlabs/tinycloud-node:latest + ports: + - "8000:8000" + - "8001:8001" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock + environment: + TINYCLOUD_KEYS_TYPE: Dstack + TINYCLOUD_STORAGE_DATABASE: "postgres://tinycloud:tinycloud@postgres:5432/tinycloud" + TINYCLOUD_STORAGE_BLOCKS_TYPE: Local + TINYCLOUD_STORAGE_BLOCKS_PATH: /data/blocks + TINYCLOUD_LOG_LEVEL: info + TINYCLOUD_CORS: "true" + ROCKET_ADDRESS: "0.0.0.0" + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:16 + environment: + POSTGRES_USER: tinycloud + POSTGRES_PASSWORD: tinycloud + POSTGRES_DB: tinycloud + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tinycloud"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/docker-compose.dstack-postgres.yaml b/docker-compose.dstack-postgres.yaml new file mode 100644 index 0000000..60aec78 --- /dev/null +++ b/docker-compose.dstack-postgres.yaml @@ -0,0 +1,27 @@ +# dstack deployment - Topology 2: Postgres + S3 +# tinycloud-node in the CVM, Postgres external. +# Database credentials encrypted by dstack KMS. +# Column encryption enabled for sensitive data. +# +# All ${...} variables should be encrypted using dstack's KMS public key +# before deployment. The operator never sees plaintext values. + +services: + tinycloud: + image: ghcr.io/tinycloudlabs/tinycloud-node:latest + ports: + - "8000:8000" + - "8001:8001" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock + environment: + TINYCLOUD_KEYS_TYPE: Dstack + TINYCLOUD_STORAGE_DATABASE: "${DATABASE_URL}" + TINYCLOUD_STORAGE_BLOCKS_TYPE: S3 + TINYCLOUD_STORAGE_BLOCKS_BUCKET: "${S3_BUCKET}" + TINYCLOUD_STORAGE_BLOCKS_ENDPOINT: "${S3_ENDPOINT}" + AWS_ACCESS_KEY_ID: "${AWS_KEY}" + AWS_SECRET_ACCESS_KEY: "${AWS_SECRET}" + TINYCLOUD_LOG_LEVEL: info + TINYCLOUD_CORS: "true" + ROCKET_ADDRESS: "0.0.0.0" diff --git a/docker-compose.dstack.yaml b/docker-compose.dstack.yaml new file mode 100644 index 0000000..ab13411 --- /dev/null +++ b/docker-compose.dstack.yaml @@ -0,0 +1,31 @@ +# dstack deployment - Topology 1: SQLite + Local Storage +# Single container, everything in the CVM. Good for small/personal instances. +# +# Deploy on dstack: +# docker build --build-arg CARGO_FEATURES=dstack -t tinycloud-dstack . +# # Or use the pre-built image from ghcr.io +# +# The same image works on dstack (TEE) and classic (non-TEE) deployments. +# In TEE mode, keys are derived from dstack KMS - never stored, never exposed. +# In classic mode, set TINYCLOUD_KEYS_SECRET manually. + +services: + tinycloud: + image: ghcr.io/tinycloudlabs/tinycloud-node:latest + ports: + - "8000:8000" + - "8001:8001" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock + - tinycloud-data:/app/data + environment: + TINYCLOUD_KEYS_TYPE: Dstack + TINYCLOUD_STORAGE_DATABASE: "sqlite:./data/caps.db?mode=rwc" + TINYCLOUD_STORAGE_BLOCKS_TYPE: Local + TINYCLOUD_STORAGE_BLOCKS_PATH: ./data/blocks + TINYCLOUD_LOG_LEVEL: info + TINYCLOUD_CORS: "true" + ROCKET_ADDRESS: "0.0.0.0" + +volumes: + tinycloud-data: diff --git a/src/config.rs b/src/config.rs index ff97786..ff4e280 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,8 @@ pub struct Config { pub cors: bool, pub keys: Keys, #[serde(default)] + pub tee: TeeConfig, + #[serde(default)] pub public_spaces: PublicSpacesConfig, } @@ -57,15 +59,42 @@ impl Default for PublicSpacesConfig { } } -#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq, Default)] #[serde(tag = "type")] pub enum Keys { Static(Static), + #[cfg(feature = "dstack")] + Dstack, + #[default] + Auto, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)] +pub struct TeeConfig { + #[serde(default = "default_tee_mode")] + pub mode: TeeMode, + #[serde(default)] + pub attestation: bool, } -impl Default for Keys { +#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq, Default)] +pub enum TeeMode { + #[default] + Auto, + Dstack, + Off, +} + +fn default_tee_mode() -> TeeMode { + TeeMode::Auto +} + +impl Default for TeeConfig { fn default() -> Self { - Self::Static(Static::default()) + Self { + mode: TeeMode::Auto, + attestation: false, + } } } diff --git a/src/dstack.rs b/src/dstack.rs new file mode 100644 index 0000000..2e1b2c0 --- /dev/null +++ b/src/dstack.rs @@ -0,0 +1,142 @@ +//! Minimal dstack TEE client. +//! +//! Communicates with the dstack daemon over a Unix domain socket using raw +//! HTTP/1.1. The default socket path is `/var/run/dstack.sock` but can be +//! overridden via the `DSTACK_SIMULATOR_ENDPOINT` environment variable. + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; + +const DEFAULT_SOCKET: &str = "/var/run/dstack.sock"; + +fn socket_path() -> String { + std::env::var("DSTACK_SIMULATOR_ENDPOINT").unwrap_or_else(|_| DEFAULT_SOCKET.to_string()) +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct GetKeyResponse { + #[serde(rename = "asBytes")] + as_bytes: String, // hex-encoded +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetQuoteResponse { + pub quote: String, // hex-encoded TDX quote + pub event_log: String, // hex-encoded event log +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InfoResponse { + pub app_id: String, + pub compose_hash: String, + pub instance_id: String, +} + +// --------------------------------------------------------------------------- +// Raw HTTP helpers +// --------------------------------------------------------------------------- + +/// Parse the body from a raw HTTP/1.1 response (everything after `\r\n\r\n`). +fn parse_response_body(raw: &[u8]) -> Result<&[u8]> { + let header_end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| anyhow!("invalid HTTP response: missing header terminator"))?; + Ok(&raw[header_end + 4..]) +} + +async fn post_json(path: &str, body: &serde_json::Value) -> Result { + let socket = socket_path(); + let mut stream = UnixStream::connect(&socket) + .await + .with_context(|| format!("connecting to dstack socket at {socket}"))?; + + let body_bytes = serde_json::to_vec(body)?; + let request = format!( + "POST {path} HTTP/1.1\r\n\ + Host: localhost\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + body_bytes.len() + ); + stream.write_all(request.as_bytes()).await?; + stream.write_all(&body_bytes).await?; + stream.shutdown().await?; + + let mut response = Vec::new(); + stream.read_to_end(&mut response).await?; + + let body = parse_response_body(&response)?; + serde_json::from_slice(body).context("parsing dstack JSON response") +} + +async fn get_json(path: &str) -> Result { + let socket = socket_path(); + let mut stream = UnixStream::connect(&socket) + .await + .with_context(|| format!("connecting to dstack socket at {socket}"))?; + + let request = format!( + "GET {path} HTTP/1.1\r\n\ + Host: localhost\r\n\ + Connection: close\r\n\ + \r\n" + ); + stream.write_all(request.as_bytes()).await?; + stream.shutdown().await?; + + let mut response = Vec::new(); + stream.read_to_end(&mut response).await?; + + let body = parse_response_body(&response)?; + serde_json::from_slice(body).context("parsing dstack JSON response") +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Derive a deterministic key from the dstack TEE. +/// +/// `path` is a hierarchical key derivation path, e.g. `"tinycloud/keys/primary"`. +/// Returns the raw key bytes. +pub async fn get_key(path: &str) -> Result> { + let body = serde_json::json!({ "path": path }); + let resp: GetKeyResponse = serde_json::from_value( + post_json("/GetKey", &body) + .await + .context("dstack GetKey request")?, + ) + .context("parsing GetKey response")?; + hex::decode(&resp.as_bytes).context("decoding hex key bytes from dstack") +} + +/// Request a TDX attestation quote from the TEE. +pub async fn get_quote(report_data: &[u8]) -> Result { + let body = serde_json::json!({ "report_data": hex::encode(report_data) }); + serde_json::from_value( + post_json("/GetQuote", &body) + .await + .context("dstack GetQuote request")?, + ) + .context("parsing GetQuote response") +} + +/// Retrieve TEE instance information. +pub async fn get_info() -> Result { + serde_json::from_value(get_json("/Info").await.context("dstack Info request")?) + .context("parsing Info response") +} + +/// Check whether the dstack socket is reachable. +pub fn is_available() -> bool { + std::path::Path::new(&socket_path()).exists() +} diff --git a/src/lib.rs b/src/lib.rs index ebbb4db..c43c12a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,13 +13,17 @@ pub mod allow_list; pub mod auth_guards; pub mod authorization; pub mod config; +#[cfg(feature = "dstack")] +pub mod dstack; pub mod prometheus; pub mod routes; pub mod storage; +pub mod tee; mod tracing; use config::{BlockStorage, Config, Keys, StagingStorage}; use routes::{ + attestation::attestation, delegate, invoke, open_host_key, public::{public_kv_get, public_kv_head, public_kv_list, public_kv_options, RateLimiter}, util_routes::*, @@ -29,6 +33,7 @@ use storage::{ file_system::{FileSystemConfig, FileSystemStore, TempFileSystemStage}, s3::{S3BlockConfig, S3BlockStore}, }; +use tee::TeeContext; use tinycloud_core::{ duckdb::DuckDbService, keys::{SecretsSetup, StaticSecret}, @@ -101,10 +106,42 @@ pub async fn app(config: &Figment) -> Result> { public_kv_head, public_kv_list, public_kv_options, + attestation, ]; - let key_setup: StaticSecret = match tinycloud_config.keys { - Keys::Static(s) => s.try_into()?, + let key_setup: StaticSecret = resolve_keys(&tinycloud_config.keys).await?; + + // Initialize TEE context if running in dstack mode + let tee_context: Option = { + #[cfg(feature = "dstack")] + { + if dstack::is_available() { + match dstack::get_info().await { + Ok(info) => { + ::tracing::info!( + app_id = %info.app_id, + compose_hash = %info.compose_hash, + "Running in dstack TEE mode" + ); + Some(TeeContext { + app_id: info.app_id, + compose_hash: info.compose_hash, + instance_id: info.instance_id, + }) + } + Err(e) => { + ::tracing::warn!("dstack socket available but get_info failed: {}", e); + None + } + } + } else { + None + } + } + #[cfg(not(feature = "dstack"))] + { + None + } }; let mut connect_opts = ConnectOptions::from(&tinycloud_config.storage.database); @@ -159,6 +196,7 @@ pub async fn app(config: &Figment) -> Result> { .manage(sql_service) .manage(duckdb_service) .manage(rate_limiter) + .manage(tee_context) .manage(tinycloud_config.storage.staging.open().await?); if tinycloud_config.cors { @@ -188,6 +226,52 @@ pub async fn app(config: &Figment) -> Result> { } } +async fn resolve_keys(keys: &Keys) -> Result { + match keys { + Keys::Static(s) => Ok(s.clone().try_into()?), + #[cfg(feature = "dstack")] + Keys::Dstack => { + let key_bytes = dstack::get_key("tinycloud/keys/primary").await?; + StaticSecret::new(key_bytes) + .map_err(|v| anyhow::anyhow!("dstack key too short: {} bytes", v.len())) + } + Keys::Auto => { + // Check TINYCLOUD_TEE_MODE env var first + match std::env::var("TINYCLOUD_TEE_MODE").ok().as_deref() { + #[cfg(feature = "dstack")] + Some("dstack") => { + let key_bytes = dstack::get_key("tinycloud/keys/primary").await?; + StaticSecret::new(key_bytes) + .map_err(|v| anyhow::anyhow!("dstack key too short: {} bytes", v.len())) + } + Some("off") => { + anyhow::bail!( + "TEE mode disabled but no static key configured. \ + Set TINYCLOUD_KEYS_SECRET or configure [keys] in config." + ) + } + _ => { + // Auto-detect: check for dstack socket + #[cfg(feature = "dstack")] + if dstack::is_available() { + ::tracing::info!("dstack socket detected, using TEE key derivation"); + let key_bytes = dstack::get_key("tinycloud/keys/primary").await?; + return StaticSecret::new(key_bytes).map_err(|v| { + anyhow::anyhow!("dstack key too short: {} bytes", v.len()) + }); + } + anyhow::bail!( + "No key source configured. Either:\n \ + - Set TINYCLOUD_KEYS_SECRET environment variable\n \ + - Configure [keys] section in tinycloud.toml\n \ + - Run inside a dstack TEE (with 'dstack' feature enabled)" + ) + } + } + } + } +} + /// Ensure local storage directories exist before connecting. /// /// For local resources (SQLite file paths, filesystem dirs), create them diff --git a/src/routes/attestation.rs b/src/routes/attestation.rs new file mode 100644 index 0000000..9adb694 --- /dev/null +++ b/src/routes/attestation.rs @@ -0,0 +1,63 @@ +use rocket::serde::json::Json; +use rocket::State; + +use crate::tee::{AttestationResponse, TeeContext}; + +/// Get attestation information about this server instance. +/// +/// In TEE mode (dstack), returns a TDX quote that can be verified +/// against dstack-verifier to cryptographically prove: +/// - The server runs on genuine Intel TDX hardware +/// - The exact Docker image and config match the published compose hash +/// - Keys are derived deterministically in hardware +/// +/// In classic mode, returns a simple response indicating no TEE is available. +/// +/// An optional `nonce` parameter can be included to prevent replay attacks. +#[get("/attestation?")] +pub async fn attestation( + tee: &State>, + nonce: Option, +) -> Json { + match tee.inner() { + Some(ctx) => { + // In TEE mode, get a fresh quote from dstack + #[cfg(feature = "dstack")] + { + let report_data = nonce.as_deref().unwrap_or("").as_bytes().to_vec(); + + match crate::dstack::get_quote(&report_data).await { + Ok(quote_resp) => Json(AttestationResponse::Dstack { + quote: quote_resp.quote, + event_log: quote_resp.event_log, + compose_hash: ctx.compose_hash.clone(), + app_id: ctx.app_id.clone(), + timestamp: time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default(), + }), + Err(e) => { + tracing::error!("Failed to get TDX quote: {}", e); + Json(AttestationResponse::Classic { + message: format!( + "TEE context available but quote generation failed: {}", + e + ), + }) + } + } + } + #[cfg(not(feature = "dstack"))] + { + let _ = nonce; // suppress unused warning + let _ = ctx; + Json(AttestationResponse::Classic { + message: "TEE context available but dstack feature not compiled in".to_string(), + }) + } + } + None => Json(AttestationResponse::Classic { + message: "This instance is not running in a TEE".to_string(), + }), + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index be38ecb..737588a 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -25,6 +25,7 @@ use tinycloud_core::{ InvocationOutcome, TransactResult, TxError, TxStoreError, }; +pub mod attestation; pub mod public; pub mod util; use util::LimitedReader; @@ -38,10 +39,14 @@ pub struct VersionInfo { #[get("/version")] pub fn version() -> Json { + #[allow(unused_mut)] + let mut features = vec!["kv", "delegation", "sharing", "sql", "duckdb"]; + #[cfg(feature = "dstack")] + features.push("tee"); Json(VersionInfo { protocol: tinycloud_lib::protocol::PROTOCOL_VERSION, version: env!("CARGO_PKG_VERSION").to_string(), - features: vec!["kv", "delegation", "sharing", "sql", "duckdb"], + features, }) } diff --git a/src/tee.rs b/src/tee.rs new file mode 100644 index 0000000..74aa4c5 --- /dev/null +++ b/src/tee.rs @@ -0,0 +1,41 @@ +//! TEE (Trusted Execution Environment) context and utilities. +//! +//! In dstack mode, this module provides attestation and identity information +//! about the running TEE instance. In classic mode, these are None/absent. + +use serde::{Deserialize, Serialize}; + +/// Runtime context for TEE mode. +/// Populated at startup via `dstack::get_info()` when running inside a TEE. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TeeContext { + /// dstack application identifier + pub app_id: String, + /// SHA256 hash of the app-compose.json configuration + pub compose_hash: String, + /// Unique instance identifier + pub instance_id: String, +} + +/// Attestation response returned by the /attestation endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "mode")] +pub enum AttestationResponse { + /// TEE mode: includes TDX quote and app identity + #[serde(rename = "dstack")] + Dstack { + /// Hex-encoded TDX quote + quote: String, + /// Hex-encoded event log + event_log: String, + /// SHA256 of app-compose.json + compose_hash: String, + /// dstack app identifier + app_id: String, + /// ISO 8601 timestamp + timestamp: String, + }, + /// Classic mode: no TEE available + #[serde(rename = "classic")] + Classic { message: String }, +} diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 9d1746f..a512d25 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -36,6 +36,8 @@ tokio.workspace = true duckdb = { version = "1.1", features = ["bundled", "appender-arrow"] } arrow = { version = "56", features = ["ipc"] } tempfile = "3" +aes-gcm = "0.10" +rand = "0.8" [dev-dependencies] sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-sqlite"] } diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 4b06f93..a2d3342 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -1,3 +1,4 @@ +use crate::encryption::ColumnEncryption; use crate::events::{epoch_hash, Delegation, Event, HashError, Invocation, Operation, Revocation}; use crate::hash::Hash; use crate::keys::{get_did_key, Secrets}; @@ -29,6 +30,7 @@ pub struct SpaceDatabase { conn: C, storage: B, secrets: S, + encryption: Option, } #[derive(Debug, Clone)] @@ -75,6 +77,8 @@ pub enum TxError { SpaceNotFound, #[error("Invalid delegation CID: {0}")] InvalidCid(String), + #[error("encryption error: {0}")] + Encryption(#[from] crate::encryption::EncryptionError), } #[non_exhaustive] @@ -119,8 +123,14 @@ impl SpaceDatabase { conn, storage, secrets, + encryption: None, }) } + + pub fn with_encryption(mut self, encryption: Option) -> Self { + self.encryption = encryption; + self + } } impl SpaceDatabase @@ -208,7 +218,14 @@ where .begin_with_config(Some(sea_orm::IsolationLevel::ReadUncommitted), None) .await?; - let result = transact(&tx, &self.storage, &self.secrets, events).await?; + let result = transact( + &tx, + &self.storage, + &self.secrets, + events, + self.encryption.as_ref(), + ) + .await?; tx.commit().await?; @@ -303,6 +320,7 @@ where &self.storage, &self.secrets, vec![Event::Invocation(Box::new(invocation), ops)], + self.encryption.as_ref(), ) .await?; @@ -359,20 +377,32 @@ where None => { // Backward compatible: no params means return all valid delegations results.push(InvocationOutcome::OpenSessions( - get_valid_delegations(&tx, space).await?, + get_valid_delegations(&tx, space, self.encryption.as_ref()).await?, )) } Some(CapabilitiesReadParams::List { filters }) => { // List with optional filters results.push(InvocationOutcome::OpenSessions( - get_filtered_delegations(&tx, space, &invoker, filters.as_ref()) - .await?, + get_filtered_delegations( + &tx, + space, + &invoker, + filters.as_ref(), + self.encryption.as_ref(), + ) + .await?, )) } Some(CapabilitiesReadParams::Chain { delegation_cid }) => { // Get the delegation chain for a specific delegation results.push(InvocationOutcome::DelegationChain( - get_delegation_chain(&tx, space, delegation_cid).await?, + get_delegation_chain( + &tx, + space, + delegation_cid, + self.encryption.as_ref(), + ) + .await?, )) } } @@ -485,6 +515,7 @@ pub(crate) async fn transact( store_setup: &S, secrets: &K, events: Vec, + encryption: Option<&ColumnEncryption>, ) -> Result> { // for each event, get the hash and the relevent space(s) let event_hashes = events @@ -729,7 +760,7 @@ pub(crate) async fn transact( for (hash, event) in event_hashes { match event { Event::Delegation(d) => { - let cid = delegation::process(db, *d).await?; + let cid = delegation::process(db, *d, encryption).await?; delegation_cids.push(cid); } Event::Invocation(i, ops) => { @@ -745,6 +776,7 @@ pub(crate) async fn transact( op.version(*v.0, *v.1, *v.2) }) .collect(), + encryption, ) .await?; } @@ -790,7 +822,7 @@ pub(crate) async fn transact( for (_, event) in event_hashes { match event { Event::Delegation(d) => { - let cid = delegation::process(db, *d).await?; + let cid = delegation::process(db, *d, encryption).await?; delegation_cids.push(cid); } Event::Invocation(_, _) | Event::Revocation(_) => { @@ -919,6 +951,7 @@ async fn get_kv_entity( async fn get_valid_delegations( db: &C, space_id: &SpaceId, + encryption: Option<&ColumnEncryption>, ) -> Result, TxError> { let (dels, abilities): (Vec, Vec>) = delegation::Entity::find() @@ -931,8 +964,7 @@ async fn get_valid_delegations( .unzip(); let parents = dels.load_many(parent_delegations::Entity, db).await?; let now = time::OffsetDateTime::now_utc(); - Ok(dels - .into_iter() + dels.into_iter() .zip(abilities) .zip(parents) .filter_map(|((del, ability), parents)| { @@ -940,7 +972,12 @@ async fn get_valid_delegations( && del.not_before.map(|n| n <= now).unwrap_or(true) && ability.iter().any(|a| a.resource.space() == Some(space_id)) { - Some(match TinyCloudDelegation::from_bytes(&del.serialization) { + let serialization = + match crate::encryption::maybe_decrypt(encryption, &del.serialization) { + Ok(s) => s, + Err(e) => return Some(Err(TxError::Encryption(e))), + }; + Some(match TinyCloudDelegation::from_bytes(&serialization) { Ok(delegation) => Ok(( del.id, DelegationInfo { @@ -960,13 +997,13 @@ async fn get_valid_delegations( delegation, }, )), - Err(e) => Err(e), + Err(e) => Err(TxError::Encoding(e)), }) } else { None } }) - .collect::, EncodingError>>()?) + .collect::, TxError>>() } /// Resolve a session key DID (did:key:...) to its root PKH DID (did:pkh:...). @@ -1025,6 +1062,7 @@ async fn get_filtered_delegations, + encryption: Option<&ColumnEncryption>, ) -> Result, TxError> { // Resolve session key DID to PKH DID for direction filtering let pkh_did = resolve_pkh_did(db, invoker) @@ -1048,8 +1086,7 @@ async fn get_filtered_delegations s, + Err(e) => return Some(Err(TxError::Encryption(e))), + }; + Some(match TinyCloudDelegation::from_bytes(&serialization) { Ok(delegation) => Ok(( del.id, DelegationInfo { @@ -1118,10 +1160,10 @@ async fn get_filtered_delegations Err(e), + Err(e) => Err(TxError::Encoding(e)), }) }) - .collect::, EncodingError>>()?) + .collect::, TxError>>() } /// Get the delegation chain for a specific delegation, ordered from leaf to root. @@ -1130,6 +1172,7 @@ async fn get_delegation_chain( db: &C, space_id: &SpaceId, delegation_cid: &str, + encryption: Option<&ColumnEncryption>, ) -> Result, TxError> { use tinycloud_lib::ipld_core::cid::Cid; @@ -1180,7 +1223,8 @@ async fn get_delegation_chain( let parent_cids: Vec = parents.iter().map(|p| p.parent.to_cid(0x55)).collect(); // Create DelegationInfo - let delegation = TinyCloudDelegation::from_bytes(&del.serialization)?; + let serialization = crate::encryption::maybe_decrypt(encryption, &del.serialization)?; + let delegation = TinyCloudDelegation::from_bytes(&serialization)?; let info = DelegationInfo { delegator: del.delegator, delegate: del.delegatee, diff --git a/tinycloud-core/src/encryption.rs b/tinycloud-core/src/encryption.rs new file mode 100644 index 0000000..984fb77 --- /dev/null +++ b/tinycloud-core/src/encryption.rs @@ -0,0 +1,166 @@ +//! Application-level column encryption using AES-256-GCM. +//! +//! Encrypted values are prefixed with a version byte: +//! - `0x01` followed by 12-byte nonce, ciphertext, and 16-byte GCM tag +//! - Any other first byte is treated as legacy plaintext (existing data) +//! +//! This allows gradual migration: new writes are encrypted, old reads still work. + +use aes_gcm::{ + aead::{Aead, KeyInit, OsRng}, + AeadCore, Aes256Gcm, Nonce, +}; + +const VERSION_ENCRYPTED: u8 = 0x01; + +#[derive(Clone)] +pub struct ColumnEncryption { + cipher: Aes256Gcm, +} + +impl std::fmt::Debug for ColumnEncryption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ColumnEncryption").finish_non_exhaustive() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum EncryptionError { + #[error("decryption failed: {0}")] + Decrypt(String), + #[error("encrypted data too short")] + TooShort, +} + +impl ColumnEncryption { + pub fn new(key: [u8; 32]) -> Self { + Self { + cipher: Aes256Gcm::new_from_slice(&key).expect("valid 32-byte key"), + } + } + + /// Encrypt plaintext. Returns: 0x01 || nonce(12B) || ciphertext || tag(16B) + pub fn encrypt(&self, plaintext: &[u8]) -> Vec { + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = self + .cipher + .encrypt(&nonce, plaintext) + .expect("encryption should not fail"); + let mut result = Vec::with_capacity(1 + 12 + ciphertext.len()); + result.push(VERSION_ENCRYPTED); + result.extend_from_slice(&nonce); + result.extend_from_slice(&ciphertext); + result + } + + /// Decrypt data. Handles version dispatch: + /// - 0x01: AES-256-GCM encrypted + /// - Anything else: return as-is (legacy plaintext) + pub fn decrypt(&self, data: &[u8]) -> Result, EncryptionError> { + if data.is_empty() { + return Ok(data.to_vec()); + } + if data[0] != VERSION_ENCRYPTED { + // Legacy plaintext - return as-is + return Ok(data.to_vec()); + } + // Encrypted: 0x01 || nonce(12) || ciphertext+tag + if data.len() < 1 + 12 + 16 { + return Err(EncryptionError::TooShort); + } + let nonce_bytes: [u8; 12] = data[1..13].try_into().expect("slice is 12 bytes"); + let nonce = Nonce::from(nonce_bytes); + let ciphertext = &data[13..]; + self.cipher + .decrypt(&nonce, ciphertext) + .map_err(|e| EncryptionError::Decrypt(e.to_string())) + } +} + +/// Helper: encrypt if encryption is configured, otherwise return plaintext. +pub fn maybe_encrypt(enc: Option<&ColumnEncryption>, data: &[u8]) -> Vec { + match enc { + Some(e) => e.encrypt(data), + None => data.to_vec(), + } +} + +/// Helper: decrypt if encryption is configured, otherwise return as-is. +pub fn maybe_decrypt( + enc: Option<&ColumnEncryption>, + data: &[u8], +) -> Result, EncryptionError> { + match enc { + Some(e) => e.decrypt(data), + None => Ok(data.to_vec()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key() -> [u8; 32] { + [0x42u8; 32] + } + + #[test] + fn round_trip() { + let enc = ColumnEncryption::new(test_key()); + let plaintext = b"hello world"; + let encrypted = enc.encrypt(plaintext); + assert_eq!(encrypted[0], VERSION_ENCRYPTED); + assert_ne!(&encrypted[1..], plaintext); + let decrypted = enc.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn legacy_plaintext_passthrough() { + let enc = ColumnEncryption::new(test_key()); + // CBOR starts with 0xa_, not 0x01 + let cbor_data = vec![0xa2, 0x01, 0x02, 0x03]; + let result = enc.decrypt(&cbor_data).unwrap(); + assert_eq!(result, cbor_data); + } + + #[test] + fn empty_data() { + let enc = ColumnEncryption::new(test_key()); + let result = enc.decrypt(&[]).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn wrong_key_fails() { + let enc1 = ColumnEncryption::new([0x42u8; 32]); + let enc2 = ColumnEncryption::new([0x43u8; 32]); + let encrypted = enc1.encrypt(b"secret"); + assert!(enc2.decrypt(&encrypted).is_err()); + } + + #[test] + fn truncated_data_fails() { + let enc = ColumnEncryption::new(test_key()); + let encrypted = enc.encrypt(b"test"); + // Truncate to just version + partial nonce + assert!(enc.decrypt(&encrypted[..5]).is_err()); + } + + #[test] + fn maybe_helpers_none() { + let data = b"plaintext"; + assert_eq!(maybe_encrypt(None, data), data.to_vec()); + assert_eq!(maybe_decrypt(None, data).unwrap(), data.to_vec()); + } + + #[test] + fn maybe_helpers_some() { + let enc = ColumnEncryption::new(test_key()); + let plaintext = b"hello"; + let encrypted = maybe_encrypt(Some(&enc), plaintext); + assert_eq!(encrypted[0], VERSION_ENCRYPTED); + let decrypted = maybe_decrypt(Some(&enc), &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } +} diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index e4f8ebe..810880e 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod db; pub mod duckdb; +pub mod encryption; pub mod events; pub mod hash; pub mod keys; @@ -13,6 +14,7 @@ pub mod types; pub mod util; pub use db::{Commit, InvocationOutcome, SpaceDatabase, TransactResult, TxError, TxStoreError}; +pub use encryption::ColumnEncryption; pub use libp2p; pub use sea_orm; pub use sea_orm_migration; diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index e985080..86254d8 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -1,3 +1,4 @@ +use crate::encryption::ColumnEncryption; use crate::hash::Hash; use crate::types::{Ability, Facts, Resource}; use crate::{events::Delegation, models::*, relationships::*, util}; @@ -124,13 +125,14 @@ pub enum DelegationError { pub(crate) async fn process( db: &C, delegation: Delegation, + encryption: Option<&ColumnEncryption>, ) -> Result { let (d, ser) = (delegation.0, delegation.1); verify(&d.delegation).await?; validate(db, &d).await?; - save(db, d, ser).await + save(db, d, ser, encryption).await } // verify signatures and time @@ -264,11 +266,16 @@ async fn save( db: &C, delegation: util::DelegationInfo, serialization: Vec, + encryption: Option<&ColumnEncryption>, ) -> Result { save_actors(&[&delegation.delegator, &delegation.delegate], db).await?; + // Hash is always computed on plaintext (before encryption) let hash: Hash = crate::hash::hash(&serialization); + // Encrypt for storage if encryption is configured + let stored_serialization = crate::encryption::maybe_encrypt(encryption, &serialization); + // save delegation match Entity::insert(ActiveModel::from(Model { id: hash, @@ -278,7 +285,7 @@ async fn save( issued_at: delegation.issued_at, not_before: delegation.not_before, facts: None, - serialization, + serialization: stored_serialization, })) .on_conflict(OnConflict::column(Column::Id).do_nothing().to_owned()) .exec(db) diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index ef7e806..2b03e5f 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -4,6 +4,7 @@ use super::super::{ relationships::*, util, }; +use crate::encryption::ColumnEncryption; use crate::types::{Facts, Resource, SpaceIdWrap}; use crate::{hash::Hash, types::Ability}; use sea_orm::{entity::prelude::*, sea_query::OnConflict, Condition, ConnectionTrait, QueryOrder}; @@ -77,6 +78,7 @@ pub(crate) async fn process( db: &C, invocation: Invocation, ops: Vec, + encryption: Option<&ColumnEncryption>, ) -> Result { let (i, serialized) = (invocation.0, invocation.1); verify(&i.invocation).await?; @@ -84,7 +86,7 @@ pub(crate) async fn process( let now = OffsetDateTime::now_utc(); validate(db, &i, Some(now)).await?; - save(db, i, Some(now), serialized, ops).await + save(db, i, Some(now), serialized, ops, encryption).await } async fn verify(invocation: &TinyCloudInvocation) -> Result<(), Error> { @@ -188,10 +190,15 @@ async fn save( time: Option, serialization: Vec, parameters: Vec, + encryption: Option<&ColumnEncryption>, ) -> Result { + // Hash is always computed on plaintext (before encryption) let hash = crate::hash::hash(&serialization); let issued_at = time.unwrap_or_else(OffsetDateTime::now_utc); + // Encrypt for storage if encryption is configured + let stored_serialization = crate::encryption::maybe_encrypt(encryption, &serialization); + // Ensure the invoker actor exists before inserting the invocation match actor::Entity::insert(actor::ActiveModel::from(actor::Model { id: invocation.invoker.clone(), @@ -213,7 +220,7 @@ async fn save( match Entity::insert(ActiveModel::from(Model { id: hash, issued_at, - serialization, + serialization: stored_serialization, facts: None, invoker: invocation.invoker, })) From c14e7ec824db9dc87ad82e4aeee84999ef535d5b Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Tue, 10 Mar 2026 12:52:54 +0000 Subject: [PATCH 2/3] feat: add inTEE flag to /version endpoint --- src/routes/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 737588a..22d9d68 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -35,10 +35,12 @@ pub struct VersionInfo { pub protocol: u32, pub version: String, pub features: Vec<&'static str>, + #[serde(rename = "inTEE")] + pub in_tee: bool, } #[get("/version")] -pub fn version() -> Json { +pub fn version(tee: &State>) -> Json { #[allow(unused_mut)] let mut features = vec!["kv", "delegation", "sharing", "sql", "duckdb"]; #[cfg(feature = "dstack")] @@ -47,6 +49,7 @@ pub fn version() -> Json { protocol: tinycloud_lib::protocol::PROTOCOL_VERSION, version: env!("CARGO_PKG_VERSION").to_string(), features, + in_tee: tee.inner().is_some(), }) } From a1f6e92e483f15a7e6d44a1ba20a3ce664c67ef7 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Tue, 10 Mar 2026 12:54:04 +0000 Subject: [PATCH 3/3] chore: add changeset for dstack TEE support --- .changeset/dstack-tee-support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dstack-tee-support.md diff --git a/.changeset/dstack-tee-support.md b/.changeset/dstack-tee-support.md new file mode 100644 index 0000000..27ac399 --- /dev/null +++ b/.changeset/dstack-tee-support.md @@ -0,0 +1,5 @@ +--- +"tinycloud": minor +--- + +Add dstack TEE support for confidential deployment. Keys can now be derived deterministically from TEE KMS, sensitive database columns are encrypted with AES-256-GCM, and a new `/attestation` endpoint provides TDX hardware attestation quotes. The `/version` endpoint now includes an `inTEE` flag. Enabled via `--features dstack`.