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
124 changes: 114 additions & 10 deletions test/n4-mounted-e2e/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -309,17 +317,24 @@ 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 {
let arguments = json!({"document_id":123});
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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -574,7 +595,7 @@ async fn seed_sql(rocket: &Rocket<Build>) -> Result<()> {
Ok(())
}

async fn seed_kv(rocket: &Rocket<Build>, seed: [u8; 32]) -> Result<()> {
async fn seed_kv(rocket: &Rocket<Build>, seed: [u8; 32]) -> Result<SessionFixture> {
let space = SpaceId::from_str(
"tinycloud:key:z6MktwtqAzuD5F77tAMBMwNs1KybZeff61EehV9xB1ZpXQG7:documents",
)?;
Expand Down Expand Up @@ -626,6 +647,14 @@ async fn seed_kv(rocket: &Rocket<Build>, seed: [u8; 32]) -> Result<()> {
resource.clone().as_uri(),
std::iter::once(("tinycloud.kv/put".parse()?, [])),
);
let capabilities_resource =
space
.clone()
.to_resource("capabilities".parse::<Service>()?, 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::<DIDURLBuf>()?,
audience: verification_method
Expand All @@ -643,8 +672,8 @@ async fn seed_kv(rocket: &Rocket<Build>, seed: [u8; 32]) -> Result<()> {
attenuation: delegation_caps,
}
.sign(Algorithm::EdDSA, &jwk)?;
let delegation_event =
Delegation::from_header_ser::<TinyCloudDelegation>(&delegation.encode()?)?;
let delegation_header = delegation.encode()?;
let delegation_event = Delegation::from_header_ser::<TinyCloudDelegation>(&delegation_header)?;
let delegation_cid = delegation_event.content_hash().to_cid(0x55);
let tinycloud = rocket
.state::<TinyCloud>()
Expand Down Expand Up @@ -691,7 +720,13 @@ async fn seed_kv(rocket: &Rocket<Build>, seed: [u8; 32]) -> Result<()> {
.invoke::<BlockStage>(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<Build>) -> Result<()> {
Expand Down Expand Up @@ -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::<u16>()
.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")
Expand Down Expand Up @@ -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");
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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");
Expand Down Expand Up @@ -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}");
})
}));
Expand Down
2 changes: 1 addition & 1 deletion tinycloud-core/src/models/invocation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ pub(crate) async fn process_admitted<C: ConnectionTrait>(
/// 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<C: ConnectionTrait>(
pub async fn authorize_admitted<C: ConnectionTrait>(
db: &C,
invocation: &util::InvocationInfo,
now: OffsetDateTime,
Expand Down
43 changes: 42 additions & 1 deletion tinycloud-core/src/share_email/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,8 @@ pub enum RecipientMatcher {
ExactEmail(String),
#[serde(rename = "emailDomain")]
EmailDomain(String),
#[serde(rename = "recipientDid")]
RecipientDid(String),
}

impl RecipientMatcher {
Expand All @@ -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),
}
}

Expand All @@ -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(),
}
}

Expand All @@ -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)
}
}
}

Expand All @@ -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 {
Expand All @@ -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])")
}
}
}
}
Expand Down Expand Up @@ -1368,6 +1391,24 @@ mod tests {
assert!(serde_json::from_str::<SafeJsonInteger>("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!({
Expand Down
1 change: 1 addition & 0 deletions tinycloud-core/src/share_email/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions tinycloud-node-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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?);
Expand Down
1 change: 1 addition & 0 deletions tinycloud-node-server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading