diff --git a/test/n4-mounted-e2e/src/main.rs b/test/n4-mounted-e2e/src/main.rs index 6c11c205..cf097cff 100644 --- a/test/n4-mounted-e2e/src/main.rs +++ b/test/n4-mounted-e2e/src/main.rs @@ -68,6 +68,14 @@ struct FixtureConfig { invitation_kid: String, } +struct SessionFixture { + space_id: String, + delegation_cid: String, + delegation_header: String, + verification_method: String, + jwk: Value, +} + #[derive(Clone)] struct Case { kind: &'static str, @@ -309,9 +317,16 @@ fn build_case( let owner_seed = [0x55u8; 32]; let owner = owner_did(&owner_seed); let expires_at = millis_time(now + time::Duration::hours(24)); - let is_kv = kind.starts_with("kv"); + let is_kv = kind.starts_with("kv") || kind == "recipient-did"; let is_domain = kind == "kv-domain" || kind == "kv-folder-domain"; let is_folder = kind == "kv-folder-domain"; + let recipient_matcher = if kind == "recipient-did" { + json!({"kind":"recipientDid","value":"did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw"}) + } else if is_domain { + json!({"kind":"emailDomain","value":"mailinator.com"}) + } else { + json!({"kind":"exactEmail","value":"sam@tinycloud.xyz"}) + }; let source = if is_kv { json!({"kind":"kv","space":SPACE,"path":if is_folder { "documents" } else { "documents/policy-payload.md" },"action":"tinycloud.kv/get"}) } else { @@ -319,7 +334,7 @@ fn build_case( json!({"kind":"sql","space":SPACE,"database":"documents","path":"shared/plan","statement":"shared_document_by_id","arguments":arguments,"argumentsDigest":sha256_b64(&value_bytes(&arguments)),"action":"tinycloud.sql/read"}) }; let source_digest = sha256_b64(&value_bytes(&source)); - let policy = json!({"type":"TinyCloudSharePolicy","version":2,"recipientMatcher":if is_domain { json!({"kind":"emailDomain","value":"mailinator.com"}) } else { json!({"kind":"exactEmail","value":"sam@tinycloud.xyz"}) },"contentSource":source,"contentSourceDigest":source_digest,"actions":if is_folder { json!(["tinycloud.kv/get","tinycloud.kv/list","tinycloud.kv/put"]) } else if is_domain { json!(["tinycloud.kv/get"]) } else if is_kv { json!(["tinycloud.kv/get","tinycloud.kv/put"]) } else { json!([source["action"]]) },"resource":if is_folder { json!({"kind":"prefix","value":"documents"}) } else { json!({"kind":"exact","value":source["path"]}) },"expiresAt":expires_at,"issuerDid":sender_did}); + let policy = json!({"type":"TinyCloudSharePolicy","version":2,"recipientMatcher":recipient_matcher,"contentSource":source,"contentSourceDigest":source_digest,"actions":if is_folder { json!(["tinycloud.kv/get","tinycloud.kv/list","tinycloud.kv/put"]) } else if is_domain { json!(["tinycloud.kv/get"]) } else if is_kv { json!(["tinycloud.kv/get","tinycloud.kv/put"]) } else { json!([source["action"]]) },"resource":if is_folder { json!({"kind":"prefix","value":"documents"}) } else { json!({"kind":"exact","value":source["path"]}) },"expiresAt":expires_at,"issuerDid":sender_did}); let policy_bytes = value_bytes(&policy); let policy_cid = cid(0x55, Code::Sha2_256, &policy_bytes); let delegation_cid = cid( @@ -447,7 +462,13 @@ fn build_case( &node_did, ); let attestation = attestation(config, &enrollment, &node_did, &status_fresh, node); - let authority_handle = if is_kv { "amh_kv_001" } else { "amh_sql_001" }; + let authority_handle = if kind == "recipient-did" { + "amh_recipient_did_001" + } else if is_kv { + "amh_kv_001" + } else { + "amh_sql_001" + }; let authority = json!({"type":"TinyCloudShareAuthorityMaterial","version":1,"handle":authority_handle,"policyOwnerDid":owner,"senderDid":sender_did,"relationship":{"policyOwnerDid":owner,"senderDid":sender_did,"authenticated":true},"mapping":{"sharePolicyCid":policy_cid,"shareDelegationCid":delegation_cid,"policyAuthorityCid":policy_parent_cid,"policyEnforcementCid":enforcement_parent_cid},"policyAuthorityBytes":b64(&authority_parent_bytes),"policyAuthorityCid":policy_parent_cid,"policyEnforcementBytes":b64(&enforcement_parent_bytes),"policyEnforcementCid":enforcement_parent_cid,"statusObservations":[authority_status,enforcement_status],"enrollment":enrollment,"attestation":attestation}); let authority_digest = sha256_b64(&value_bytes(&authority)); Ok(Case { @@ -574,7 +595,7 @@ async fn seed_sql(rocket: &Rocket) -> Result<()> { Ok(()) } -async fn seed_kv(rocket: &Rocket, seed: [u8; 32]) -> Result<()> { +async fn seed_kv(rocket: &Rocket, seed: [u8; 32]) -> Result { let space = SpaceId::from_str( "tinycloud:key:z6MktwtqAzuD5F77tAMBMwNs1KybZeff61EehV9xB1ZpXQG7:documents", )?; @@ -626,6 +647,14 @@ async fn seed_kv(rocket: &Rocket, seed: [u8; 32]) -> Result<()> { resource.clone().as_uri(), std::iter::once(("tinycloud.kv/put".parse()?, [])), ); + let capabilities_resource = + space + .clone() + .to_resource("capabilities".parse::()?, None, None, None); + delegation_caps.with_actions( + capabilities_resource.as_uri(), + std::iter::once(("tinycloud.capabilities/read".parse()?, [])), + ); let delegation = Payload { issuer: verification_method.parse::()?, audience: verification_method @@ -643,8 +672,8 @@ async fn seed_kv(rocket: &Rocket, seed: [u8; 32]) -> Result<()> { attenuation: delegation_caps, } .sign(Algorithm::EdDSA, &jwk)?; - let delegation_event = - Delegation::from_header_ser::(&delegation.encode()?)?; + let delegation_header = delegation.encode()?; + let delegation_event = Delegation::from_header_ser::(&delegation_header)?; let delegation_cid = delegation_event.content_hash().to_cid(0x55); let tinycloud = rocket .state::() @@ -691,7 +720,13 @@ async fn seed_kv(rocket: &Rocket, seed: [u8; 32]) -> Result<()> { .invoke::(invocation, inputs) .await .map_err(|error| anyhow::anyhow!("KV seed invocation: {error}"))?; - Ok(()) + Ok(SessionFixture { + space_id: space.to_string(), + delegation_cid: delegation_cid.to_string(), + delegation_header, + verification_method, + jwk: serde_json::to_value(jwk)?, + }) } async fn mounted_http_adversarial_checks(rocket: Rocket) -> Result<()> { @@ -829,6 +864,25 @@ async fn run() -> Result<()> { .windows(2) .find(|pair| pair[0] == "--descriptor") .map(|pair| PathBuf::from(&pair[1])); + let profile_output = args + .windows(2) + .find(|pair| pair[0] == "--profile-output") + .map(|pair| PathBuf::from(&pair[1])); + let trust_bundle_output = args + .windows(2) + .find(|pair| pair[0] == "--trust-bundle-output") + .map(|pair| PathBuf::from(&pair[1])); + let quiet = args.iter().any(|argument| argument == "--quiet"); + let listen_port = args + .windows(2) + .find(|pair| pair[0] == "--listen-port") + .map(|pair| { + pair[1] + .parse::() + .context("--listen-port must be a valid TCP port") + }) + .transpose()? + .unwrap_or(0); let issuer_public = args .windows(2) .find(|pair| pair[0] == "--issuer-public-key") @@ -875,6 +929,7 @@ async fn run() -> Result<()> { build_case(&fixture_config, "kv-domain", &sender, &node, now)?, build_case(&fixture_config, "kv-folder-domain", &sender, &node, now)?, build_case(&fixture_config, "sql", &sender, &node, now)?, + build_case(&fixture_config, "recipient-did", &sender, &node, now)?, ]; let temp = TempDir::new().context("temporary fixture directory")?; let material_path = temp.path().join("authority-material.json"); @@ -888,7 +943,7 @@ async fn run() -> Result<()> { .context("authority material write")?; tinycloud_core::share_email::AuthenticatedAuthorityMaterialProvider::from_path(&material_path) .map_err(|error| anyhow::anyhow!("generated authority material validation: {error:?}"))?; - let listener = TcpListener::bind(("127.0.0.1", 0)).context("reserve ephemeral local port")?; + let listener = TcpListener::bind(("127.0.0.1", listen_port)).context("reserve local port")?; let port = listener.local_addr()?.port(); drop(listener); let invitation_public = b64(&node @@ -921,6 +976,9 @@ async fn run() -> Result<()> { }); fs::write(&trust_bundle_path, serde_json::to_vec(&trust_bundle)?) .context("trust bundle write")?; + if let Some(path) = trust_bundle_output { + fs::write(path, serde_json::to_vec(&trust_bundle)?).context("joined trust bundle write")?; + } let figment = figment( temp.path(), &secret, @@ -932,7 +990,51 @@ async fn run() -> Result<()> { .await .context("default-feature production Rocket app composition")?; seed_sql(&rocket).await?; - seed_kv(&rocket, [0x44; 32]).await?; + let session = seed_kv(&rocket, [0x44; 32]).await?; + if let Some(home) = profile_output { + let profile_dir = home.join(".tinycloud/profiles/joined"); + fs::create_dir_all(&profile_dir)?; + let session_did = session + .verification_method + .split('#') + .next() + .context("session DID principal")?; + fs::write( + home.join(".tinycloud/config.json"), + "{\"defaultProfile\":\"joined\",\"version\":1}\n", + )?; + fs::write( + profile_dir.join("profile.json"), + serde_json::to_vec_pretty(&json!({ + "name": "joined", + "host": format!("http://127.0.0.1:{port}"), + "chainId": 1, + "spaceName": "documents", + "did": session_did, + "sessionDid": session_did, + "spaceId": session.space_id, + "authMethod": "openkey" + }))?, + )?; + fs::write( + profile_dir.join("key.json"), + serde_json::to_vec_pretty(&session.jwk)?, + )?; + fs::write( + profile_dir.join("session.json"), + serde_json::to_vec_pretty(&json!({ + "delegationHeader": {"Authorization": session.delegation_header}, + "delegationCid": session.delegation_cid, + "spaceId": session.space_id, + "jwk": session.jwk, + "verificationMethod": session_did + }))?, + )?; + fs::write( + profile_dir.join("session.json.metadata.json"), + "{\"formatVersion\":1}\n", + )?; + } if self_test { mounted_http_adversarial_checks(rocket).await?; eprintln!("production HTTP adversarial checks passed"); @@ -962,7 +1064,9 @@ async fn run() -> Result<()> { eprintln!("production descriptor write failed: {error}"); } } - println!("{}", String::from_utf8_lossy(&descriptor_bytes)); + if !quiet { + println!("{}", String::from_utf8_lossy(&descriptor_bytes)); + } eprintln!("tinycloud-node-production-e2e listening on http://127.0.0.1:{port}"); }) })); diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 9218b48e..40878d06 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -178,7 +178,7 @@ pub(crate) async fn process_admitted( /// skips the signature check (already done at admission) and re-checks /// signed time validity instead, to close the same TOCTOU window /// `process_admitted` closes on the write path. -pub(crate) async fn authorize_admitted( +pub async fn authorize_admitted( db: &C, invocation: &util::InvocationInfo, now: OffsetDateTime, diff --git a/tinycloud-core/src/share_email/types.rs b/tinycloud-core/src/share_email/types.rs index c6fa1a5e..016ae5b9 100644 --- a/tinycloud-core/src/share_email/types.rs +++ b/tinycloud-core/src/share_email/types.rs @@ -638,6 +638,8 @@ pub enum RecipientMatcher { ExactEmail(String), #[serde(rename = "emailDomain")] EmailDomain(String), + #[serde(rename = "recipientDid")] + RecipientDid(String), } impl RecipientMatcher { @@ -651,6 +653,9 @@ impl RecipientMatcher { .map(|value| format!("emailDomain:{value}")) .map_err(|_| TypeError::InvalidRecipientMatcher) } + Self::RecipientDid(value) => Did::parse(value.clone()) + .map(|value| format!("recipientDid:{}", value.as_str())) + .map_err(|_| TypeError::InvalidRecipientMatcher), } } @@ -660,7 +665,7 @@ impl RecipientMatcher { match self { Self::ExactEmail(value) => tinycloud_auth::share_email_evidence::normalize_email(value) .map_err(|_| TypeError::InvalidRecipientMatcher), - Self::EmailDomain(_) => self.canonical(), + Self::EmailDomain(_) | Self::RecipientDid(_) => self.canonical(), } } @@ -675,6 +680,9 @@ impl RecipientMatcher { tinycloud_auth::share_email_evidence::normalize_policy_domain(value) .is_ok_and(|normalized| normalized == *value) } + Self::RecipientDid(value) => { + Did::parse(value.clone()).is_ok_and(|normalized| normalized.as_str() == value) + } } } @@ -695,12 +703,24 @@ impl RecipientMatcher { ) .is_some_and(|(expected, actual)| expected == actual) } + Self::RecipientDid(_) => false, } } pub fn is_domain(&self) -> bool { matches!(self, Self::EmailDomain(_)) } + + pub fn is_recipient_did(&self) -> bool { + matches!(self, Self::RecipientDid(_)) + } + + pub fn recipient_did(&self) -> Option<&str> { + match self { + Self::RecipientDid(value) => Some(value), + _ => None, + } + } } impl fmt::Debug for RecipientMatcher { @@ -710,6 +730,9 @@ impl fmt::Debug for RecipientMatcher { Self::EmailDomain(_) => { formatter.write_str("RecipientMatcher::EmailDomain([REDACTED])") } + Self::RecipientDid(_) => { + formatter.write_str("RecipientMatcher::RecipientDid([REDACTED])") + } } } } @@ -1368,6 +1391,24 @@ mod tests { assert!(serde_json::from_str::("1.0").is_err()); } + #[test] + fn recipient_did_matchers_are_canonical_and_method_validated() { + let key = RecipientMatcher::RecipientDid(HOLDER.to_owned()); + assert_eq!(key.canonical().unwrap(), format!("recipientDid:{HOLDER}")); + assert!(key.is_canonical()); + assert!( + RecipientMatcher::RecipientDid("did:web:recipient.example:path".to_owned()) + .is_canonical() + ); + assert!(RecipientMatcher::RecipientDid("did:pkh:eip155:1:0xabc".to_owned()).is_canonical()); + assert!(!RecipientMatcher::RecipientDid("did:key:zholder".to_owned()).is_canonical()); + assert!( + !RecipientMatcher::RecipientDid("did:web:-recipient.example".to_owned()).is_canonical() + ); + assert!(!RecipientMatcher::RecipientDid("did:pkh:eip155:1".to_owned()).is_canonical()); + assert!(!key.matches_verified_email("person@example.com")); + } + #[test] fn v2_policy_is_canonical_and_rejects_browser_resource_shapes() { let source = serde_json::json!({ diff --git a/tinycloud-core/src/share_email/verifier.rs b/tinycloud-core/src/share_email/verifier.rs index c1ddedac..03faf13f 100644 --- a/tinycloud-core/src/share_email/verifier.rs +++ b/tinycloud-core/src/share_email/verifier.rs @@ -153,6 +153,7 @@ impl ExactEmailVerifier { let matcher = match matcher { RecipientMatcher::ExactEmail(value) => EmailMatcher::Exact(value), RecipientMatcher::EmailDomain(value) => EmailMatcher::Domain(value), + RecipientMatcher::RecipientDid(_) => return Err(PortError::Denied), }; let evidence = self .verify_inner( diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 6a4355a0..5a11c029 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -100,6 +100,7 @@ use routes::{ open_host_key, public::{public_kv_get, public_kv_head, public_kv_list, public_kv_options, RateLimiter}, revoke, signed_kv_get, + upload_attestation::mint_upload_attestation, util_routes::*, version, }; @@ -258,6 +259,7 @@ pub async fn app_with_control( encryption_well_known, encryption_decrypt, revoke_encryption_network, + mint_upload_attestation, ]; routes.extend(share_email::public_routes()); routes.extend(share_v2::public_routes()); @@ -438,6 +440,17 @@ pub async fn app_with_control( } else { None }; + let upload_attestation_runtime = if tinycloud_config.share_email.enabled { + Some( + routes::upload_attestation::UploadAttestationRuntime::compose( + seed_conn.clone(), + &key_setup, + &tinycloud_config.share_email, + )?, + ) + } else { + None + }; if let Some(runtime) = share_email_runtime.as_ref() { if !runtime.bridge.self_check().await { anyhow::bail!( @@ -552,6 +565,7 @@ pub async fn app_with_control( .manage(rate_limiter) .manage(share_email_runtime) .manage(share_v2_runtime) + .manage(upload_attestation_runtime) .manage(tee_context) .manage(encryption_service) .manage(tinycloud_config.storage.staging.open().await?); diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 5831a7c4..542f6640 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -68,6 +68,7 @@ pub mod node_keys; pub mod public; #[cfg(feature = "tc-bench-v1")] pub mod tc_bench; +pub mod upload_attestation; pub mod util; use util::LimitedReader; diff --git a/tinycloud-node-server/src/routes/upload_attestation.rs b/tinycloud-node-server/src/routes/upload_attestation.rs new file mode 100644 index 00000000..3fac4e3e --- /dev/null +++ b/tinycloud-node-server/src/routes/upload_attestation.rs @@ -0,0 +1,886 @@ +//! Owner/session-bound upload attestations for Share. +//! +//! This route is deliberately a small authorization adapter. It does not +//! authorize an upload from the HTTP header shape: the normal invocation +//! verifier checks the signature, time window, delegation graph, and live +//! revocation state before this module mints anything. + +use base64::{encode_config, URL_SAFE_NO_PAD}; +use rocket::{ + data::{Data, ToByteUnit}, + http::Status, + response::status::Custom, + serde::json::Json, + State, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use time::{format_description::well_known::Rfc3339, Duration, OffsetDateTime}; +use tinycloud_auth::identity::did_principal_matches; +use tinycloud_core::{ + models::{abilities, delegation, invocation as invocation_model}, + policy_capability::jcs, + relationships::parent_delegations, + sea_orm::{ColumnTrait, EntityTrait, QueryFilter}, + share_email::{ + invitation::{random_protocol_jti, Ed25519InvitationSigner, InvitationSigner}, + types::TargetOrigin, + }, + types::Resource, + util::{Capability, InvocationInfo}, + AdmittedInvocation, +}; +use tokio::io::AsyncReadExt; + +use crate::{ + authorization::AuthHeaderGetter, + config::{Config, ShareEmailConfig}, + invocation_replay::InvocationReplayCache, + share_v2, +}; + +const DOMAIN: &[u8] = b"xyz.tinycloud.share/upload-attestation/v1\0"; +const MAX_UPLOAD_METADATA_BYTES: usize = 64 * 1024; +const SEALED_BLOB_OVERHEAD_BYTES: u64 = 1 + 12 + 16; +const MAX_UPLOAD_BYTES: u64 = share_v2::MAX_BODY_BYTES as u64 + SEALED_BLOB_OVERHEAD_BYTES; +const MAX_RETENTION_BYTES: usize = 1024; +const BASELINE_ABILITY: &str = "tinycloud.capabilities/read"; +const MAX_RETENTION_SECONDS: i64 = 8 * 24 * 60 * 60; + +#[derive(Debug, Serialize)] +pub struct ApiErrorBody { + error: ApiError, +} + +#[derive(Debug, Serialize)] +pub struct ApiError { + code: &'static str, +} + +type ApiErrorResponse = Custom>; + +fn error(status: Status, code: &'static str) -> ApiErrorResponse { + Custom( + status, + Json(ApiErrorBody { + error: ApiError { code }, + }), + ) +} + +/// The node-side signer is the same derived Ed25519 key advertised through +/// the existing Share trust bundle. The private key is held only in memory. +pub struct UploadAttestationRuntime { + conn: tinycloud_core::sea_orm::DatabaseConnection, + signer: Arc, + issuer: String, + share_origin: String, +} + +impl UploadAttestationRuntime { + pub fn compose( + conn: tinycloud_core::sea_orm::DatabaseConnection, + key_setup: &tinycloud_core::keys::StaticSecret, + config: &ShareEmailConfig, + ) -> anyhow::Result { + let share_origin = TargetOrigin::parse(config.return_origin.clone())? + .as_str() + .to_owned(); + let key = key_setup.derive_key(b"tinycloud/share-email/invitation-signing"); + let secret = tinycloud_core::libp2p::identity::ed25519::SecretKey::try_from_bytes(key) + .map_err(|_| anyhow::anyhow!("invalid upload attestation signing key"))?; + let keypair = tinycloud_core::libp2p::identity::ed25519::Keypair::from(secret); + let signer = Ed25519InvitationSigner::new(config.node_signing_kid.clone(), keypair.into())?; + Ok(Self { + conn, + signer: Arc::new(signer), + issuer: config.node_audience.clone(), + share_origin, + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct UploadAttestationRequest { + pub share_origin: String, + pub encrypted_blob_cid: String, + pub encrypted_blob_sha256: String, + pub byte_length: u64, + pub delete_after: String, + pub retention: Value, + pub request_body_digest: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UploadAttestation { + #[serde(rename = "type")] + pub artifact_type: &'static str, + pub version: u8, + pub issuer: String, + pub kid: String, + pub owner_did: String, + pub session_did: String, + pub share_origin: String, + pub encrypted_blob_cid: String, + pub encrypted_blob_sha256: String, + pub byte_length: u64, + pub delete_after: String, + pub retention: Value, + pub issued_at: String, + pub authority_expires_at: String, + pub expires_at: String, + pub jti: String, + pub signature: String, +} + +#[post("/share/upload/attestation", format = "json", data = "")] +pub async fn mint_upload_attestation( + data: Data<'_>, + invocation: AuthHeaderGetter, + runtime: &State>, + replay: &State, + config: &State, +) -> Result, ApiErrorResponse> { + let runtime = runtime.inner().as_ref().ok_or(error( + Status::ServiceUnavailable, + "upload_authority_unavailable", + ))?; + let raw = read_body(data).await?; + let body_value: Value = serde_json::from_slice(&raw) + .map_err(|_| error(Status::BadRequest, "upload_attestation_invalid"))?; + if jcs::canonicalize(&body_value) != raw { + return Err(error(Status::BadRequest, "upload_attestation_invalid")); + } + let request: UploadAttestationRequest = serde_json::from_value(body_value.clone()) + .map_err(|_| error(Status::BadRequest, "upload_attestation_invalid"))?; + let unsigned_request_digest = request_body_digest(&body_value) + .ok_or(error(Status::BadRequest, "upload_attestation_invalid"))?; + if request.request_body_digest != unsigned_request_digest { + return Err(error(Status::BadRequest, "upload_attestation_invalid")); + } + + let now = OffsetDateTime::now_utc(); + let admitted = AdmittedInvocation::admit(invocation.0, config.invocation.max_lifetime_secs) + .await + .map_err(|_| error(Status::Unauthorized, "upload_authorization_invalid"))?; + let auth = &admitted.invocation().0; + if auth.invocation.payload().audience.to_string() != runtime.issuer { + return Err(error(Status::Forbidden, "upload_authorization_invalid")); + } + validate_request(&request, &runtime.share_origin, now) + .map_err(|_| error(Status::BadRequest, "upload_attestation_invalid"))?; + invocation_model::authorize_admitted(&runtime.conn, auth, now) + .await + .map_err(|_| error(Status::Unauthorized, "upload_authorization_invalid"))?; + if !has_baseline_scope(&auth.capabilities) + || !invocation_body_digest_matches(auth, &request.request_body_digest) + { + return Err(error(Status::Forbidden, "upload_authorization_invalid")); + } + let max_lifetime = config.invocation.max_lifetime_secs; + if auth.invocation.payload().expiration.as_seconds() + > now.unix_timestamp() as f64 + max_lifetime as f64 + { + return Err(error(Status::Unauthorized, "upload_authorization_invalid")); + } + replay + .check_and_insert(&admitted, max_lifetime) + .await + .map_err(|_| error(Status::Unauthorized, "upload_authorization_invalid"))?; + + let (owner_did, delegation_expiry) = owner_did_and_expiry(&runtime.conn, auth) + .await + .ok_or(error(Status::Forbidden, "upload_authorization_invalid"))?; + let session_did = auth.invoker.clone(); + + let invocation_expiry = OffsetDateTime::from_unix_timestamp_nanos( + (auth.invocation.payload().expiration.as_seconds() * 1_000_000_000.0) as i128, + ) + .map_err(|_| error(Status::Unauthorized, "upload_authorization_invalid"))?; + let authority_expiry = delegation_expiry + .map(|expiry| expiry.min(invocation_expiry)) + .unwrap_or(invocation_expiry); + let expires_at = (now + Duration::seconds(120)).min(authority_expiry); + if expires_at <= now { + return Err(error(Status::Unauthorized, "upload_authorization_invalid")); + } + let mut attestation = UploadAttestation { + artifact_type: "TinyCloudShareUploadAttestation", + version: 1, + issuer: runtime.issuer.clone(), + kid: runtime.signer.kid().to_owned(), + owner_did, + session_did, + share_origin: runtime.share_origin.clone(), + encrypted_blob_cid: request.encrypted_blob_cid, + encrypted_blob_sha256: request.encrypted_blob_sha256, + byte_length: request.byte_length, + delete_after: request.delete_after, + retention: request.retention, + issued_at: timestamp(now), + authority_expires_at: timestamp(authority_expiry), + expires_at: timestamp(expires_at), + jti: random_protocol_jti().as_str().to_owned(), + signature: String::new(), + }; + let unsigned = serde_json::to_value(&attestation) + .map_err(|_| error(Status::InternalServerError, "upload_authority_unavailable"))?; + let mut signed = DOMAIN.to_vec(); + signed.extend(jcs::canonicalize(&without_signature(&unsigned))); + let signature = runtime + .signer + .sign(&signed) + .map_err(|_| error(Status::ServiceUnavailable, "upload_authority_unavailable"))?; + attestation.signature = encode_config(signature, URL_SAFE_NO_PAD); + Ok(Json(attestation)) +} + +async fn read_body(data: Data<'_>) -> Result, ApiErrorResponse> { + let mut bytes = Vec::new(); + data.open((MAX_UPLOAD_METADATA_BYTES + 1).bytes()) + .read_to_end(&mut bytes) + .await + .map_err(|_| error(Status::BadRequest, "upload_attestation_invalid"))?; + if bytes.len() > MAX_UPLOAD_METADATA_BYTES { + return Err(error(Status::PayloadTooLarge, "upload_attestation_invalid")); + } + Ok(bytes) +} + +fn request_body_digest(value: &Value) -> Option { + let mut unsigned = value.as_object()?.clone(); + unsigned.remove("requestBodyDigest"); + Some(encode_config( + Sha256::digest(jcs::canonicalize(&Value::Object(unsigned))), + URL_SAFE_NO_PAD, + )) +} + +fn without_signature(value: &Value) -> Value { + let mut object = value.as_object().cloned().unwrap_or_default(); + object.remove("signature"); + Value::Object(object) +} + +fn has_baseline_scope(capabilities: &[Capability]) -> bool { + capabilities.iter().any(|capability| { + capability.ability.as_ref().as_ref() == BASELINE_ABILITY + && matches!(&capability.resource, Resource::TinyCloud(resource) + if resource.service().as_str() == "capabilities" && resource.path().is_none()) + }) +} + +fn invocation_body_digest_matches(invocation: &InvocationInfo, expected: &str) -> bool { + invocation + .invocation + .payload() + .facts + .as_ref() + .is_some_and(|facts| { + facts.iter().any(|fact| { + fact.as_object().is_some_and(|object| { + object.get("requestBodyDigest").and_then(Value::as_str) == Some(expected) + }) + }) + }) +} + +async fn owner_did_and_expiry( + conn: &tinycloud_core::sea_orm::DatabaseConnection, + invocation: &InvocationInfo, +) -> Option<(String, Option)> { + // This route deliberately supports the ordinary one-parent session + // invocation shape. A multi-parent or cyclic/malformed chain fails + // closed instead of attributing an intermediate delegator as owner. + if invocation.parents.len() != 1 { + return None; + } + let mut current = tinycloud_core::hash::Hash::from(*invocation.parents.first()?); + let mut expected_delegatee = invocation.invoker.clone(); + let mut expiry: Option = None; + for _ in 0..32 { + let row = delegation::Entity::find_by_id(current) + .one(conn) + .await + .ok()??; + if !did_principal_matches(&row.delegatee, &expected_delegatee) { + return None; + } + expiry = match (expiry, row.expiry) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + }; + let parents = parent_delegations::Entity::find() + .filter(parent_delegations::Column::Child.eq(current)) + .all(conn) + .await + .ok()?; + if parents.is_empty() { + if !has_baseline_ability(conn, current).await { + return None; + } + return Some((row.delegator, expiry)); + } + if parents.len() != 1 { + return None; + } + expected_delegatee = row.delegator.clone(); + current = parents[0].parent; + } + None +} + +async fn has_baseline_ability( + conn: &tinycloud_core::sea_orm::DatabaseConnection, + delegation_id: tinycloud_core::hash::Hash, +) -> bool { + abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(delegation_id)) + .all(conn) + .await + .ok() + .is_some_and(|rows| { + rows.iter().any(|row| { + row.ability.as_ref().as_ref() == BASELINE_ABILITY + && matches!(&row.resource, Resource::TinyCloud(resource) + if resource.service().as_str() == "capabilities" && resource.path().is_none()) + }) + }) +} + +#[cfg(test)] +async fn debug_authorization_classification( + conn: &tinycloud_core::sea_orm::DatabaseConnection, + invocation: &InvocationInfo, +) -> &'static str { + let Some(parent) = invocation.parents.first() else { + return "missing_parent"; + }; + let id = tinycloud_core::hash::Hash::from(*parent); + let Some(row) = delegation::Entity::find_by_id(id) + .one(conn) + .await + .ok() + .flatten() + else { + return "delegation_missing"; + }; + if !did_principal_matches(&row.delegatee, &invocation.invoker) { + return "invocation_signer_mismatch"; + } + if !has_baseline_ability(conn, id).await { + return "baseline_ability_missing"; + } + "persisted_session_proof" +} + +fn validate_request( + request: &UploadAttestationRequest, + expected_origin: &str, + now: OffsetDateTime, +) -> Result<(), ()> { + let origin = TargetOrigin::parse(request.share_origin.clone()).map_err(|_| ())?; + if origin.as_str() != request.share_origin || request.share_origin != expected_origin { + return Err(()); + } + request + .encrypted_blob_cid + .parse::() + .map_err(|_| ())?; + let digest = + base64::decode_config(&request.encrypted_blob_sha256, URL_SAFE_NO_PAD).map_err(|_| ())?; + if digest.len() != 32 + || encode_config(&digest, URL_SAFE_NO_PAD) != request.encrypted_blob_sha256 + || request.byte_length > MAX_UPLOAD_BYTES + { + return Err(()); + } + let delete_after = OffsetDateTime::parse(&request.delete_after, &Rfc3339).map_err(|_| ())?; + if delete_after <= now || request.delete_after != timestamp(delete_after) { + return Err(()); + } + let retention = jcs::canonicalize(&request.retention); + if request.retention != Value::String("until-delete".to_owned()) + || retention.len() > MAX_RETENTION_BYTES + || delete_after > now + Duration::seconds(MAX_RETENTION_SECONDS) + { + return Err(()); + } + Ok(()) +} + +fn timestamp(value: OffsetDateTime) -> String { + let format = time::format_description::parse( + "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z", + ) + .expect("fixed timestamp format"); + value + .to_offset(time::UtcOffset::UTC) + .format(&format) + .expect("timestamp formats") +} + +#[cfg(test)] +mod tests { + use super::*; + use rocket::local::asynchronous::Client; + use serde_json::json; + use tinycloud_auth::multihash_codetable::{Code, MultihashDigest}; + use tinycloud_core::sea_orm::Database; + + fn request(now: OffsetDateTime) -> UploadAttestationRequest { + UploadAttestationRequest { + share_origin: "https://share.tinycloud.xyz".to_owned(), + encrypted_blob_cid: tinycloud_auth::ipld_core::cid::Cid::new_v1( + 0x55, + Code::Sha2_256.digest(b"encrypted blob"), + ) + .to_string(), + encrypted_blob_sha256: encode_config( + Sha256::digest(b"encrypted blob"), + URL_SAFE_NO_PAD, + ), + byte_length: 15, + delete_after: timestamp(now + Duration::hours(1)), + retention: Value::String("until-delete".to_owned()), + request_body_digest: "digest".to_owned(), + } + } + + #[test] + fn body_digest_excludes_only_the_digest_field() { + let value = json!({ + "byteLength": 4, + "deleteAfter": "2030-01-01T00:00:00.000Z", + "encryptedBlobCid": "bafybeigdyrzt4x3", + "encryptedBlobSha256": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "requestBodyDigest": "ignored", + "retention": "until-delete", + "shareOrigin": "https://share.tinycloud.xyz" + }); + let digest = request_body_digest(&value).expect("digest"); + let mut unsigned = value.as_object().unwrap().clone(); + unsigned.remove("requestBodyDigest"); + assert_eq!( + digest, + encode_config( + Sha256::digest(jcs::canonicalize(&Value::Object(unsigned))), + URL_SAFE_NO_PAD + ) + ); + } + + #[test] + fn signature_is_not_part_of_signed_attestation_bytes() { + let value = json!({"signature": "redacted", "version": 1}); + assert_eq!(without_signature(&value), json!({"version": 1})); + } + + #[tokio::test] + async fn mounted_route_requires_a_decodable_invocation_before_body_authorization() { + let database = Database::connect("sqlite::memory:") + .await + .expect("in-memory database"); + let rocket = rocket::build() + .mount("/", rocket::routes![mint_upload_attestation]) + .manage(None::) + .manage(InvocationReplayCache::new(database)) + .manage(Config::default()); + let client = Client::tracked(rocket).await.expect("Rocket client"); + + let cases = [ + ("missing authorization", None, Status::Unauthorized), + ( + "malformed authorization", + Some("not-an-invocation"), + Status::Unauthorized, + ), + ]; + for (name, authorization, expected) in cases { + let mut request = client + .post("/share/upload/attestation") + .header(rocket::http::ContentType::JSON) + .body("{}"); + if let Some(value) = authorization { + request = request.header(rocket::http::Header::new("Authorization", value)); + } + assert_eq!(request.dispatch().await.status(), expected, "{name}"); + } + } + + #[tokio::test] + async fn mounted_route_authorization_matrix_rejects_cryptographically_valid_but_unauthorized_invocations( + ) { + use rocket::http::{ContentType, Header, Status}; + use serde_json::Map; + use tinycloud_auth::{ + resolver::DID_METHODS, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, + }; + use tinycloud_core::keys::StaticSecret; + + fn metadata(now: OffsetDateTime) -> Value { + let unsigned = json!({ + "byteLength": 14, + "deleteAfter": timestamp(now + Duration::hours(1)), + "encryptedBlobCid": tinycloud_auth::ipld_core::cid::Cid::new_v1( + 0x55, + Code::Sha2_256.digest(b"encrypted blob"), + ).to_string(), + "encryptedBlobSha256": encode_config( + Sha256::digest(b"encrypted blob"), + URL_SAFE_NO_PAD, + ), + "retention": "until-delete", + "shareOrigin": "https://share.tinycloud.xyz", + }); + let request_body_digest = request_body_digest(&unsigned).expect("request digest"); + let mut body = unsigned.as_object().cloned().expect("object"); + body.insert( + "requestBodyDigest".to_owned(), + Value::String(request_body_digest), + ); + Value::Object(body) + } + + async fn invocation(expiration: f64, audience: &str) -> String { + let jwk = JWK::generate_ed25519().expect("test invocation key"); + let issuer = DID_METHODS + .generate(&jwk, "key") + .expect("test issuer") + .to_string(); + Payload { + issuer: issuer.parse::().expect("issuer vm"), + audience: audience.parse::().expect("audience did"), + not_before: None, + expiration: NumericDate::try_from_seconds(expiration).expect("expiration"), + nonce: Some(format!("urn:uuid:mounted-upload-{}", expiration as i64)), + facts: Some(vec![json!({ "requestBodyDigest": "wrong" })]), + proof: vec![], + attenuation: Capabilities::>::new(), + } + .sign(Algorithm::EdDSA, &jwk) + .expect("signed invocation") + .encode() + .expect("encoded invocation") + } + + let database = Database::connect("sqlite::memory:") + .await + .expect("authorization database"); + let signer_secret = StaticSecret::new(vec![7; 32]).expect("signing secret"); + let node_did = signer_secret.node_did(); + let mut config = Config::default(); + config.share_email.node_audience = node_did.clone(); + config.share_email.node_signing_kid = format!("{node_did}#invitation-key-1"); + let runtime = UploadAttestationRuntime::compose( + database.clone(), + &signer_secret, + &config.share_email, + ) + .expect("mounted runtime"); + let valid_audience = config.share_email.node_audience.clone(); + let replay_database = Database::connect("sqlite::memory:") + .await + .expect("replay database"); + let client = Client::tracked( + rocket::build() + .mount("/", rocket::routes![mint_upload_attestation]) + .manage(Some(runtime)) + .manage(InvocationReplayCache::new(replay_database)) + .manage(config), + ) + .await + .expect("Rocket client"); + let now = OffsetDateTime::now_utc(); + let cases = [ + ( + "missing proof", + now.unix_timestamp() as f64 + 60.0, + valid_audience.as_str(), + Status::Forbidden, + ), + ( + "expired invocation", + now.unix_timestamp() as f64 - 1.0, + valid_audience.as_str(), + Status::Unauthorized, + ), + ( + "wrong audience", + now.unix_timestamp() as f64 + 60.0, + "did:key:z6MktwtqAzuD5F77tAMBMwNs1KybZeff61EehV9xB1ZpXQG7", + Status::Forbidden, + ), + ]; + for (name, expiration, audience, expected) in cases { + let body = metadata(now); + let response = client + .post("/share/upload/attestation") + .header(ContentType::JSON) + .header(Header::new( + "Authorization", + invocation(expiration, audience).await, + )) + .body(jcs::canonicalize(&body)) + .dispatch() + .await; + assert_eq!(response.status(), expected, "{name}"); + } + } + + #[tokio::test] + async fn packed_session_invocation_gets_a_mounted_attestation_after_activation() { + use rocket::http::{ContentType, Header, Status}; + use tinycloud_auth::{ + authorization::HeaderEncode, + resolver::DID_METHODS, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, + }; + use tinycloud_core::{ + events::Delegation, + keys::StaticSecret, + migrations::Migrator, + models::{abilities, actor, delegation}, + sea_orm::{ActiveModelTrait, Set}, + sea_orm_migration::MigratorTrait, + types::{Ability, Resource}, + }; + + let database = Database::connect("sqlite::memory:") + .await + .expect("authorization database"); + Migrator::up(&database, None) + .await + .expect("database schema"); + let signer_secret = StaticSecret::new(vec![7; 32]).expect("signing secret"); + let mut config = Config::default(); + config.share_email.node_audience = "did:web:node.example".to_owned(); + config.share_email.node_signing_kid = + format!("{}#invitation-key-1", config.share_email.node_audience); + let runtime = UploadAttestationRuntime::compose( + database.clone(), + &signer_secret, + &config.share_email, + ) + .expect("mounted runtime"); + let session_jwk = JWK::generate_ed25519().expect("session key"); + let session_vm = DID_METHODS + .generate(&session_jwk, "key") + .expect("session verification method") + .to_string(); + let session_did = session_vm + .split('#') + .next() + .expect("session DID") + .to_owned(); + let owner_jwk = JWK::generate_ed25519().expect("owner key"); + let owner_vm = DID_METHODS + .generate(&owner_jwk, "key") + .expect("owner verification method") + .to_string(); + let owner_did = owner_vm.split('#').next().expect("owner DID").to_owned(); + let space = format!("tinycloud:{owner_did}:documents"); + let capability_resource = format!("{space}/capabilities"); + let mut capabilities = Capabilities::>::new(); + capabilities.with_actions( + capability_resource.parse().expect("capability resource"), + std::iter::once((BASELINE_ABILITY.parse().expect("baseline ability"), [])), + ); + let expiration = OffsetDateTime::now_utc() + Duration::hours(1); + let delegation = Payload { + issuer: owner_vm.parse::().expect("owner VM"), + audience: session_did.parse::().expect("session DID"), + not_before: None, + expiration: NumericDate::try_from_seconds(expiration.unix_timestamp() as f64) + .expect("delegation expiration"), + nonce: Some("mounted-share-session-proof".into()), + facts: Some(Vec::::new()), + proof: Vec::new(), + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, &owner_jwk) + .expect("signed session delegation"); + let delegation_header = delegation.encode().expect("encoded delegation"); + let persisted = Delegation::from_header_ser::< + tinycloud_auth::authorization::TinyCloudDelegation, + >(&delegation_header) + .expect("decoded delegation"); + let delegation_id = persisted.content_hash(); + actor::ActiveModel { + id: Set(owner_did.clone()), + } + .insert(&database) + .await + .expect("owner actor"); + actor::ActiveModel { + id: Set(session_did.clone()), + } + .insert(&database) + .await + .expect("session actor"); + delegation::ActiveModel { + id: Set(delegation_id), + delegator: Set(owner_did), + delegatee: Set(session_did.clone()), + expiry: Set(Some(expiration)), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(persisted.serialized_bytes().to_vec()), + } + .insert(&database) + .await + .expect("persisted session delegation"); + abilities::ActiveModel { + delegation: Set(delegation_id), + resource: Set(capability_resource.parse::().expect("capability")), + ability: Set(Ability::try_from(BASELINE_ABILITY.to_owned()).expect("ability")), + caveats: Set(Default::default()), + } + .insert(&database) + .await + .expect("persisted baseline ability"); + + let body_without_digest = json!({ + "byteLength": 14, + "deleteAfter": timestamp(OffsetDateTime::now_utc() + Duration::hours(1)), + "encryptedBlobCid": tinycloud_auth::ipld_core::cid::Cid::new_v1( + 0x55, + Code::Sha2_256.digest(b"encrypted blob"), + ).to_string(), + "encryptedBlobSha256": encode_config(Sha256::digest(b"encrypted blob"), URL_SAFE_NO_PAD), + "retention": "until-delete", + "shareOrigin": "https://share.tinycloud.xyz", + }); + let digest = request_body_digest(&body_without_digest).expect("request digest"); + let mut body = body_without_digest + .as_object() + .expect("body object") + .clone(); + body.insert("requestBodyDigest".into(), Value::String(digest.clone())); + let invocation = tinycloud_auth::authorization::make_invocation( + vec![( + capability_resource.parse().expect("resource ID"), + vec![BASELINE_ABILITY.parse().expect("baseline ability")], + )], + &delegation_id.to_cid(0x55), + &session_jwk, + &session_vm, + (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() as f64, + tinycloud_auth::authorization::InvocationOptions { + facts: Some(vec![json!({ "requestBodyDigest": digest })]), + ..Default::default() + }, + ) + .expect("packed invocation shape"); + let mut target_payload = invocation.payload().clone(); + target_payload.audience = config + .share_email + .node_audience + .parse::() + .expect("node audience"); + let invocation = target_payload + .sign(Algorithm::EdDSA, &session_jwk) + .expect("target-bound invocation"); + let invocation_header = HeaderEncode::encode(&invocation).expect("encoded invocation"); + let decoded = InvocationInfo::try_from( + tinycloud_auth::authorization::TinyCloudInvocation::decode(&invocation_header) + .expect("decoded invocation"), + ) + .expect("invocation info"); + assert_eq!( + debug_authorization_classification(&database, &decoded).await, + "persisted_session_proof" + ); + assert!(has_baseline_scope(&decoded.capabilities)); + assert!(invocation_body_digest_matches(&decoded, &digest)); + assert!(invocation_model::authorize_admitted( + &database, + &decoded, + OffsetDateTime::now_utc() + ) + .await + .is_ok()); + + let replay_database = Database::connect("sqlite::memory:") + .await + .expect("replay database"); + Migrator::up(&replay_database, None) + .await + .expect("replay database schema"); + let client = Client::tracked( + rocket::build() + .mount("/", rocket::routes![mint_upload_attestation]) + .manage(Some(runtime)) + .manage(InvocationReplayCache::new(replay_database)) + .manage(config), + ) + .await + .expect("Rocket client"); + let response = client + .post("/share/upload/attestation") + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation_header)) + .body(jcs::canonicalize(&Value::Object(body))) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + } + + #[test] + fn request_validation_rejects_redirects_and_ambiguous_origins() { + let now = OffsetDateTime::now_utc(); + for origin in [ + "https://share.tinycloud.xyz/redirect", + "https://share.tinycloud.xyz?next=/", + "https://share.tinycloud.xyz#fragment", + "https://share.tinycloud.xyz:443", + ] { + let mut candidate = request(now); + candidate.share_origin = origin.to_owned(); + assert!(validate_request(&candidate, "https://share.tinycloud.xyz", now).is_err()); + } + } + + #[test] + fn request_validation_rejects_bad_digest_size_and_delete_time() { + let now = OffsetDateTime::now_utc(); + let mut candidate = request(now); + candidate.encrypted_blob_sha256 = encode_config([0u8; 31], URL_SAFE_NO_PAD); + assert!(validate_request(&candidate, "https://share.tinycloud.xyz", now).is_err()); + + let mut candidate = request(now); + candidate.byte_length = MAX_UPLOAD_BYTES + 1; + assert!(validate_request(&candidate, "https://share.tinycloud.xyz", now).is_err()); + + let mut candidate = request(now); + candidate.delete_after = timestamp(now - Duration::seconds(1)); + assert!(validate_request(&candidate, "https://share.tinycloud.xyz", now).is_err()); + + let mut candidate = request(now); + candidate.delete_after = timestamp(now + Duration::seconds(MAX_RETENTION_SECONDS + 1)); + assert!(validate_request(&candidate, "https://share.tinycloud.xyz", now).is_err()); + + let mut candidate = request(now); + candidate.retention = Value::String("until-delete-extra".to_owned()); + assert!(validate_request(&candidate, "https://share.tinycloud.xyz", now).is_err()); + } +} diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index b398677b..aa46c8de 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -29,7 +29,7 @@ use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use tokio::io::AsyncReadExt; use tinycloud_auth::{ - authorization::TinyCloudDelegation, + authorization::{HeaderEncode, TinyCloudDelegation}, identity::did_principal_matches, multihash_codetable::{Code, MultihashDigest}, share_email_evidence::{normalized_email_hash, verify_detached_ed25519}, @@ -1137,9 +1137,8 @@ fn validate_policy( || policy.recipient_matcher.value.is_empty() || !matches!( policy.recipient_matcher.kind.as_str(), - "exactEmail" | "emailDomain" + "exactEmail" | "emailDomain" | "recipientDid" ) - || !policy.recipient_matcher.value.is_ascii() { return Err(()); } @@ -1152,6 +1151,12 @@ fn validate_policy( &policy.recipient_matcher.value, ) .map_err(|_| ())?, + "recipientDid" => { + tinycloud_core::share_email::types::Did::parse(policy.recipient_matcher.value.clone()) + .map_err(|_| ())? + .as_str() + .to_owned() + } _ => return Err(()), }; if canonical_matcher != policy.recipient_matcher.value { @@ -1361,6 +1366,52 @@ async fn verify_delegation_signature(delegation: &TinyCloudDelegation) -> Result Ok(()) } +/// Recipient-DID authorization carries the exact OpenKey session delegation +/// that the authenticated client restored. The holder signature and +/// presentation bind its digest to the request; this check binds the same +/// bytes to Node's durable delegation record and live revocation graph. +async fn verify_recipient_did_credential( + runtime: &ShareV2Runtime, + credential: &str, + credential_cid: &str, + holder_did: &str, +) -> Result<(), ()> { + if credential.is_empty() || credential == "openkey-device-session" { + return Err(()); + } + let (delegation, bytes) = TinyCloudDelegation::decode(credential).map_err(|_| ())?; + if bytes.is_empty() || raw_blake3_cid(&bytes) != credential_cid { + return Err(()); + } + let cid = credential_cid + .parse::() + .map_err(|_| ())?; + let id = Hash::from(cid); + let row = delegation::Entity::find_by_id(id) + .one(&runtime.conn) + .await + .map_err(|_| ())? + .ok_or(())?; + let stored = + maybe_decrypt(Some(&runtime.policy_encryption), &row.serialization).map_err(|_| ())?; + if stored != bytes || revocation_in_ancestry(&runtime.conn, id).await? { + return Err(()); + } + verify_delegation_signature(&delegation).await?; + let info = DelegationInfo::try_from(delegation).map_err(|_| ())?; + let now = OffsetDateTime::now_utc(); + if !did_principal_matches(&info.delegate, holder_did) + || !did_principal_matches(&row.delegatee, holder_did) + || info.expiry.is_none_or(|expiry| expiry <= now) + || info.not_before.is_some_and(|not_before| not_before > now) + || row.expiry.is_none_or(|expiry| expiry <= now) + || row.not_before.is_some_and(|not_before| not_before > now) + { + return Err(()); + } + Ok(()) +} + fn raw_blake3_cid(bytes: &[u8]) -> String { tinycloud_core::hash::hash(bytes).to_cid(0x55).to_string() } @@ -1625,6 +1676,7 @@ struct V2DeliveryRequest { share_url: String, document_name: String, jti: String, + idempotency_key: String, expires_at: String, request_body_digest: String, } @@ -1931,6 +1983,12 @@ async fn registered_policy( ) .map_err(|_| ())?, ), + "recipientDid" => TypedRecipientMatcher::RecipientDid( + Did::parse(policy.policy.recipient_matcher.value.clone()) + .map_err(|_| ())? + .as_str() + .to_owned(), + ), _ => return Err(()), }; let matcher_json = serde_json::to_value(&policy.policy.recipient_matcher).map_err(|_| ())?; @@ -2362,7 +2420,7 @@ fn challenge_request_violation( request: &V2ChallengeRequest, runtime: &ShareV2Runtime, ) -> Option<&'static str> { - let checks: [(&'static str, bool); 16] = [ + let checks: [(&'static str, bool); 17] = [ ( "envelope_cid_equals_registration_cid", request.envelope_cid == request.registration_cid, @@ -2433,6 +2491,13 @@ fn challenge_request_violation( "requested_action_outside_registered_policy", !registered.envelope.policy.actions.contains(&request.action), ), + ( + "recipient_did_holder_mismatch", + registered + .matcher + .recipient_did() + .is_some_and(|recipient| recipient != request.holder_did), + ), ]; checks .into_iter() @@ -2574,7 +2639,16 @@ pub async fn policy_session_v2( return Err(error(Status::ServiceUnavailable, "capability_unavailable")); } let raw = read_body(data).await?; - let request: V2SessionRequest = serde_json::from_slice(&raw) + let raw_value: Value = serde_json::from_slice(&raw) + .map_err(|_| error(Status::BadRequest, "policy_session_invalid"))?; + if !raw_value + .get("credential") + .and_then(Value::as_str) + .is_some_and(|credential| !credential.is_empty()) + { + return Err(error(Status::Unauthorized, "recipient_credential_required")); + } + let request: V2SessionRequest = serde_json::from_value(raw_value) .map_err(|_| error(Status::BadRequest, "policy_session_invalid"))?; let challenge = share_anonymous_challenge::Entity::find_by_id(&request.challenge_id) .one(&runtime.conn) @@ -2678,6 +2752,14 @@ pub async fn policy_session_v2( .get("credentialDigest") .and_then(Value::as_str) != Some(credential_digest.as_str()) + || binding_message.get("targetOrigin").and_then(Value::as_str) + != Some(challenge_request.target_origin.as_str()) + || binding_message.get("nodeAudience").and_then(Value::as_str) + != Some(challenge_request.node_audience.as_str()) + || binding_message.get("enforcerDid").and_then(Value::as_str) + != Some(runtime.enforcer_did.as_str()) + || binding_message.get("expiresAt").and_then(Value::as_str) + != Some(presentation.expires_at.as_str()) { return Err(share_error("invalid_holder_proof")); } @@ -2685,50 +2767,71 @@ pub async fn policy_session_v2( typed_scope(®istered, &challenge_request).map_err(|_| share_error("policy_denied"))?; let holder = DidKey::parse(presentation_holder.to_owned()) .map_err(|_| share_error("invalid_holder_proof"))?; - let expiry = registered.expiry.unix_timestamp(); - let verifier = runtime - .verifier - .as_ref() - .ok_or(error(Status::ServiceUnavailable, "capability_unavailable"))? - .at_time(now.unix_timestamp()); - let evidence = verifier - .verify_matcher_for( - request.credential.as_bytes(), - &scope, - &holder, - ®istered.matcher, - expiry, + if let Some(recipient_did) = registered.matcher.recipient_did() { + if recipient_did != presentation_holder { + return Err(share_error("policy_denied")); + } + let credential_cid = binding_message + .get("delegationCid") + .and_then(Value::as_str) + .ok_or_else(|| share_error("policy_denied"))?; + if binding_message.get("jti").and_then(Value::as_str) != Some(presentation.jti.as_str()) { + return Err(share_error("invalid_holder_proof")); + } + verify_recipient_did_credential( + runtime, + &request.credential, + credential_cid, + presentation_holder, ) + .await .map_err(|_| share_error("policy_denied"))?; - if evidence.credential_digest.as_str() != credential_digest { - return Err(share_error("policy_denied")); + } else { + let expiry = registered.expiry.unix_timestamp(); + let verifier = runtime + .verifier + .as_ref() + .ok_or(error(Status::ServiceUnavailable, "capability_unavailable"))? + .at_time(now.unix_timestamp()); + let evidence = verifier + .verify_matcher_for( + request.credential.as_bytes(), + &scope, + &holder, + ®istered.matcher, + expiry, + ) + .map_err(|_| share_error("policy_denied"))?; + if evidence.credential_digest.as_str() != credential_digest { + return Err(share_error("policy_denied")); + } + let expected_email_hash = normalized_email_hash(&evidence.disclosed_email) + .map_err(|_| share_error("policy_denied"))?; + let challenge_nonce = ProtocolNonce::parse(request.nonce.clone()) + .map_err(|_| share_error("invalid_holder_proof"))?; + let challenge_id = ProtocolNonce::parse(request.challenge_id.clone()) + .map_err(|_| share_error("invalid_holder_proof"))?; + let request_digest = Sha256Digest::parse(challenge_request.request_body_digest.clone()) + .map_err(|_| share_error("invalid_holder_proof"))?; + let enforcer = DidKey::parse(runtime.enforcer_did.clone()) + .map_err(|_| share_error("invalid_holder_proof"))?; + verifier + .verify_holder_binding( + &holder_binding, + &scope, + &expected_email_hash, + &evidence.credential_digest, + challenge_id.as_str(), + &challenge_nonce, + &request_digest, + &enforcer, + &holder, + &holder, + &holder, + &holder, + ) + .map_err(|_| share_error("invalid_holder_proof"))?; } - let expected_email_hash = normalized_email_hash(&evidence.disclosed_email) - .map_err(|_| share_error("policy_denied"))?; - let challenge_nonce = ProtocolNonce::parse(request.nonce.clone()) - .map_err(|_| share_error("invalid_holder_proof"))?; - let challenge_id = ProtocolNonce::parse(request.challenge_id.clone()) - .map_err(|_| share_error("invalid_holder_proof"))?; - let request_digest = Sha256Digest::parse(challenge_request.request_body_digest.clone()) - .map_err(|_| share_error("invalid_holder_proof"))?; - let enforcer = DidKey::parse(runtime.enforcer_did.clone()) - .map_err(|_| share_error("invalid_holder_proof"))?; - verifier - .verify_holder_binding( - &holder_binding, - &scope, - &expected_email_hash, - &evidence.credential_digest, - challenge_id.as_str(), - &challenge_nonce, - &request_digest, - &enforcer, - &holder, - &holder, - &holder, - &holder, - ) - .map_err(|_| share_error("invalid_holder_proof"))?; let session_id = tinycloud_core::share_email::invitation::random_protocol_nonce(); let session_expires = (now + time::Duration::minutes(5)).min(registered.expiry); let runtime_delegation = create_runtime_delegation( @@ -3271,6 +3374,7 @@ pub async fn authorize_delivery_v2( if expires <= now || expires > now + time::Duration::minutes(5) || decode_canonical_b64(&request.jti, 16).map_or(true, |value| value.len() != 16) + || !canonical_delivery_idempotency_key(&request.idempotency_key) { return Err(share_error("delivery_authorization_invalid")); } @@ -3346,7 +3450,7 @@ pub async fn authorize_delivery_v2( "authorityMaterialHandle": request.registration_cid, "authorityMaterialDigest": b64_digest(request.registration_cid.as_bytes()), "requestBodyDigest": request.request_body_digest, - "idempotencyKey": request.jti, + "idempotencyKey": request.idempotency_key, "expiresAt": request.expires_at, "dataAuthority": false }); @@ -3357,6 +3461,14 @@ pub async fn authorize_delivery_v2( )) } +fn canonical_delivery_idempotency_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte)) +} + #[cfg(test)] mod tests { use super::*; @@ -3462,6 +3574,20 @@ mod tests { assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_ok()); } + #[test] + fn policy_validation_accepts_recipient_did_and_rejects_noncanonical_dids() { + let (mut envelope, request, config) = sample_policy(); + envelope.policy.recipient_matcher = RecipientMatcher { + kind: "recipientDid".to_owned(), + value: "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw".to_owned(), + }; + assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_ok()); + envelope.policy.recipient_matcher.value = "did:key:zholder".to_owned(); + assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + envelope.policy.recipient_matcher.value = "did:web:-recipient.example".to_owned(); + assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + } + #[test] fn policy_validation_rejects_path_and_action_widening() { let (mut envelope, request, config) = sample_policy(); @@ -4888,6 +5014,24 @@ mod tests { ); } + #[tokio::test] + async fn recipient_did_credential_rejects_the_legacy_marker_at_the_authority_boundary() { + let key_setup = StaticSecret::new(vec![0x39u8; 32]).expect("32-byte test secret"); + let tee_context = TeeContext::derive_local(&key_setup); + let runtime = compose_test_runtime(&key_setup, Some(tee_context), true).await; + assert!( + verify_recipient_did_credential( + &runtime, + "openkey-device-session", + "bafybeigdyrzt5n6n4j2s5x7m7n5f4q6r3s2t1u0v9w8x7y6z5a4b3c2d1e0", + "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw", + ) + .await + .is_err(), + "recipient-DID authorization must never treat the old marker as a credential" + ); + } + #[tokio::test] async fn production_corpus_registration_request_dispatches_through_a_real_rocket_route() { let corpus = production_corpus();