Skip to content
Open
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 fixtures/tenant_authorization_v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"authorizationDomain":"tinycloud.tenant_authorization@1","body":"{\"credentialType\":\"opencredentials.shape-rotator-membership/v1\",\"deliveryId\":\"delivery_tc475_001\",\"descriptorDigest\":\"F4iRCXEIUTJ7lNioxgxjMOh8qYAdxr5VhiflgGuJppc\",\"environment\":\"local\",\"expiresAt\":\"2026-08-05T12:05:00Z\",\"holderDid\":\"did:key:z6MkhappyPathHolder\",\"issuedAt\":\"2026-08-05T12:00:00Z\",\"nonce\":\"nonce_tc475_001\",\"profile\":\"shape-rotator.membership/v1\",\"proof\":{\"inviteCode\":\"TC475-INVITE\"},\"requestId\":\"request_tc475_001\",\"requirementDigest\":\"cAeGaT09CWiA6hSGF7axWbnQeu7vnEmgIb_9S5TdpM4\",\"tenantId\":\"shape-rotator\",\"type\":\"tenant_authorization@1\"}","contentSha256":"46765435e95c66b3c58a6638776c3fedacb1b9cce862c11e08ecd51995f2e258","deliveryId":"delivery_tc475_001","expectedMac":"e__kQVbsW0fcBkzOI7mYRCh2m-TvjhA-RVoHrbxHD-c","method":"POST","route":"/api/tenant/shape-rotator/authorize","timestamp":"2026-08-05T12:00:00Z","webhookKey":"dGM0NzUtd2ViaG9vay1obWFjLWtleS1maXh0dXJlLTMy"}
48 changes: 48 additions & 0 deletions tinycloud-node-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,39 @@ pub struct Config {
pub public_spaces: PublicSpacesConfig,
#[serde(default)]
pub share_email: ShareEmailConfig,
/// TC-475's registered Shape Rotator issuer. This is a separate, bounded
/// trust tuple so the frozen exact-email v1 configuration is unchanged.
#[serde(default)]
pub tenant_issuer: Option<TenantIssuerConfig>,
#[serde(default)]
pub retention: RetentionConfig,
}

/// The only non-email issuer that may satisfy the ordinary TC-465 policy
/// credential path. Its profile and credential type are pinned in code;
/// configuration supplies public verification material only.
#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)]
pub struct TenantIssuerConfig {
pub issuer_did: String,
pub issuer_kid: String,
pub issuer_key_version: u64,
pub issuer_public_key: String,
}

impl TenantIssuerConfig {
pub fn validate(&self) -> Result<(), &'static str> {
if self.issuer_did.is_empty()
|| !self.issuer_did.starts_with("did:")
|| !canonical_kid_matches(&self.issuer_kid, &self.issuer_did)
|| self.issuer_key_version == 0
|| !canonical_key(&self.issuer_public_key)
{
return Err("tenant issuer configuration is incomplete");
}
Ok(())
}
}

/// Production exact-email composition. The capability remains unavailable
/// until every trust/key/dependency field is configured; there are no fake
/// adapters or development credentials in this configuration surface.
Expand Down Expand Up @@ -1283,6 +1312,25 @@ mod tests {
}
}

#[test]
fn tenant_issuer_accepts_only_a_complete_public_trust_tuple() {
let issuer = TenantIssuerConfig {
issuer_did: "did:web:issuer.credentials.org".into(),
issuer_kid: "did:web:issuer.credentials.org#shape-rotator-1".into(),
issuer_key_version: 1,
issuer_public_key: "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA".into(),
};
assert!(issuer.validate().is_ok());

let mut invalid = issuer;
invalid.issuer_kid = "did:web:issuer.credentials.org#other".into();
invalid.issuer_did = "did:web:other.credentials.org".into();
assert_eq!(
invalid.validate(),
Err("tenant issuer configuration is incomplete")
);
}

/// The exact `emailOrigin` the committed production document carries.
/// Share's schema requires the field; the node's must accept it.
const PRODUCTION_EMAIL_ORIGIN: &str = "https://email.tinycloud.xyz";
Expand Down
22 changes: 22 additions & 0 deletions tinycloud-node-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ pub async fn app_with_control(
.share_email
.validate_for_v2_database(tinycloud_config.storage.database())
.map_err(|error| anyhow::anyhow!(error))?;
if let Some(tenant_issuer) = tinycloud_config.tenant_issuer.as_ref() {
tenant_issuer
.validate()
.map_err(|error| anyhow::anyhow!(error))?;
}

// Ensure local storage directories exist.
// SQLite file paths and local dirs are resources the server owns — auto-create them.
Expand Down Expand Up @@ -474,6 +479,23 @@ pub async fn app_with_control(
),
);
}
if let Some(tenant_issuer) = tinycloud_config.tenant_issuer.as_ref() {
let decoded =
base64::decode_config(&tenant_issuer.issuer_public_key, base64::URL_SAFE_NO_PAD)
.context("tenant issuer public key must be canonical base64url")?;
let public_key: [u8; 32] = decoded
.try_into()
.map_err(|_| anyhow::anyhow!("tenant issuer public key must be 32 bytes"))?;
policy_v3_runtime = policy_v3_runtime.with_credential_issuer(
tinycloud_auth::share_email_evidence::IssuerKey::new(
tenant_issuer.issuer_did.clone(),
policy_v3::SHAPE_ROTATOR_CREDENTIAL_TYPE,
tenant_issuer.issuer_key_version,
tenant_issuer.issuer_kid.clone(),
public_key,
),
);
}
#[cfg(all(tinycloud_legacy_share_v2, feature = "dstack"))]
let dstack_tee_key_derived = matches!(tinycloud_config.keys, config::Keys::Dstack)
|| matches!(tinycloud_config.keys, config::Keys::Auto) && dstack::is_available();
Expand Down
67 changes: 63 additions & 4 deletions tinycloud-node-server/src/policy_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pub const POLICY_SESSION_PROFILE: &str = "policy-session-ucan/v1";
pub const POLICY_V1_SCHEMA: &str = "xyz.tinycloud.policy/policy/v1";
pub const POLICY_V2_SCHEMA: &str = "xyz.tinycloud.policy/policy/v2";
pub const POLICY_CREDENTIAL_REQUIREMENT_V1: &str = "TinyCloudPolicyCredentialRequirement";
pub const SHAPE_ROTATOR_PROFILE: &str = "shape-rotator.membership/v1";
pub const SHAPE_ROTATOR_CREDENTIAL_TYPE: &str = "opencredentials.shape-rotator-membership/v1";
pub const CREDENTIAL_PRESENTATION_V3_SCHEMA: &str = "xyz.tinycloud.policy/presentation/v3";
const CREDENTIAL_PRESENTATION_V3_DOMAIN: &[u8] = b"xyz.tinycloud.policy/Presentation/v3\0";
pub const POLICY_ENFORCEMENT_V2_SCHEMA: &str = "xyz.tinycloud.policy/enforcement-delegation/v2";
Expand All @@ -67,7 +69,7 @@ pub struct PolicyV3Runtime {
pub conn: DatabaseConnection,
pub node_did: String,
signer: StaticSecret,
credential_issuer: Option<IssuerKey>,
credential_issuers: Vec<IssuerKey>,
sqlite_writer_lock: Option<Arc<tokio::sync::Mutex<()>>>,
}

Expand All @@ -81,7 +83,7 @@ impl PolicyV3Runtime {
conn,
node_did: node_did.into(),
signer,
credential_issuer: None,
credential_issuers: Vec::new(),
sqlite_writer_lock: None,
}
}
Expand All @@ -94,10 +96,28 @@ impl PolicyV3Runtime {
/// Install the operator-authenticated OpenCredentials issuer tuple used
/// by policy/v2 admission. Policy/v1 remains available without it.
pub fn with_credential_issuer(mut self, issuer: IssuerKey) -> Self {
self.credential_issuer = Some(issuer);
self.credential_issuers.push(issuer);
self
}

fn credential_issuer_for(
&self,
projection: &serde_json::Map<String, Value>,
) -> Option<&IssuerKey> {
let issuer_did = projection.get("issuerDid")?.as_str()?;
let issuer_kid = projection.get("issuerKid")?.as_str()?;
let credential_type = projection.get("credentialType")?.get("id")?.as_str()?;
let profile = projection.get("profile")?.get("id")?.as_str()?;
self.credential_issuers.iter().find(|issuer| {
issuer.enabled
&& issuer.issuer_did == issuer_did
&& issuer.kid == issuer_kid
&& issuer.vct == credential_type
&& (profile != SHAPE_ROTATOR_PROFILE
|| credential_type == SHAPE_ROTATOR_CREDENTIAL_TYPE)
})
}

async fn is_registered_policy_root(&self, cid: &str) -> Result<bool, &'static str> {
policy_v3_root::Entity::find_by_id(cid.to_owned())
.one(&self.conn)
Expand Down Expand Up @@ -4413,7 +4433,7 @@ fn validate_credential_admission_v3(
.ok_or((Status::Forbidden, "credential-requirement-missing".into()))?,
)?;
validate_request_local_requirement(requirement, projection)?;
let issuer = runtime.credential_issuer.as_ref().ok_or((
let issuer = runtime.credential_issuer_for(projection).ok_or((
Status::ServiceUnavailable,
"credential-issuer-unavailable".into(),
))?;
Expand Down Expand Up @@ -5067,6 +5087,45 @@ mod tests {
use tinycloud_core::sea_orm::{Database, EntityTrait, TransactionTrait};
use tinycloud_core::sea_orm_migration::MigratorTrait;

#[tokio::test]
async fn registered_shape_rotator_issuer_is_selected_by_the_full_tuple() {
let issuer = IssuerKey::new(
"did:web:issuer.credentials.org",
SHAPE_ROTATOR_CREDENTIAL_TYPE,
1,
"did:web:issuer.credentials.org#shape-rotator-1",
[7; 32],
);
let runtime = PolicyV3Runtime {
conn: Database::connect("sqlite::memory:").await.unwrap(),
node_did: "did:key:node".into(),
signer: StaticSecret::new(vec![0; 32]).unwrap(),
credential_issuers: vec![issuer],
sqlite_writer_lock: None,
};
let mut projection = serde_json::Map::new();
projection.insert("issuerDid".into(), json!("did:web:issuer.credentials.org"));
projection.insert(
"issuerKid".into(),
json!("did:web:issuer.credentials.org#shape-rotator-1"),
);
projection.insert(
"profile".into(),
json!({"id": SHAPE_ROTATOR_PROFILE, "version": 1}),
);
projection.insert(
"credentialType".into(),
json!({"id": SHAPE_ROTATOR_CREDENTIAL_TYPE, "version": 1}),
);
assert!(runtime.credential_issuer_for(&projection).is_some());

projection.insert(
"credentialType".into(),
json!({"id": "opencredentials.email/v1", "version": 1}),
);
assert!(runtime.credential_issuer_for(&projection).is_none());
}

fn policy_fixture() -> Value {
let keypair = tinycloud_core::libp2p::identity::ed25519::Keypair::generate();
let owner_did = tinycloud_core::keys::public_key_to_did_key(
Expand Down
47 changes: 47 additions & 0 deletions tinycloud-node-server/src/share_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3407,9 +3407,56 @@ pub async fn authorize_delivery_v2(
#[cfg(test)]
mod tests {
use super::*;
use hmac::{Hmac, Mac};
use rocket::local::asynchronous::Client;
use time::Duration;

#[test]
fn tenant_authorization_golden_vector_is_byte_exact() {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../../fixtures/tenant_authorization_v1.json"))
.expect("valid fixture");
let body = fixture["body"].as_str().expect("body").as_bytes();
let request: serde_json::Value =
serde_json::from_slice(body).expect("authorization request");
assert_eq!(request["proof"]["inviteCode"], "TC475-INVITE");
for field in ["descriptorDigest", "requirementDigest"] {
assert_eq!(
decode_config(request[field].as_str().expect("digest"), URL_SAFE_NO_PAD)
.unwrap()
.len(),
32
);
}
assert_eq!(hex::encode(Sha256::digest(body)), fixture["contentSha256"]);
let key = decode_config(
fixture["webhookKey"].as_str().expect("key"),
URL_SAFE_NO_PAD,
)
.unwrap();
let mut mac = Hmac::<Sha256>::new_from_slice(&key).unwrap();
for (index, part) in [
fixture["authorizationDomain"].as_str().unwrap().as_bytes(),
fixture["method"].as_str().unwrap().as_bytes(),
fixture["route"].as_str().unwrap().as_bytes(),
fixture["deliveryId"].as_str().unwrap().as_bytes(),
fixture["timestamp"].as_str().unwrap().as_bytes(),
body,
]
.iter()
.enumerate()
{
mac.update(part);
if index < 5 {
mac.update(b"\0");
}
}
assert_eq!(
encode_config(mac.finalize().into_bytes(), URL_SAFE_NO_PAD),
fixture["expectedMac"]
);
}

#[test]
fn policy_domain_matches_the_sdk_wire_contract() {
assert_eq!(POLICY_DOMAIN, "xyz.tinycloud.share/policy/v2\0");
Expand Down