Skip to content
Draft
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
12 changes: 12 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,20 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 5
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -u root"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
TINYCLOUD_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres
TINYCLOUD_TEST_MYSQL_URL: mysql://root@127.0.0.1:3306/mysql
# TC-1054: tests/postgres_pool_timeouts.rs black-holes chosen loopback
# connections with exact-4-tuple iptables DROP rules (the runner is a
# throwaway VM with passwordless sudo). Unset, they fail under CI.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Add signed, scoped and global key-principal revocation with durable receipts, policy authority witnesses, and ordered admission/invocation checks (TC-1149). Existing natural session-expiry behavior is unchanged; legacy witness backfill requires a count-only snapshot dry-run before release.
- CACAO/SIWE delegation ingestion normalizes ECDSA recovery IDs 0/1 to 27/28 before hashing and persisting the canonical DAG-CBOR bytes (TC-1071). Equivalent low-s signatures now share one delegation CID, including the CID used for revocation lookup. High-s signatures are rejected during decoding, matching the existing EIP-191 verifier and EIP-2 low-s convention; UCAN/JWS bytes remain unchanged. Existing stored delegation IDs are not rewritten.
- Synchronize the standalone M1, W5 and load-signer Cargo locks with CACAO's `k256` dependency so `--locked` dependency resolution succeeds (TC-1071). Keep the load signer compatible with the SDK's default invocation lifetime.

Expand Down
52 changes: 52 additions & 0 deletions docs/delegation-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,58 @@ support. On the same `utf8mb4`/default-strict configuration, unmodified
alias migration exists. That separate full-schema prerequisite must be resolved
and exercised before claiming end-to-end support for that configuration.

## Explicit Key Revocation and Policy Witness

`POST /keys/revoke` accepts JSON with `targetDid`, `scope` (`{"kind":"global"}`
or `{"kind":"space","spaceId":"..."}`), and `authorization`, a signed UCAN or
SIWE/CACAO addressed to this node. The signed capability names the same
target and scope. Global revocation requires the target key or its explicitly
enrolled global recovery delegate. A space owner or its scoped recovery
delegate may revoke a key only in that owner's space. Generic `/revoke`
continues to revoke a single delegation CID, not a whole key.

The signed policy-v3 JSON `/revoke` records one root's status, not a
key-wide cutoff. Fresh root events check the historical policy witness;
repeating the exact committed event remains idempotent.
Every witness-backed use checks the loaded roots' signed revocation status,
including an already-issued signed KV download ticket. This cutoff uses the
same root rows as provenance verification, without another query or lock.

`targetDid` must be a canonical DID with valid DID syntax, not a DID URL
or a delegation CID; an empty method-specific ID is rejected.

The committed response includes the target, scope, and original cutoff.
Repeating the exact signed request returns the same receipt even after the
signer is revoked or its recovery proof expires. Changed signed bytes with
the same nonce return `409` only for a still-authorized signer; a signer
without current target/scope authority receives `403`. First-use request
and recovery proof validity are checked after blocking authorization locks.

On a fresh recovery request, the node verifies the signed chain before
locking its persisted proof rows. It then locks every dependency principal
in canonical DID order, taking the target and request signer exclusively
and other chain principals shared on their first visit. No principal lock
is upgraded within the transaction; the locks cover the tombstone and
receipt commit.

The cutoff remains immutable; successful responses follow its durable commit.
Later policy mints, delegation admission, and invocations cannot use authority
derived from the revoked principal. Natural proof expiry is not an explicit
revoke.

Policy registration persists a versioned, signed-byte-bound witness for its
authority dependencies. The signed candidate limit counts relevant closures,
not unrelated incoming grants. Preexisting registrations receive a conservative
transactional backfill at startup; inconsistent or incomplete provenance
prevents serving rather than silently narrowing dependencies. Before release,
an operator runs `policy-witness-dry-run` with the node's configuration on a
production snapshot or read replica. It opens read-only and reports only
aggregate `ok`, `missing`, `ambiguous`, and `overflow` counts. Any unresolved
rows require a release decision; the startup backfill is not a substitute for
the dry-run gate. Owner-self classification remains `unknown/legacy` in this
phase; natural sign-in expiry retains existing behavior. Signed self-session
classification is tracked separately in TC-1213.

## Child Delegation (UCAN) Creation

For sharing, child UCANs are created that delegate from the session key to another DID.
Expand Down
3 changes: 3 additions & 0 deletions tinycloud-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ duckdb = ["dep:arrow", "dep:duckdb"]
# build. Downstream crates enable it only on their `tinycloud-core`
# dev-dependency so their own tests can assert exactly-once verification.
verification-count-test-hook = []
# Reconstructed pre-sunset signed Share-v2 fixtures only. Off by default so
# shipped authorization never accepts a caller-selected signature time.
historical-invocation-test-clock = []

tokio = [
"sea-orm/runtime-tokio-rustls",
Expand Down
112 changes: 111 additions & 1 deletion tinycloud-core/src/auth_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ use crate::relationships::parent_delegations;
use sea_orm::{
entity::prelude::*,
sea_query::{Alias, CommonTableExpression, Expr, JoinType, Query, UnionType, WithClause},
ConnectionTrait,
ConnectionTrait, DbErr,
};
use std::collections::{HashMap, HashSet};
use tinycloud_auth::identity::principal_did;

pub(crate) use crate::models::revocation::ChainTraversalError;
use crate::models::revocation::MAX_CHAIN_TRAVERSAL_NODES;
Expand All @@ -42,6 +43,115 @@ pub(crate) async fn load_chain_lock_ids<C: ConnectionTrait>(
load_closure(db, roots, true).await.map(|(ids, _)| ids)
}

/// The caller already loaded and bounded the signed proof closure. Resolve
/// its immutable signer/audience principals and canonical alias lock IDs in
/// one query; live revocation is still checked after acquiring those guards.
pub(crate) async fn invocation_authority_for_closure<C: ConnectionTrait>(
db: &C,
mut ids: Vec<Hash>,
) -> Result<(Vec<Hash>, Vec<String>), DbErr> {
if ids.is_empty() {
return Ok((ids, Vec::new()));
}
let query = Query::select()
.columns([
(delegation::Entity, delegation::Column::Id),
(delegation::Entity, delegation::Column::Delegator),
(delegation::Entity, delegation::Column::Delegatee),
])
.expr_as(
Expr::col((
delegation_cid_alias::Entity,
delegation_cid_alias::Column::Canonical,
)),
Alias::new("canonical_lock"),
)
.from(delegation::Entity)
.join(
JoinType::LeftJoin,
delegation_cid_alias::Entity,
Expr::col((delegation::Entity, delegation::Column::Id)).equals((
delegation_cid_alias::Entity,
delegation_cid_alias::Column::Id,
)),
)
.and_where(
Expr::col((delegation::Entity, delegation::Column::Id)).is_in(ids.iter().copied()),
)
.to_owned();
let rows = db
.query_all(db.get_database_backend().build(&query))
.await?;
if rows.len() != ids.len() {
return Err(DbErr::Custom("authorization-chain-missing".into()));
}
let mut principals = Vec::with_capacity(rows.len() * 2);
let mut aliases = HashMap::with_capacity(rows.len());
for row in rows {
let id: Hash = row.try_get("", "id")?;
if let Some(canonical) = row.try_get::<Option<Hash>>("", "canonical_lock")? {
aliases.insert(id, canonical);
}
for column in ["delegator", "delegatee"] {
let did: String = row.try_get("", column)?;
principals.push(
principal_did(&did)
.map_err(|_| DbErr::Custom("authorization-principal-invalid".into()))?,
);
}
}
for id in &mut ids {
if let Some(canonical) = aliases.get(id) {
*id = *canonical;
}
}
principals.sort_unstable();
principals.dedup();
Ok((ids, principals))
}

/// Every signer and audience in a bounded, persisted proof closure. Missing
/// rows and unparseable principals are authorization failures, not an empty
/// dependency set. CID identity and DID-principal identity remain separate.
pub(crate) async fn load_chain_principals<C: ConnectionTrait>(
db: &C,
roots: &[Hash],
) -> Result<Vec<String>, ChainTraversalError> {
let (ids, _) = load_closure_edges(db, roots).await?;
principals_for_ids(db, &ids).await
}

/// Resolve the signers of an already bounded proof closure without re-running
/// its recursive query. The invocation request also needs these same IDs to
/// discover its policy session, so it can share this request-local snapshot.
pub(crate) async fn principals_for_ids<C: ConnectionTrait>(
db: &C,
ids: &[Hash],
) -> Result<Vec<String>, ChainTraversalError> {
if ids.is_empty() {
return Ok(Vec::new());
}
let rows = delegation::Entity::find()
.filter(delegation::Column::Id.is_in(ids.iter().copied()))
.all(db)
.await?;
if rows.len() != ids.len() {
return Err(DbErr::Custom("authorization-chain-missing".into()).into());
}
let mut principals = Vec::with_capacity(rows.len() * 2);
for row in rows {
for did in [&row.delegator, &row.delegatee] {
principals.push(
principal_did(did)
.map_err(|_| DbErr::Custom("authorization-principal-invalid".into()))?,
);
}
}
principals.sort_unstable();
principals.dedup();
Ok(principals)
}

async fn load_closure<C: ConnectionTrait>(
db: &C,
roots: &[Hash],
Expand Down
Loading
Loading