Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,38 @@ The SDK checks this endpoint during sign-in and requires an exact protocol versi
|--------|----------|------|-------------|
| `GET` | `/version` | No | Protocol version and feature discovery |
| `POST` | `/invoke` | Yes | Execute KV operations (get, put, list, delete) |
| `POST` | `/signed/kv` | Yes | Create an expiring signed URL for an exact KV object read |
| `GET` | `/signed/kv/<ticketId>` | Signed URL ticket | Fetch a KV object through a signed URL |
| `POST` | `/delegate` | Yes | Create capability delegations |
| `GET` | `/peer/generate/<space>` | No | Generate space host key pair |
| `GET` | `/healthz` | No | Health check |

### Signed KV URLs

`POST /signed/kv` accepts a normal TinyCloud invocation whose authorized capabilities include a `tinycloud.kv/get` capability that can be attenuated to the requested `{space, path}`. The invocation may include other capabilities. The JSON body is:

```json
{
"space": "tinycloud:...",
"path": "transcripts/audio.wav",
"ttlSeconds": 60
}
```

The response includes a short relative `url`, opaque `ticketId`, and RFC3339 `expiresAt`:

```json
{
"url": "/signed/kv/JUWkXuA4mqnxDVXcaxXoVME8uYUTumgmuwbllQFxHnQ",
"ticketId": "JUWkXuA4mqnxDVXcaxXoVME8uYUTumgmuwbllQFxHnQ",
"expiresAt": "2026-05-12T16:25:00Z"
}
```

The node stores the ticket durably in its database with the exact KV scope, issuer/subject DID, ability, service, creation and expiry timestamps, parent expiry metadata, optional hash/ETag binding, and proof metadata. The bearer URL does not contain the space, path, or signed claims. Reads load the ticket, validate expiry and scope, then perform a private KV read; signed KV URLs are not limited to public spaces.

Ticket expiry is the earliest of the requested TTL, node maximum TTL, invocation expiry, and parent delegation expiry when known. `maxUses` is rejected in v1 because limited-use URLs need durable counters and replay protection to stay correct across restarts and multi-node deployments.

## Quickstart

To run TinyCloud Protocol locally you will need the latest version of [rust](https://rustup.rs).
Expand Down
27 changes: 27 additions & 0 deletions tinycloud-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,25 @@ where
.await
}

pub async fn create_signed_kv_ticket(
&self,
model: signed_kv_ticket::Model,
) -> Result<signed_kv_ticket::Model, DbErr> {
signed_kv_ticket::Entity::insert(signed_kv_ticket::ActiveModel::from(model.clone()))
.exec(&self.conn)
.await?;
Ok(model)
}

pub async fn find_signed_kv_ticket(
&self,
ticket_id: &str,
) -> Result<Option<signed_kv_ticket::Model>, DbErr> {
signed_kv_ticket::Entity::find_by_id(ticket_id.to_string())
.one(&self.conn)
.await
}

pub async fn deactivate_hook_subscription(&self, subscription_id: &str) -> Result<(), DbErr> {
let Some(model) = hook_subscription::Entity::find_by_id(subscription_id.to_string())
.one(&self.conn)
Expand Down Expand Up @@ -411,6 +430,14 @@ where
&self,
space_id: &SpaceId,
key: &Path,
) -> Result<Option<(Metadata, Hash, Content<B::Readable>)>, EitherError<DbErr, B::Error>> {
self.kv_get(space_id, key).await
}

pub async fn kv_get(
&self,
space_id: &SpaceId,
key: &Path,
) -> Result<Option<(Metadata, Hash, Content<B::Readable>)>, EitherError<DbErr, B::Error>> {
get_kv(&self.conn, &self.storage, space_id, key).await
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use sea_orm_migration::prelude::*;

use crate::models::signed_kv_ticket;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(signed_kv_ticket::Entity)
.if_not_exists()
.col(
ColumnDef::new(signed_kv_ticket::Column::Id)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::IssuerDid)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::SubjectDid)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::SpaceId)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::Path)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::Service)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::Ability)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::CreatedAt)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::ExpiresAt)
.string()
.not_null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::InvocationExpiresAt)
.string()
.null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::ParentExpiresAt)
.string()
.null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::ContentHash)
.string()
.null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::Etag)
.string()
.null(),
)
.col(
ColumnDef::new(signed_kv_ticket::Column::ParentCidsJson)
.string()
.null(),
)
.primary_key(Index::create().col(signed_kv_ticket::Column::Id))
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(signed_kv_ticket::Entity).to_owned())
.await
}
}
2 changes: 2 additions & 0 deletions tinycloud-core/src/migrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use sea_orm_migration::prelude::*;
pub mod m20230510_101010_init_tables;
pub mod m20260218_sql_database;
pub mod m20260409_000000_hook_tables;
pub mod m20260512_000000_signed_kv_tickets;

pub struct Migrator;

Expand All @@ -12,6 +13,7 @@ impl MigratorTrait for Migrator {
Box::new(m20230510_101010_init_tables::Migration),
Box::new(m20260218_sql_database::Migration),
Box::new(m20260409_000000_hook_tables::Migration),
Box::new(m20260512_000000_signed_kv_tickets::Migration),
]
}
}
1 change: 1 addition & 0 deletions tinycloud-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ pub mod invocation;
pub mod kv_delete;
pub mod kv_write;
pub mod revocation;
pub mod signed_kv_ticket;
pub mod space;
26 changes: 26 additions & 0 deletions tinycloud-core/src/models/signed_kv_ticket.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "signed_kv_ticket")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, unique)]
pub id: String,
pub issuer_did: String,
pub subject_did: String,
pub space_id: String,
pub path: String,
pub service: String,
pub ability: String,
pub created_at: String,
pub expires_at: String,
pub invocation_expires_at: Option<String>,
pub parent_expires_at: Option<String>,
pub content_hash: Option<String>,
pub etag: Option<String>,
pub parent_cids_json: Option<String>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
1 change: 1 addition & 0 deletions tinycloud-node-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ opentelemetry_sdk = { version = "0.29.0", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.29.0", features = ["grpc-tonic", "trace"] }
pin-project = "1"
prometheus = { version = "0.13.0", features = ["process"] }
rand = "0.8"
reqwest = { version = "0.11", features = ["json"] }
rocket = { version = "0.5.1", features = ["json", "tls", "mtls"] }
subtle = "2"
Expand Down
9 changes: 8 additions & 1 deletion tinycloud-node-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub mod hooks;
pub mod prometheus;
pub mod quota;
pub mod routes;
pub mod signed_urls;
pub mod storage;
pub mod tee;
mod tracing;
Expand All @@ -30,10 +31,11 @@ use quota::QuotaCache;
use routes::{
admin::{delete_quota, get_quota, list_quotas, set_quota},
attestation::attestation,
delegate,
create_signed_kv_url, delegate,
hooks::{create_hook_ticket, create_webhook, delete_webhook, hook_events, list_webhooks},
info, invoke, open_host_key,
public::{public_kv_get, public_kv_head, public_kv_list, public_kv_options, RateLimiter},
signed_kv_get,
util_routes::*,
version,
};
Expand Down Expand Up @@ -113,6 +115,8 @@ pub async fn app(config: &Figment) -> Result<Rocket<Build>> {
open_host_key,
invoke,
delegate,
create_signed_kv_url,
signed_kv_get,
create_hook_ticket,
hook_events,
create_webhook,
Expand All @@ -136,6 +140,8 @@ pub async fn app(config: &Figment) -> Result<Rocket<Build>> {
tinycloud_config.hooks.clone(),
key_setup.derive_key(b"tinycloud/hooks/tickets"),
);
let signed_url_runtime =
signed_urls::SignedUrlRuntime::new(key_setup.derive_key(b"tinycloud/kv/signed-urls"));

// Initialize TEE context if running in dstack mode
let tee_context: Option<TeeContext> = {
Expand Down Expand Up @@ -241,6 +247,7 @@ pub async fn app(config: &Figment) -> Result<Rocket<Build>> {
.manage(duckdb_service)
.manage(quota_cache)
.manage(hook_runtime)
.manage(signed_url_runtime)
.manage(webhook_encryption)
.manage(rate_limiter)
.manage(tee_context)
Expand Down
59 changes: 57 additions & 2 deletions tinycloud-node-server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ use tokio_util::compat::TokioAsyncReadCompatExt;
use tracing::{info_span, Instrument};

use crate::{
auth_guards::{DataIn, DataOut, InvOut, ObjectHeaders},
auth_guards::{DataIn, DataOut, InvOut, KVResponse, ObjectHeaders},
authorization::AuthHeaderGetter,
config::Config,
hooks::{HookRuntime, WriteEvent},
quota::QuotaCache,
routes::public::is_public_space,
signed_urls::{
load_signed_kv_ticket, mint_signed_kv_url, validate_signed_kv_hash_binding,
validate_signed_kv_ticket, SignedKvUrlRequest, SignedKvUrlResponse, SignedUrlRuntime,
},
tracing::TracingSpan,
BlockStage, BlockStores, TinyCloud,
};
Expand Down Expand Up @@ -54,7 +58,15 @@ fn build_info(
quota_cache: &State<QuotaCache>,
) -> NodeInfo {
#[allow(unused_mut)]
let mut features = vec!["kv", "delegation", "sharing", "sql", "duckdb", "hooks"];
let mut features = vec![
"kv",
"delegation",
"sharing",
"sql",
"duckdb",
"hooks",
"signed-urls",
];
#[cfg(feature = "dstack")]
features.push("tee");
NodeInfo {
Expand Down Expand Up @@ -118,6 +130,49 @@ pub async fn open_host_key(
})
}

#[post("/signed/kv", format = "json", data = "<request>")]
pub async fn create_signed_kv_url(
invocation: AuthHeaderGetter<InvocationInfo>,
request: Json<SignedKvUrlRequest>,
runtime: &State<SignedUrlRuntime>,
tinycloud: &State<TinyCloud>,
) -> Result<Json<SignedKvUrlResponse>, (Status, String)> {
let invocation_info = invocation.0 .0.clone();
verify_auth(invocation.0, tinycloud).await?;
let response = mint_signed_kv_url(
&invocation_info,
request.into_inner(),
runtime.inner(),
tinycloud.inner(),
)
.await?;
Ok(Json(response))
}

#[get("/signed/kv/<ticket_id>")]
pub async fn signed_kv_get(
ticket_id: &str,
tinycloud: &State<TinyCloud>,
) -> Result<
KVResponse<tinycloud_core::storage::Content<<BlockStores as ImmutableReadStore>::Readable>>,
(Status, String),
> {
let ticket = load_signed_kv_ticket(tinycloud.inner(), ticket_id).await?;
let (space_id, key) = validate_signed_kv_ticket(&ticket)?;

match tinycloud
.kv_get(&space_id, &key)
.await
.map_err(|e| (Status::InternalServerError, e.to_string()))?
{
Some((md, hash, content)) => {
validate_signed_kv_hash_binding(&ticket, &hash)?;
Ok(KVResponse::new(md, hash, content))
}
None => Err((Status::NotFound, "Key not found".to_string())),
}
}

#[derive(Serialize)]
pub struct DelegateResponse {
pub cid: String,
Expand Down
Loading
Loading