From 3d51864ccd4a59d625f5a87de82309c97455cc39 Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 5 Aug 2026 01:48:43 -0400 Subject: [PATCH 1/6] test(TC-475): pin tenant authorization golden vector --- fixtures/tenant_authorization_v1.json | 1 + tinycloud-node-server/src/share_v2.rs | 36 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 fixtures/tenant_authorization_v1.json diff --git a/fixtures/tenant_authorization_v1.json b/fixtures/tenant_authorization_v1.json new file mode 100644 index 00000000..0e30a10d --- /dev/null +++ b/fixtures/tenant_authorization_v1.json @@ -0,0 +1 @@ +{"authorizationDomain":"tinycloud.tenant_authorization@1","body":"{\"credentialType\":\"opencredentials.shape-rotator-membership/v1\",\"deliveryId\":\"delivery_tc475_001\",\"descriptorDigest\":\"uNWIJ...\",\"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\",\"requestId\":\"request_tc475_001\",\"requirementDigest\":\"uRequirementsTc475\",\"tenantId\":\"shape-rotator\",\"type\":\"tenant_authorization@1\"}","contentSha256":"9517d6462b21a05f69e6e98895412b323bc495112539ef3f7d81bd57bb90ee37","deliveryId":"delivery_tc475_001","expectedMac":"3JkLKvWy7sCXAyuZwGgS6uxTWBR8RBaRjDc9Een_PmQ","method":"POST","route":"/api/tenant/shape-rotator/authorize","timestamp":"2026-08-05T12:00:00Z","webhookKey":"dGM0NzUtd2ViaG9vay1obWFjLWtleS1maXh0dXJlLTMy"} diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index 4b0c2918..fa4cc0ef 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -3407,9 +3407,45 @@ 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(); + 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::::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"); From 5880b51b0c7ce86fd419fdf7703d26b7a1195644 Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 5 Aug 2026 03:26:42 -0400 Subject: [PATCH 2/6] feat(TC-475): admit registered tenant issuer --- tinycloud-node-server/src/config.rs | 48 ++++++++++++++++++ tinycloud-node-server/src/lib.rs | 22 +++++++++ tinycloud-node-server/src/policy_v3.rs | 67 ++++++++++++++++++++++++-- 3 files changed, 133 insertions(+), 4 deletions(-) diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index faefbd6a..13ec4424 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -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, #[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. @@ -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"; diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index a7a77bed..527315da 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -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. @@ -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(); diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index e69ba08e..be36cb5b 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -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"; @@ -67,7 +69,7 @@ pub struct PolicyV3Runtime { pub conn: DatabaseConnection, pub node_did: String, signer: StaticSecret, - credential_issuer: Option, + credential_issuers: Vec, sqlite_writer_lock: Option>>, } @@ -81,7 +83,7 @@ impl PolicyV3Runtime { conn, node_did: node_did.into(), signer, - credential_issuer: None, + credential_issuers: Vec::new(), sqlite_writer_lock: None, } } @@ -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, + ) -> 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 { policy_v3_root::Entity::find_by_id(cid.to_owned()) .one(&self.conn) @@ -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(), ))?; @@ -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::random(), + 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( From b10c19c2a12689cf850e8dd2fc285be454d9022e Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 5 Aug 2026 12:13:54 -0400 Subject: [PATCH 3/6] fix(TC-475): construct deterministic tenant test secret --- tinycloud-node-server/src/policy_v3.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index be36cb5b..523e7c04 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -5099,7 +5099,7 @@ mod tests { let runtime = PolicyV3Runtime { conn: Database::connect("sqlite::memory:").await.unwrap(), node_did: "did:key:node".into(), - signer: StaticSecret::random(), + signer: StaticSecret::new(vec![0; 32]).unwrap(), credential_issuers: vec![issuer], sqlite_writer_lock: None, }; From 08648407b2c81488fd65c99297b4bd6454b5f8bc Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 5 Aug 2026 12:58:13 -0400 Subject: [PATCH 4/6] fix(TC-475): synchronize tenant authorization vector --- fixtures/tenant_authorization_v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/tenant_authorization_v1.json b/fixtures/tenant_authorization_v1.json index 0e30a10d..3dbc9f7f 100644 --- a/fixtures/tenant_authorization_v1.json +++ b/fixtures/tenant_authorization_v1.json @@ -1 +1 @@ -{"authorizationDomain":"tinycloud.tenant_authorization@1","body":"{\"credentialType\":\"opencredentials.shape-rotator-membership/v1\",\"deliveryId\":\"delivery_tc475_001\",\"descriptorDigest\":\"uNWIJ...\",\"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\",\"requestId\":\"request_tc475_001\",\"requirementDigest\":\"uRequirementsTc475\",\"tenantId\":\"shape-rotator\",\"type\":\"tenant_authorization@1\"}","contentSha256":"9517d6462b21a05f69e6e98895412b323bc495112539ef3f7d81bd57bb90ee37","deliveryId":"delivery_tc475_001","expectedMac":"3JkLKvWy7sCXAyuZwGgS6uxTWBR8RBaRjDc9Een_PmQ","method":"POST","route":"/api/tenant/shape-rotator/authorize","timestamp":"2026-08-05T12:00:00Z","webhookKey":"dGM0NzUtd2ViaG9vay1obWFjLWtleS1maXh0dXJlLTMy"} +{"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\",\"requestId\":\"request_tc475_001\",\"requirementDigest\":\"uRequirementsTc475\",\"tenantId\":\"shape-rotator\",\"type\":\"tenant_authorization@1\"}","contentSha256":"c4b4d2d4325fad20b7c2b802b1798935bf93447cbcec284b6c729f3b1db49c1d","deliveryId":"delivery_tc475_001","expectedMac":"wYIvojNs4xG0X5ZUhHf3-XQ1G_-aJrMOyN21QQjgSS8","method":"POST","route":"/api/tenant/shape-rotator/authorize","timestamp":"2026-08-05T12:00:00Z","webhookKey":"dGM0NzUtd2ViaG9vay1obWFjLWtleS1maXh0dXJlLTMy"} From 42b076e37fb0f45016c25947d2f29a9363d6c5b5 Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 5 Aug 2026 13:13:59 -0400 Subject: [PATCH 5/6] test(TC-475): refresh tenant authorization vector --- fixtures/tenant_authorization_v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/tenant_authorization_v1.json b/fixtures/tenant_authorization_v1.json index 3dbc9f7f..2f5c96b5 100644 --- a/fixtures/tenant_authorization_v1.json +++ b/fixtures/tenant_authorization_v1.json @@ -1 +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\",\"requestId\":\"request_tc475_001\",\"requirementDigest\":\"uRequirementsTc475\",\"tenantId\":\"shape-rotator\",\"type\":\"tenant_authorization@1\"}","contentSha256":"c4b4d2d4325fad20b7c2b802b1798935bf93447cbcec284b6c729f3b1db49c1d","deliveryId":"delivery_tc475_001","expectedMac":"wYIvojNs4xG0X5ZUhHf3-XQ1G_-aJrMOyN21QQjgSS8","method":"POST","route":"/api/tenant/shape-rotator/authorize","timestamp":"2026-08-05T12:00:00Z","webhookKey":"dGM0NzUtd2ViaG9vay1obWFjLWtleS1maXh0dXJlLTMy"} +{"authorizationDomain":"tinycloud.tenant_authorization@1","body":"{\"credentialType\":\"opencredentials.shape-rotator-membership/v1\",\"deliveryId\":\"delivery_tc475_001\",\"descriptorDigest\":\"ZCYjsvDejJhJ1ESF4iYZCoIHuY6XTsCt2FOWt5dL_j0\",\"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\",\"requestId\":\"request_tc475_001\",\"requirementDigest\":\"cAeGaT09CWiA6hSGF7axWbnQeu7vnEmgIb_9S5TdpM4\",\"tenantId\":\"shape-rotator\",\"type\":\"tenant_authorization@1\"}","contentSha256":"b6c7021afe539413c88527c41e49bc4a7cfc641dd410c1b68567f3f01b7923f9","deliveryId":"delivery_tc475_001","expectedMac":"qS-51AncYi1NFgbWBpTibACdK9zgOrwOONDZtljAZAI","method":"POST","route":"/api/tenant/shape-rotator/authorize","timestamp":"2026-08-05T12:00:00Z","webhookKey":"dGM0NzUtd2ViaG9vay1obWFjLWtleS1maXh0dXJlLTMy"} From 182c3cdea5a0e9e29091432196ad08f39bb79af3 Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 5 Aug 2026 13:31:40 -0400 Subject: [PATCH 6/6] test(TC-475): validate complete authorization vector --- fixtures/tenant_authorization_v1.json | 2 +- tinycloud-node-server/src/share_v2.rs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/fixtures/tenant_authorization_v1.json b/fixtures/tenant_authorization_v1.json index 2f5c96b5..3ce44c32 100644 --- a/fixtures/tenant_authorization_v1.json +++ b/fixtures/tenant_authorization_v1.json @@ -1 +1 @@ -{"authorizationDomain":"tinycloud.tenant_authorization@1","body":"{\"credentialType\":\"opencredentials.shape-rotator-membership/v1\",\"deliveryId\":\"delivery_tc475_001\",\"descriptorDigest\":\"ZCYjsvDejJhJ1ESF4iYZCoIHuY6XTsCt2FOWt5dL_j0\",\"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\",\"requestId\":\"request_tc475_001\",\"requirementDigest\":\"cAeGaT09CWiA6hSGF7axWbnQeu7vnEmgIb_9S5TdpM4\",\"tenantId\":\"shape-rotator\",\"type\":\"tenant_authorization@1\"}","contentSha256":"b6c7021afe539413c88527c41e49bc4a7cfc641dd410c1b68567f3f01b7923f9","deliveryId":"delivery_tc475_001","expectedMac":"qS-51AncYi1NFgbWBpTibACdK9zgOrwOONDZtljAZAI","method":"POST","route":"/api/tenant/shape-rotator/authorize","timestamp":"2026-08-05T12:00:00Z","webhookKey":"dGM0NzUtd2ViaG9vay1obWFjLWtleS1maXh0dXJlLTMy"} +{"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"} diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index fa4cc0ef..6e8279b9 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -3417,6 +3417,17 @@ mod tests { 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"),