From 97912e20755c8c04db6c791bc7c93aa51e0fcea1 Mon Sep 17 00:00:00 2001 From: Naomi Date: Tue, 20 Oct 2026 22:18:18 +0000 Subject: [PATCH 01/11] TC-1500: restrict UCAN DID resolution to one allow-list Replace the network_methods gate with a fixed allow-list: did:key, did:pkh and did:jwk. Refuse every other method, did:pkh outside the eip155 namespace, and did:key outside Ed25519, before any resolver lookup at each UCAN verification and DID resolution site. Remove the network_methods configuration logic. DidResolutionConfig stays as an empty section so existing configuration files still parse; the /info field is a constant false. Tests: a table test over the resolution sites with a counting resolver proves refusal with zero lookups; an app-startup test checks that the retired environment variable does not enable any method. --- tinycloud-core/src/did_resolution.rs | 116 +++++--- tinycloud-core/tests/did_resolution_policy.rs | 278 +++++++++++------- tinycloud-node-server/src/config.rs | 11 +- tinycloud-node-server/src/lib.rs | 14 +- tinycloud-node-server/src/main.rs | 22 -- .../src/node_control/control.rs | 10 - tinycloud-node-server/src/routes/mod.rs | 3 +- tinycloud-node-server/src/runtime.rs | 2 - .../tests/did_resolution_app_startup.rs | 65 +--- 9 files changed, 261 insertions(+), 260 deletions(-) diff --git a/tinycloud-core/src/did_resolution.rs b/tinycloud-core/src/did_resolution.rs index da03b697..05418674 100644 --- a/tinycloud-core/src/did_resolution.rs +++ b/tinycloud-core/src/did_resolution.rs @@ -4,7 +4,7 @@ use std::{ collections::{HashMap, VecDeque}, env, sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, + atomic::{AtomicU64, Ordering}, Mutex, OnceLock, }, time::{Duration, Instant}, @@ -18,27 +18,16 @@ use tinycloud_auth::ssi::{ ucan::{Ucan, UcanRevocation}, }; -pub const OFFLINE_DID_METHODS: &[&str] = &["key", "pkh", "jwk"]; -static NETWORK_METHODS: AtomicBool = AtomicBool::new(false); +/// The only issuer DID methods the node resolves. Each derives verification material without +/// network access. Every other method is refused before any lookup, whatever the configuration. +pub const ALLOWED_DID_METHODS: &[&str] = &["key", "pkh", "jwk"]; +/// `did:key` is limited to Ed25519: base58btc multibase `z` over the `0xed 0x01` multicodec. +const ED25519_DID_KEY_PREFIX: &str = "z6Mk"; +/// `did:pkh` is limited to the EVM (`eip155`) namespace. +const PKH_EVM_NAMESPACE_PREFIX: &str = "eip155:"; static REFUSED_TOTAL: AtomicU64 = AtomicU64::new(0); static REFUSAL_OBSERVER: OnceLock = OnceLock::new(); -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct DidResolutionPolicy { - pub network_methods: bool, -} - -/// Set the process policy from effective startup configuration. -pub fn configure(policy: DidResolutionPolicy) { - NETWORK_METHODS.store(policy.network_methods, Ordering::Relaxed); -} - -pub fn policy() -> DidResolutionPolicy { - DidResolutionPolicy { - network_methods: NETWORK_METHODS.load(Ordering::Relaxed), - } -} - /// Process-lifetime refusal count. Public status reads never acquire a lock. pub fn refused_total() -> u64 { REFUSED_TOTAL.load(Ordering::Relaxed) @@ -61,47 +50,61 @@ pub enum UcanVerifyError { Invalid, } -/// Defence in depth for callers resolving a DID directly. -pub struct NodeDidResolver { - network_methods: bool, +/// Defence in depth for callers resolving a DID directly. `inner` is consulted only after +/// the method and namespace checks pass. +pub struct NodeDidResolver { + inner: R, } -impl NodeDidResolver { +impl NodeDidResolver { pub fn current() -> Self { Self { - network_methods: policy().network_methods, + inner: AnyDidMethod::default(), } } - +} +impl NodeDidResolver { + /// Wrap `inner` as the lookup backend. Refusal runs before `inner` is consulted. + pub fn new(inner: R) -> Self { + Self { inner } + } fn check(&self, issuer: &DIDURL) -> Result<(), UcanVerifyError> { if issuer.query().is_some() { refused(issuer.did(), "query_rejected"); return Err(UcanVerifyError::QueryRejected); } - self.check_method(issuer.did()) - } - - fn check_method(&self, did: &DID) -> Result<(), UcanVerifyError> { - let method = did.method_name(); - if !self.network_methods && !OFFLINE_DID_METHODS.contains(&method) { - refused(did, "method_disabled"); - return Err(UcanVerifyError::MethodDisabled { - method: method.to_owned(), - }); - } - Ok(()) + check_method(issuer.did()) } } -impl DIDResolver for NodeDidResolver { +impl DIDResolver for NodeDidResolver { async fn resolve_representation<'a>( &'a self, did: &'a DID, options: Options, ) -> Result>, resolution::Error> { - self.check_method(did) - .map_err(resolution::Error::internal)?; - AnyDidMethod::default() - .resolve_representation(did, options) - .await + check_method(did).map_err(resolution::Error::internal)?; + self.inner.resolve_representation(did, options).await + } +} + +/// Admit a DID only when its method is allow-listed and, for `did:key` and `did:pkh`, its +/// key type or namespace is allowed. Runs before any lookup. +fn check_method(did: &DID) -> Result<(), UcanVerifyError> { + let method = did.method_name(); + if !ALLOWED_DID_METHODS.contains(&method) || !namespace_allowed(did) { + refused(did, "method_disabled"); + return Err(UcanVerifyError::MethodDisabled { + method: method.to_owned(), + }); + } + Ok(()) +} + +fn namespace_allowed(did: &DID) -> bool { + let id = did.method_specific_id(); + match did.method_name() { + "key" => id.starts_with(ED25519_DID_KEY_PREFIX), + "pkh" => id.starts_with(PKH_EVM_NAMESPACE_PREFIX), + _ => true, } } @@ -111,7 +114,20 @@ where F: for<'de> Deserialize<'de> + Serialize, A: for<'de> Deserialize<'de> + Serialize, { - let resolver = NodeDidResolver::current(); + verify_ucan_signature_with(ucan, AnyDidMethod::default()).await +} + +/// Verify a UCAN against an explicit lookup backend. Refusal runs before `inner` is consulted. +pub async fn verify_ucan_signature_with( + ucan: &Ucan, + inner: R, +) -> Result<(), UcanVerifyError> +where + F: for<'de> Deserialize<'de> + Serialize, + A: for<'de> Deserialize<'de> + Serialize, + R: DIDResolver, +{ + let resolver = NodeDidResolver { inner }; resolver.check(&ucan.payload().issuer)?; tokio::time::timeout(did_resolution_timeout(), ucan.verify_signature(&resolver)) .await @@ -125,7 +141,17 @@ pub async fn verify_ucan_revocation_signature( algorithm: Algorithm, jwk: Option<&JWK>, ) -> Result<(), UcanVerifyError> { - let resolver = NodeDidResolver::current(); + verify_ucan_revocation_signature_with(revocation, algorithm, jwk, AnyDidMethod::default()).await +} + +/// Revocation counterpart of [`verify_ucan_signature_with`]. +pub async fn verify_ucan_revocation_signature_with( + revocation: &UcanRevocation, + algorithm: Algorithm, + jwk: Option<&JWK>, + inner: R, +) -> Result<(), UcanVerifyError> { + let resolver = NodeDidResolver { inner }; resolver.check(&revocation.issuer)?; tokio::time::timeout( did_resolution_timeout(), diff --git a/tinycloud-core/tests/did_resolution_policy.rs b/tinycloud-core/tests/did_resolution_policy.rs index 3fd547bd..8aee3753 100644 --- a/tinycloud-core/tests/did_resolution_policy.rs +++ b/tinycloud-core/tests/did_resolution_policy.rs @@ -1,10 +1,12 @@ -//! TC-1080 refusal regressions. Every socket target is a listener bound by this test. +//! DID method allow-list refusal tests. Every socket target is a listener bound by this test. #[path = "support/did_resolution.rs"] mod support; use futures::FutureExt; use std::{ - io::{Read, Write}, - sync::OnceLock, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, time::Duration, }; use support::*; @@ -12,8 +14,8 @@ use tinycloud_auth::{ resolver::DID_METHODS, ssi::{ dids::{ - resolution::{DerefError, Error as ResolutionError, Options}, - DIDBuf, DIDResolver, DIDURLBuf, + resolution::{DerefError, Error as ResolutionError, Options, Output}, + DIDBuf, DIDResolver, DIDURLBuf, DID, }, jwk::{Algorithm, JWK}, ucan::UcanRevocation, @@ -21,23 +23,154 @@ use tinycloud_auth::{ }; use tinycloud_core::did_resolution::*; -async fn policy_lock() -> tokio::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| tokio::sync::Mutex::new(())) - .lock() - .await +/// Counts lookups that reach the inner backend. Always fails, so a test cannot pass by success. +#[derive(Clone, Default)] +struct CountingResolver(Arc); + +impl CountingResolver { + fn lookups(&self) -> usize { + self.0.load(Ordering::SeqCst) + } +} + +impl DIDResolver for CountingResolver { + async fn resolve_representation<'a>( + &'a self, + _did: &'a DID, + _options: Options, + ) -> Result>, ResolutionError> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(ResolutionError::internal("counting stub")) + } +} + +/// (issuer, method label expected in the refusal). +const REFUSED: &[(&str, &str)] = &[ + ("did:web:127.0.0.1%3A1", "web"), + ("did:web:example.com", "web"), + ("did:tz:mainnet:local", "tz"), + ( + "did:ethr:0x0000000000000000000000000000000000000001", + "ethr", + ), + ("did:ion:local", "ion"), + ("did:future:local", "future"), + ("did:pkh:solana:local:abc", "pkh"), + ("did:pkh:bip122:local:abc", "pkh"), + ("did:pkh:tezos:local:abc", "pkh"), + ("did:pkh:aleo:local:abc", "pkh"), + // secp256k1 did:key (multibase prefix zQ3s), not Ed25519 (z6Mk). + ( + "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme", + "key", + ), +]; + +#[tokio::test] +async fn refused_dids_are_refused_with_zero_lookups_at_every_site() { + let key = JWK::generate_ed25519().unwrap(); + for (issuer, method) in REFUSED { + let refusal = || UcanVerifyError::MethodDisabled { + method: (*method).into(), + }; + let message = refusal().to_string(); + + let counting = CountingResolver::default(); + let ucan = signed_ucan(issuer, &key, Algorithm::EdDSA); + assert_eq!( + verify_ucan_signature_with(&ucan, counting.clone()).await, + Err(refusal()), + "{issuer}: ucan signature" + ); + assert_eq!(counting.lookups(), 0, "{issuer}: ucan signature lookups"); + + let counting = CountingResolver::default(); + let revocation = UcanRevocation::sign( + issuer.parse().unwrap(), + ucan.payload().proof[0], + &key, + Algorithm::EdDSA, + ) + .unwrap(); + assert_eq!( + verify_ucan_revocation_signature_with( + &revocation, + Algorithm::EdDSA, + None, + counting.clone() + ) + .await, + Err(refusal()), + "{issuer}: revocation" + ); + assert_eq!(counting.lookups(), 0, "{issuer}: revocation lookups"); + + let did: DIDBuf = issuer.parse().unwrap(); + let counting = CountingResolver::default(); + let result = NodeDidResolver::new(counting.clone()) + .resolve_representation(&did, Options::default()) + .await; + assert!( + matches!(&result, Err(ResolutionError::Internal(m)) if *m == message), + "{issuer}: direct representation" + ); + assert_eq!( + counting.lookups(), + 0, + "{issuer}: direct representation lookups" + ); + + let url: DIDURLBuf = format!("{issuer}#key").parse().unwrap(); + let counting = CountingResolver::default(); + let result = NodeDidResolver::new(counting.clone()) + .dereference(&url) + .await; + assert!( + matches!( + &result, + Err(DerefError::Resolution(ResolutionError::Internal(m))) if *m == message + ), + "{issuer}: direct dereference" + ); + assert_eq!( + counting.lookups(), + 0, + "{issuer}: direct dereference lookups" + ); + } } -struct ResetPolicy; -impl Drop for ResetPolicy { - fn drop(&mut self) { - configure(DidResolutionPolicy::default()); + +#[tokio::test] +async fn allowed_dids_pass_the_gate_and_reach_lookup_once() { + let ed = JWK::generate_ed25519().unwrap(); + let secp = JWK::generate_secp256k1(); + let cases = [ + (DID_METHODS.generate(&ed, "key").unwrap().to_string(), "key"), + ( + DID_METHODS + .generate(&secp, "pkh:eip155:1") + .unwrap() + .to_string(), + "pkh:eip155:1", + ), + (DID_METHODS.generate(&ed, "jwk").unwrap().to_string(), "jwk"), + ]; + for (did, label) in cases { + let did: DIDBuf = did.parse().unwrap(); + let counting = CountingResolver::default(); + let result = NodeDidResolver::new(counting.clone()) + .resolve_representation(&did, Options::default()) + .await; + assert_eq!(counting.lookups(), 1, "{label}: lookup must run once"); + assert!( + matches!(&result, Err(ResolutionError::Internal(m)) if m == "counting stub"), + "{label}: must reach the inner backend, not the refusal" + ); } } #[tokio::test] -async fn default_refuses_web_variants_before_connecting() { - let _lock = policy_lock().await; - assert_eq!(policy(), DidResolutionPolicy::default()); +async fn web_variants_are_refused_before_connecting() { let first = bound_listener(); let second = bound_listener(); // Would be a local redirect destination. let port = first.local_addr().unwrap().port(); @@ -60,9 +193,7 @@ async fn default_refuses_web_variants_before_connecting() { } #[tokio::test] -async fn default_direct_representation_refuses_valid_web_before_connecting() { - let _lock = policy_lock().await; - assert_eq!(policy(), DidResolutionPolicy::default()); +async fn direct_representation_refuses_valid_web_before_connecting() { let listener = bound_listener(); let did: DIDBuf = format!( "did:web:127.0.0.1%3A{}", @@ -71,7 +202,7 @@ async fn default_direct_representation_refuses_valid_web_before_connecting() { .parse() .unwrap(); let resolver = NodeDidResolver::current(); - // Bound the test if the resolver's second guard regresses and SSI waits for HTTP. + // Bound the test if the refusal regresses and SSI waits for HTTP. let result = tokio::time::timeout( Duration::from_secs(1), resolver.resolve_representation(&did, Options::default()), @@ -86,9 +217,7 @@ async fn default_direct_representation_refuses_valid_web_before_connecting() { } #[tokio::test] -async fn default_direct_dereference_refuses_valid_web_before_connecting() { - let _lock = policy_lock().await; - assert_eq!(policy(), DidResolutionPolicy::default()); +async fn direct_dereference_refuses_valid_web_before_connecting() { let listener = bound_listener(); let issuer: DIDURLBuf = format!( "did:web:127.0.0.1%3A{}#key", @@ -109,8 +238,7 @@ async fn default_direct_dereference_refuses_valid_web_before_connecting() { } #[tokio::test] -async fn offline_refuses_tz_ethr_ion_and_unknown_methods() { - let _lock = policy_lock().await; +async fn real_resolver_refuses_non_allowlisted_methods_offline() { let listener = bound_listener(); let key = JWK::generate_ed25519().unwrap(); for method in ["tz", "ethr", "ion", "future"] { @@ -129,46 +257,38 @@ async fn offline_refuses_tz_ethr_ion_and_unknown_methods() { } #[tokio::test] -async fn issuer_queries_are_refused_without_panic_under_both_policies() { - let _lock = policy_lock().await; - let _reset = ResetPolicy; +async fn issuer_queries_are_refused_without_panic() { let listener = bound_listener(); let port = listener.local_addr().unwrap().port(); let key = JWK::generate_ed25519().unwrap(); - for enabled in [false, true] { - configure(DidResolutionPolicy { - network_methods: enabled, - }); - for issuer in [ - format!("did:tz:mainnet:local?tzkt_url=http://127.0.0.1:{port}/"), - format!("did:web:127.0.0.1%3A{port}?accept=foo"), - "did:key:local?accept%5B0%5D=foo".into(), - "did:key:local?".into(), - ] { - let ucan = signed_ucan(&issuer, &key, Algorithm::EdDSA); - let outcome = std::panic::AssertUnwindSafe(verify_ucan_signature(&ucan)) - .catch_unwind() - .await; - assert_eq!(outcome.unwrap(), Err(UcanVerifyError::QueryRejected)); - let revocation = UcanRevocation::sign( - issuer.parse().unwrap(), - ucan.payload().proof[0], - &key, - Algorithm::EdDSA, - ) - .unwrap(); - assert_eq!( - verify_ucan_revocation_signature(&revocation, Algorithm::EdDSA, None).await, - Err(UcanVerifyError::QueryRejected) - ); - } + for issuer in [ + format!("did:tz:mainnet:local?tzkt_url=http://127.0.0.1:{port}/"), + format!("did:web:127.0.0.1%3A{port}?accept=foo"), + "did:key:local?accept%5B0%5D=foo".into(), + "did:key:local?".into(), + ] { + let ucan = signed_ucan(&issuer, &key, Algorithm::EdDSA); + let outcome = std::panic::AssertUnwindSafe(verify_ucan_signature(&ucan)) + .catch_unwind() + .await; + assert_eq!(outcome.unwrap(), Err(UcanVerifyError::QueryRejected)); + let revocation = UcanRevocation::sign( + issuer.parse().unwrap(), + ucan.payload().proof[0], + &key, + Algorithm::EdDSA, + ) + .unwrap(); + assert_eq!( + verify_ucan_revocation_signature(&revocation, Algorithm::EdDSA, None).await, + Err(UcanVerifyError::QueryRejected) + ); } assert_no_connections(&[&listener]).await; } #[tokio::test] async fn key_pkh_and_jwk_still_verify_offline() { - let _lock = policy_lock().await; for method in ["key", "pkh:eip155:1", "jwk"] { let (key, algorithm) = if method.starts_with("pkh") { (JWK::generate_secp256k1(), Algorithm::ES256K) @@ -197,47 +317,3 @@ async fn key_pkh_and_jwk_still_verify_offline() { ); } } - -#[tokio::test] -async fn explicit_opt_in_verifies_web_against_bound_listener() { - let _lock = policy_lock().await; - let _reset = ResetPolicy; - configure(DidResolutionPolicy { - network_methods: true, - }); - let listener = bound_listener(); - let port = listener.local_addr().unwrap().port(); - let did = format!("did:web:127.0.0.1%3A{port}"); - let issuer = format!("{did}#key"); - let key = JWK::generate_ed25519().unwrap(); - let ucan = signed_ucan(&issuer, &key, Algorithm::EdDSA); - let body = serde_json::json!({"@context":"https://www.w3.org/ns/did/v1", "id":did, "verificationMethod":[{"id":issuer,"type":"JsonWebKey2020","controller":did,"publicKeyJwk":key.to_public()}]}).to_string(); - let server = std::thread::spawn(move || { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - match listener.accept() { - Ok((mut stream, _)) => { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut request = [0; 4096]; - let size = stream.read(&mut request).unwrap(); - assert!(String::from_utf8_lossy(&request[..size]) - .starts_with("GET /.well-known/did.json HTTP/1.1")); - write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/did+ld+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); - return 1; - } - Err(error) - if error.kind() == std::io::ErrorKind::WouldBlock - && std::time::Instant::now() < deadline => - { - std::thread::sleep(Duration::from_millis(5)) - } - result => panic!("local resolution failed: {result:?}"), - } - } - }); - let outcome = verify_ucan_signature(&ucan).await; - assert_eq!(server.join().unwrap(), 1); - assert_eq!(outcome, Ok(())); -} diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 8e2c2dc2..b3bb8987 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1523,15 +1523,10 @@ impl Default for DatabaseConfig { } } -/// UCAN issuer resolution policy (TC-1080). Parsing and identity comparisons are unchanged. +/// UCAN issuer resolution. The method allow-list is fixed in `tinycloud_core::did_resolution` +/// and has no settings. The section is still accepted so existing configs keep parsing. #[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] -pub struct DidResolutionConfig { - /// Allow methods beyond key, pkh and jwk. Default: false. - /// Canonical env: TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS=true. - /// Requires a restart; issuer queries are always refused. - #[serde(default)] - pub network_methods: bool, -} +pub struct DidResolutionConfig {} impl DatabaseConfig { /// Reject settings that would make the node unusable (TC-1317): a zero diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 4a65c225..046241c2 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -516,11 +516,6 @@ pub async fn app_with_control( if egress_policy.refresh().await.is_err() { ::tracing::warn!("webhook egress closed: internal endpoint lookup failed"); } - tinycloud_core::did_resolution::configure( - tinycloud_core::did_resolution::DidResolutionPolicy { - network_methods: tinycloud_config.did_resolution.network_methods, - }, - ); tinycloud_core::did_resolution::set_refusal_observer( prometheus::observe_did_resolution_refused, ); @@ -540,11 +535,10 @@ pub async fn app_with_control( config.extract_inner::("log_level")?, )?; - ::tracing::info!(network_methods = tinycloud_config.did_resolution.network_methods, - offline_methods = ?tinycloud_core::did_resolution::OFFLINE_DID_METHODS, "did resolution policy"); - if tinycloud_config.did_resolution.network_methods { - ::tracing::warn!("network DID resolution enabled: did:web/did:tz fetches use the unhardened ssi client (TC-1080)"); - } + ::tracing::info!( + allowed_methods = ?tinycloud_core::did_resolution::ALLOWED_DID_METHODS, + "did resolution policy" + ); let routes = production_routes(); #[cfg(feature = "tc-bench-v1")] diff --git a/tinycloud-node-server/src/main.rs b/tinycloud-node-server/src/main.rs index a4c3a17a..59354b08 100644 --- a/tinycloud-node-server/src/main.rs +++ b/tinycloud-node-server/src/main.rs @@ -170,28 +170,6 @@ mod tests { .expect("env lock should not be poisoned") } - #[test] - fn did_resolution_defaults_off_and_canonical_env_enables_it() { - let _lock = lock_env(); - let _network = EnvVarGuard::unset("TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS"); - assert!(!config::Config::default().did_resolution.network_methods); - assert!( - !build_config_figment() - .extract::() - .unwrap() - .did_resolution - .network_methods - ); - let _network = EnvVarGuard::set("TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS", "true"); - assert!( - build_config_figment() - .extract::() - .unwrap() - .did_resolution - .network_methods - ); - } - #[test] fn canonical_double_underscore_loads_hooks_max_ticket_ttl_seconds() { let _lock = lock_env(); diff --git a/tinycloud-node-server/src/node_control/control.rs b/tinycloud-node-server/src/node_control/control.rs index e77838f8..e6cad3ae 100644 --- a/tinycloud-node-server/src/node_control/control.rs +++ b/tinycloud-node-server/src/node_control/control.rs @@ -499,11 +499,6 @@ pub async fn spawn_control_plane( ) -> Result { let mut effective_config = config.clone(); effective_config.storage.resolve(); - tinycloud_core::did_resolution::configure( - tinycloud_core::did_resolution::DidResolutionPolicy { - network_methods: effective_config.did_resolution.network_methods, - }, - ); let pid = std::process::id(); let data_path = effective_config.storage.datadir.clone(); @@ -1914,11 +1909,6 @@ mod tests { let figment = runtime::serve_config_figment(&base_config_path).unwrap(); let mut config = figment.extract::().unwrap(); config.storage.resolve(); - tinycloud_core::did_resolution::configure( - tinycloud_core::did_resolution::DidResolutionPolicy { - network_methods: config.did_resolution.network_methods, - }, - ); let server = spawn_control_plane(&config, base_config_path, profile) .await .unwrap(); diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index e5810911..054b215d 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -300,7 +300,8 @@ fn build_info( in_tee: tee.inner().is_some(), quota_url: quota_cache.quota_url().map(|s| s.to_string()), did_resolution: DidResolutionInfo { - network_methods: tinycloud_core::did_resolution::policy().network_methods, + // Always false: the node resolves only allow-listed methods. Kept for /info wire compatibility. + network_methods: false, refused_total: tinycloud_core::did_resolution::refused_total(), }, // Two relaxed atomic loads: `/info` is public and polled. diff --git a/tinycloud-node-server/src/runtime.rs b/tinycloud-node-server/src/runtime.rs index 7f3ecc0c..0a12ffe3 100644 --- a/tinycloud-node-server/src/runtime.rs +++ b/tinycloud-node-server/src/runtime.rs @@ -590,7 +590,6 @@ mod tests { let _telemetry_canonical = EnvGuard::set("TINYCLOUD_TELEMETRY__ENABLED", "true"); let _telemetry_legacy = EnvGuard::unset("TINYCLOUD_TELEMETRY_ENABLED"); let _pool_canonical = EnvGuard::set("TINYCLOUD_DATABASE__MAX_CONNECTIONS", "200"); - let _did_network = EnvGuard::set("TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS", "true"); let _timeout_mode = EnvGuard::set("TINYCLOUD_DATABASE__STATEMENT_TIMEOUT_MODE", "local"); let _user_timeout = EnvGuard::set("TINYCLOUD_DATABASE__TCP_USER_TIMEOUT_MS", "20000"); let _keys_env = clear_keys_env(); @@ -606,7 +605,6 @@ mod tests { assert!(cfg.telemetry.enabled); // The pool knob is env-overridable via the double-underscore form. assert_eq!(cfg.database.max_connections, 200); - assert!(cfg.did_resolution.network_methods); // Absent config falls back to the hardcoded default of 100. assert_eq!(config::DatabaseConfig::default().max_connections, 100); assert_eq!(config::Config::default().database.max_connections, 100); diff --git a/tinycloud-node-server/tests/did_resolution_app_startup.rs b/tinycloud-node-server/tests/did_resolution_app_startup.rs index 91fd2971..86358308 100644 --- a/tinycloud-node-server/tests/did_resolution_app_startup.rs +++ b/tinycloud-node-server/tests/did_resolution_app_startup.rs @@ -1,5 +1,5 @@ -//! Real app startup applies the documented environment opt-in and restart policy. -//! One test in its own process isolates the process-global policy and env fixture. +//! Real app startup. The retired `network_methods` environment key must not enable any DID +//! method outside the allow-list. One test in its own process isolates the env fixture. #[path = "../../tinycloud-core/tests/support/did_resolution.rs"] mod support; use rocket::{ @@ -10,10 +10,6 @@ use rocket::{ local::asynchronous::Client, }; use serde_json::{json, Value}; -use std::{ - io::{Read, Write}, - time::Duration, -}; use support::*; use tinycloud_auth::ssi::jwk::{Algorithm, JWK}; use tinycloud_core::did_resolution::{verify_ucan_signature, UcanVerifyError}; @@ -88,54 +84,8 @@ async fn assert_web_refused() { ); } -async fn assert_web_verified_locally() { - let listener = bound_listener(); - let did = format!( - "did:web:127.0.0.1%3A{}", - listener.local_addr().unwrap().port() - ); - let issuer = format!("{did}#key"); - let key = JWK::generate_ed25519().unwrap(); - let ucan = signed_ucan(&issuer, &key, Algorithm::EdDSA); - let body = json!({"@context":"https://www.w3.org/ns/did/v1", "id":did, - "verificationMethod":[{"id":issuer,"type":"JsonWebKey2020","controller":did,"publicKeyJwk":key.to_public()}]}).to_string(); - let server = std::thread::spawn(move || { - let deadline = std::time::Instant::now() + Duration::from_secs(4); - loop { - match listener.accept() { - Ok((mut stream, peer)) => { - assert!(peer.ip().is_loopback()); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut request = [0; 4096]; - let size = stream.read(&mut request).unwrap(); - assert!(String::from_utf8_lossy(&request[..size]) - .starts_with("GET /.well-known/did.json HTTP/1.1")); - write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/did+ld+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); - return 1; - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - if std::time::Instant::now() >= deadline { - return 0; - } - std::thread::sleep(Duration::from_millis(5)); - } - result => panic!("local listener failed: {result:?}"), - } - } - }); - let outcome = tokio::time::timeout(Duration::from_secs(5), verify_ucan_signature(&ucan)).await; - let accepted = server.join().unwrap(); - assert_eq!(outcome, Ok(Ok(())), "app must apply the environment opt-in"); - assert_eq!(accepted, 1); -} - #[tokio::test] -async fn app_startup_honors_default_env_opt_in_and_restart_to_default() { +async fn app_startup_ignores_retired_network_methods_env() { let _restore = RestoreEnv(std::env::var_os(NETWORK_METHODS_ENV)); std::env::remove_var(NETWORK_METHODS_ENV); let (client, _dir) = app().await; @@ -143,16 +93,9 @@ async fn app_startup_honors_default_env_opt_in_and_restart_to_default() { assert_web_refused().await; drop(client); + // The retired key set to true must not enable web or report a different policy. std::env::set_var(NETWORK_METHODS_ENV, "true"); let (client, _dir) = app().await; - // Resolve before checking /info so the configure-removal mutant fails on - // refused verification, rather than only on the reported policy boolean. - assert_web_verified_locally().await; - assert!(network_methods(&client).await); - drop(client); - - std::env::remove_var(NETWORK_METHODS_ENV); - let (client, _dir) = app().await; assert!(!network_methods(&client).await); assert_web_refused().await; } From 33a39cd9596c82f6c9efe7f8024a4f2b27638f35 Mon Sep 17 00:00:00 2001 From: Naomi Date: Tue, 20 Oct 2026 22:18:18 +0000 Subject: [PATCH 02/11] TC-1500: document the fixed DID allow-list Changelog, README, tinycloud.toml and the delegation-flow gate now describe the fixed allow-list and the retired network_methods setting. --- CHANGELOG.md | 1 + README.md | 20 +++++++++----------- docs/delegation-flow.md | 20 ++++++++++---------- tinycloud.toml | 6 ++---- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b1bf7b..bf6e103d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes +- UCAN DID resolution uses one fixed allow-list: `did:key` (Ed25519 keys only), `did:pkh` (`eip155` namespace only) and `did:jwk`, all offline. Every other DID method, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, is refused with `did_method_disabled:` before any resolution or network lookup. The `did_resolution.network_methods` setting and `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS` are removed and no longer enable any method. `GET /info` keeps `did_resolution.network_methods` as a constant `false` for wire compatibility. - Schema-changing SQL statements now require `tinycloud.sql/schema` (TC-1581). `tinycloud.sql/write` covers data statements only (`INSERT`, `UPDATE`, `DELETE`, `SELECT`). `CREATE`, `ALTER` and `DROP` of tables, views and indexes need a schema-conferring ability: `sql/schema`, `sql/admin` or `sql/*`. This is judged for every statement of a request against all the SQL abilities the invocation holds, in `query`, `execute`, the `schema` list of `execute`, `batch` and named statements, and again by the SQLite authorizer, which now also refuses `CREATE TRIGGER`. A client that holds both `write` and `schema` (the SDK sends both for `execute({ schema })` and for migrations) is unaffected; the SDK has requested `sql/schema` for DDL since 2.5.0. `PRAGMA` still needs `sql/admin`. The storage-quota gate classifies a statement as a write whoever may run it. Configurable with `[global.storage.sql] ddl_requires_schema = "enforce" | "log" | "off"` (default `enforce`): `log` runs the statement and records it, with one constant warning per request and a counter by service and statement kind, and no SQL text, space ids, DIDs or table names; `off` restores the earlier rule that `write` also suffices for the statements `log` counts; the other changes in this release (the `CREATE TRIGGER` refusal, the nested-block-comment refusal, the CTE write classification, the schema-table and PRAGMA restrictions) apply in every mode. `log` counts only statements the parser classifies as schema-changing. A request that mixes `schema` entries and `sql` is not transactional: the schema entries run first and can stay applied if a later statement is refused. No migration; the default needs no compose change. See `docs/sql-schema-authority.md`. - Fixed `WITH … INSERT` and `WITH … UPDATE` SQL statements being classified as reads (TC-1585). They skipped the storage-quota gate, emitted no write-hook events and reported no touched table, so a write that changed the database was not made durable until a later write did. Any statement tree that contains an `INSERT` or `UPDATE` is now a write: it is quota-gated, publishes write events for its table, is persisted, and is refused to a read-tier grant and to a `readOnly` caveat. `WITH … DELETE` is still not accepted by the SQL parser, for any ability. - Connection-level SQLite state no longer outlives a request on a database's shared connection (TC-1586). An `sql/admin` holder may now run only an allow-list of PRAGMAs: the plain schema reports, the database header values `user_version` and `application_id` (set or read), the check pragmas `integrity_check`, `quick_check` and `foreign_key_check`, and read-only reports such as `journal_mode`, `foreign_keys` and `page_count`. Any other PRAGMA, including every form that sets connection-level or process-wide settings, is refused. After any request in which the authorizer allowed a PRAGMA outside the plain schema reports, the node also resets the connection to its opening state, for in-memory and file-backed databases alike; if that reset fails, the database's actor is retired and the next request rebuilds it from the durable copy. The SQLite authorizer also no longer lets a data statement write `sqlite_master`, `sqlite_schema` or their temp counterparts: only a schema change (which writes them internally), a pure query, and `admin` do. `sql/write` keeps writing application tables. diff --git a/README.md b/README.md index 4e10dbb2..13428e59 100644 --- a/README.md +++ b/README.md @@ -388,17 +388,15 @@ setting. DID parsing and string comparisons, including share-email `did:web` identifiers, are unchanged. Existing route status codes and policy machine codes are preserved. -Keep `[global.did_resolution] network_methods = false` in `tinycloud.toml` -or the canonical environment variable -`TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS=false`. Network DID methods, including -`did:web`, remain disabled under the current policy. - -**Full TC-1080 Part B hardening is REQUIRED before any future re-enable**, -including setting the existing `network_methods` option to `true`. The -unhardened SSI client is not an acceptable rollback path. Part B is not -implemented here, and there is no Part B development bypass. The required -node-side fetcher, destination enforcement, resource limits and local-listener -acceptance checks are described in the +Network DID methods, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, are +never resolved for UCAN verification. No setting or environment variable enables +them. + +**Full TC-1080 Part B hardening is REQUIRED before any future enablement of network +DID resolution.** The unhardened SSI client is not an acceptable path. Part B is not +implemented here, and there is no Part B development bypass. The required node-side +fetcher, destination enforcement, resource limits and local-listener acceptance checks +are described in the [developer gate](docs/delegation-flow.md#network-did-method-future-enable-gate-tc-1132). The startup log records the effective policy. Refusals increment diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index 1a9a4cb3..b66eb76a 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -25,16 +25,16 @@ The delegation hierarchy enables: ### Network DID method future-enable gate (TC-1132) -The node's UCAN issuer resolver currently permits only offline `did:key`, -`did:pkh` and `did:jwk` resolution by default. Network methods, including -`did:web`, remain disabled with `network_methods = false`. DID identifiers used -for parsing or identity comparisons (for example share-email `did:web` values) -are unaffected; this gate concerns network resolution during verification. - -**Full TC-1080 plan Part B is REQUIRED before any future re-enable**, including -the existing `network_methods = true` setting. Do not restore the unhardened SSI -fetcher as a rollback. Part B is not implemented by TC-1132 Parts C/D, and no -Part B development flag or bypass is provided. +The node's UCAN issuer resolver resolves only the fixed allow-list: `did:key` (Ed25519 +keys only), `did:pkh` (`eip155` namespace only) and `did:jwk`, all offline. Every other +DID method, including `did:web`, is refused before any resolution, and no setting enables +another method. DID identifiers used for parsing or identity comparisons (for example +share-email `did:web` values) are unaffected; this gate concerns network resolution +during verification. + +**Full TC-1080 plan Part B is REQUIRED before any future enablement of a network DID +method.** Do not restore the unhardened SSI fetcher. Part B is not implemented by +TC-1132 Parts C/D, and no Part B development flag or bypass is provided. Before a future change enables `did:web`, it must implement and verify all of Part B: diff --git a/tinycloud.toml b/tinycloud.toml index 4f5a143b..04688b8c 100644 --- a/tinycloud.toml +++ b/tinycloud.toml @@ -81,7 +81,5 @@ cors = true enabled = false [global.did_resolution] - ## UCAN issuers resolve only key, pkh and jwk by default. - ## Rollback: TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS=true, then restart. - ## Issuer queries remain refused. Opt-in uses the unhardened SSI client (TC-1080). - # network_methods = false + ## UCAN issuers resolve only did:key (Ed25519), did:pkh (eip155) and did:jwk. + ## Other DID methods are refused before any resolution. No setting enables them. From 1557ca9a5291346270188908bb5dc8349b0cfd7d Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 01:42:23 +0000 Subject: [PATCH 03/11] TC-1500: tighten DID allow-list admission and signature sites --- tinycloud-auth/src/share_email_evidence.rs | 9 +- tinycloud-core/src/did_resolution.rs | 94 ++++++++++- .../src/encryption_network/service.rs | 14 +- .../src/policy_authority/verifier.rs | 39 +++++ tinycloud-core/src/share_email/data_plane.rs | 12 +- tinycloud-core/tests/did_resolution_policy.rs | 155 +++++++++++------- tinycloud-node-server/src/config.rs | 32 +++- tinycloud-node-server/src/lib.rs | 5 + tinycloud-node-server/src/policy_v3.rs | 12 +- tinycloud-node-server/src/share_v2.rs | 12 +- .../tests/did_resolution_single_entry.rs | 107 ++++++++++++ 11 files changed, 388 insertions(+), 103 deletions(-) diff --git a/tinycloud-auth/src/share_email_evidence.rs b/tinycloud-auth/src/share_email_evidence.rs index 0cd2e92c..29ec1e82 100644 --- a/tinycloud-auth/src/share_email_evidence.rs +++ b/tinycloud-auth/src/share_email_evidence.rs @@ -567,7 +567,14 @@ pub fn verify_detached_ed25519( verify_ed25519(&public_key, message, signature) } -fn did_key_public_key(did: &str) -> Result<[u8; 32], EvidenceError> { +/// Length of an Ed25519 `did:key` string: the `did:key:` prefix plus a 48-character identifier. +const ED25519_DID_KEY_LEN: usize = "did:key:".len() + 48; +/// Decodes an Ed25519 `did:key` into its 32-byte public key. The length is checked before base58 +/// decoding, and only base58btc multibase over the `0xed 0x01` multicodec is accepted. +pub fn did_key_public_key(did: &str) -> Result<[u8; 32], EvidenceError> { + if did.len() != ED25519_DID_KEY_LEN { + return Err(EvidenceError::Invalid); + } let encoded = did .strip_prefix("did:key:z") .ok_or(EvidenceError::Invalid)?; diff --git a/tinycloud-core/src/did_resolution.rs b/tinycloud-core/src/did_resolution.rs index 05418674..931d4ce0 100644 --- a/tinycloud-core/src/did_resolution.rs +++ b/tinycloud-core/src/did_resolution.rs @@ -1,4 +1,5 @@ //! Node UCAN issuer resolution policy. DID syntax and identity comparisons are unaffected. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, VecDeque}, @@ -9,22 +10,29 @@ use std::{ }, time::{Duration, Instant}, }; +use tinycloud_auth::share_email_evidence; use tinycloud_auth::ssi::{ dids::{ resolution::{self, Options, Output}, AnyDidMethod, DIDResolver, DID, DIDURL, }, - jwk::{Algorithm, JWK}, + jwk::{Algorithm, Params, JWK}, ucan::{Ucan, UcanRevocation}, }; /// The only issuer DID methods the node resolves. Each derives verification material without /// network access. Every other method is refused before any lookup, whatever the configuration. +/// `did:web` credential issuers are configured in `tinycloud-node-server/src/issuer_set.rs` and +/// are not resolved through this module. pub const ALLOWED_DID_METHODS: &[&str] = &["key", "pkh", "jwk"]; /// `did:key` is limited to Ed25519: base58btc multibase `z` over the `0xed 0x01` multicodec. const ED25519_DID_KEY_PREFIX: &str = "z6Mk"; +/// Length of an Ed25519 `did:key` identifier, checked before any decoding. +const ED25519_DID_KEY_ID_LEN: usize = 48; /// `did:pkh` is limited to the EVM (`eip155`) namespace. const PKH_EVM_NAMESPACE_PREFIX: &str = "eip155:"; +/// Longest `did:pkh:eip155` identifier: namespace, u64 chain id (<= 20 digits), `0x`, 40 hex. +const PKH_EVM_ID_MAX_LEN: usize = 70; static REFUSED_TOTAL: AtomicU64 = AtomicU64::new(0); static REFUSAL_OBSERVER: OnceLock = OnceLock::new(); @@ -86,8 +94,8 @@ impl DIDResolver for NodeDidResolver { } } -/// Admit a DID only when its method is allow-listed and, for `did:key` and `did:pkh`, its -/// key type or namespace is allowed. Runs before any lookup. +/// Admit a DID only when its method is allow-listed and its key type or namespace is allowed. +/// Runs before any lookup. fn check_method(did: &DID) -> Result<(), UcanVerifyError> { let method = did.method_name(); if !ALLOWED_DID_METHODS.contains(&method) || !namespace_allowed(did) { @@ -99,12 +107,31 @@ fn check_method(did: &DID) -> Result<(), UcanVerifyError> { Ok(()) } +/// Admit an identifier only when its key type or namespace is allowed. Length bounds run +/// before any decoding. fn namespace_allowed(did: &DID) -> bool { let id = did.method_specific_id(); match did.method_name() { - "key" => id.starts_with(ED25519_DID_KEY_PREFIX), - "pkh" => id.starts_with(PKH_EVM_NAMESPACE_PREFIX), - _ => true, + "key" => { + id.len() == ED25519_DID_KEY_ID_LEN + && id.starts_with(ED25519_DID_KEY_PREFIX) + && share_email_evidence::did_key_public_key(did.as_str()).is_ok() + } + "pkh" => id.len() <= PKH_EVM_ID_MAX_LEN && id.starts_with(PKH_EVM_NAMESPACE_PREFIX), + "jwk" => jwk_is_ed25519(id), + _ => false, + } +} + +/// Decodes a `did:jwk` identifier as the SSI resolver does (unpadded base64url, then JWK JSON). +/// Admits only an OKP `Ed25519` key, the same key type as `did:key`. +fn jwk_is_ed25519(id: &str) -> bool { + let Ok(bytes) = URL_SAFE_NO_PAD.decode(id) else { + return false; + }; + match serde_json::from_slice::(&bytes) { + Ok(jwk) => matches!(&jwk.params, Params::OKP(okp) if okp.curve == "Ed25519"), + Err(_) => false, } } @@ -310,6 +337,61 @@ mod tests { assert_eq!(sanitized_host(DID::new(did.as_bytes()).unwrap()).len(), 253); } + fn admitted(did: &str) -> bool { + namespace_allowed(DID::new(did.as_bytes()).unwrap()) + } + + #[test] + fn did_key_is_admitted_only_at_the_ed25519_identifier_length() { + let key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; + assert_eq!(key.len(), ED25519_DID_KEY_ID_LEN); + assert!(admitted(&format!("did:key:{key}"))); + assert!(!admitted(&format!("did:key:{}", &key[..47]))); + assert!(!admitted(&format!("did:key:{key}1"))); + // Right length and prefix, but not an Ed25519 multicodec encoding. + assert!(!admitted(&format!("did:key:z6Mk{}", "1".repeat(44)))); + // Oversized: refused by length before any base58 decode. + assert!(!admitted(&format!("did:key:z6Mk{}", "1".repeat(65_532)))); + } + + #[test] + fn did_pkh_is_admitted_only_as_bounded_evm() { + assert!(admitted( + "did:pkh:eip155:1:0x0000000000000000000000000000000000000001" + )); + // Longest valid identifier: a 20-digit chain id. + assert!(admitted(&format!( + "did:pkh:eip155:{}:0x{}", + "1".repeat(20), + "0".repeat(40) + ))); + assert!(!admitted(&format!( + "did:pkh:eip155:{}:0x{}", + "1".repeat(21), + "0".repeat(40) + ))); + assert!(!admitted("did:pkh:solana:local:abc")); + assert!(!admitted(&format!( + "did:pkh:eip155:1:{}", + "0".repeat(65_536) + ))); + } + + #[test] + fn did_jwk_is_admitted_only_as_ed25519_okp() { + use base64::Engine as _; + let jwk_did = |json: &str| format!("did:jwk:{}", URL_SAFE_NO_PAD.encode(json)); + assert!(admitted(&jwk_did( + r#"{"kty":"OKP","crv":"Ed25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}"# + ))); + assert!(!admitted(&jwk_did( + r#"{"kty":"OKP","crv":"X25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}"# + ))); + assert!(!admitted(&jwk_did( + r#"{"kty":"RSA","n":"AQAB","e":"AQAB"}"# + ))); + } + fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index 543ae642..c878766c 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -1086,17 +1086,9 @@ fn decode_base64(s: &str) -> Result, &'static str> { } fn did_key_public_key(did: &str) -> Result { - let encoded = did - .strip_prefix("did:key:") - .ok_or_else(|| "issuer must be a did:key".to_string())?; - let (_base, bytes) = tinycloud_auth::ipld_core::cid::multibase::decode(encoded) - .map_err(|err| err.to_string())?; - let key_bytes = match bytes.as_slice() { - [0xed, rest @ ..] if rest.len() == 32 => rest, - [0xed, 0x01, rest @ ..] if rest.len() == 32 => rest, - _ => return Err("issuer did:key is not an ed25519 public key".to_string()), - }; - let ed = libp2p::identity::ed25519::PublicKey::try_from_bytes(key_bytes) + let bytes = tinycloud_auth::share_email_evidence::did_key_public_key(did) + .map_err(|_| "issuer must be an ed25519 did:key".to_string())?; + let ed = libp2p::identity::ed25519::PublicKey::try_from_bytes(&bytes) .map_err(|err| err.to_string())?; Ok(ed.into()) } diff --git a/tinycloud-core/src/policy_authority/verifier.rs b/tinycloud-core/src/policy_authority/verifier.rs index 537ba567..d59a8f24 100644 --- a/tinycloud-core/src/policy_authority/verifier.rs +++ b/tinycloud-core/src/policy_authority/verifier.rs @@ -154,6 +154,10 @@ fn verify_eip191( if signature.len() != 65 { return Err(super::AuthorityError::SchemaInvalid); } + // Only an eip155 did:pkh names an EVM address to compare against the recovered key. + tinycloud_auth::identity::parse_pkh_did(&artifact.issuer_did) + .map_err(|_| super::AuthorityError::SchemaInvalid)? + .ok_or(super::AuthorityError::SchemaInvalid)?; let recovery = match signature[64] { 27 | 28 => RecoveryId::try_from(signature[64] - 27), 0 | 1 => RecoveryId::try_from(signature[64]), @@ -317,4 +321,39 @@ mod tests { ); } } + + #[test] + fn eip191_issuer_must_be_an_eip155_pkh_before_recovery() { + let artifact = |issuer: &str| PolicyDelegation { + schema: "xyz.tinycloud.policy/enforcement-delegation/v1".to_owned(), + role: DelegationRole::PolicySessionRoot, + delegation_cid: "placeholder".to_owned(), + issuer_did: issuer.to_owned(), + audience_did: issuer.to_owned(), + capabilities: Vec::new(), + proof_cids: Vec::new(), + not_before: "2026-07-19T00:00:00Z".to_owned(), + expires_at: "2026-07-19T00:05:00Z".to_owned(), + delegation_mode: super::super::DelegationMode::Attenuable, + facts: std::collections::BTreeMap::new(), + signature: DelegationSignature { + suite: EIP191_SUITE.to_owned(), + value: String::new(), + }, + }; + let signature = [0u8; 65]; + for issuer in [ + "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw", + "did:pkh:solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:0x0000000000000000000000000000000000000001", + "did:pkh:eip155:0:0x0000000000000000000000000000000000000001", + ] { + assert!( + matches!( + verify_eip191(&artifact(issuer), b"unsigned", &signature), + Err(super::super::AuthorityError::SchemaInvalid) + ), + "{issuer} must be refused before recovery" + ); + } + } } diff --git a/tinycloud-core/src/share_email/data_plane.rs b/tinycloud-core/src/share_email/data_plane.rs index 92cc6912..0effb503 100644 --- a/tinycloud-core/src/share_email/data_plane.rs +++ b/tinycloud-core/src/share_email/data_plane.rs @@ -712,17 +712,9 @@ fn timestamp(value: OffsetDateTime) -> Result { } fn did_key_public_key(did: &str) -> Result { - let encoded = did - .strip_prefix("did:key:") - .ok_or(DataPlaneError::InvalidProof)?; - let (_, bytes) = tinycloud_auth::ipld_core::cid::multibase::decode(encoded) + let bytes = tinycloud_auth::share_email_evidence::did_key_public_key(did) .map_err(|_| DataPlaneError::InvalidProof)?; - let key_bytes = match bytes.as_slice() { - [0xed, rest @ ..] if rest.len() == 32 => rest, - [0xed, 0x01, rest @ ..] if rest.len() == 32 => rest, - _ => return Err(DataPlaneError::InvalidProof), - }; - let key = libp2p::identity::ed25519::PublicKey::try_from_bytes(key_bytes) + let key = libp2p::identity::ed25519::PublicKey::try_from_bytes(&bytes) .map_err(|_| DataPlaneError::InvalidProof)?; Ok(key.into()) } diff --git a/tinycloud-core/tests/did_resolution_policy.rs b/tinycloud-core/tests/did_resolution_policy.rs index 8aee3753..ff49bb79 100644 --- a/tinycloud-core/tests/did_resolution_policy.rs +++ b/tinycloud-core/tests/did_resolution_policy.rs @@ -1,6 +1,7 @@ //! DID method allow-list refusal tests. Every socket target is a listener bound by this test. #[path = "support/did_resolution.rs"] mod support; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use futures::FutureExt; use std::{ sync::{ @@ -68,76 +69,104 @@ const REFUSED: &[(&str, &str)] = &[ #[tokio::test] async fn refused_dids_are_refused_with_zero_lookups_at_every_site() { - let key = JWK::generate_ed25519().unwrap(); for (issuer, method) in REFUSED { - let refusal = || UcanVerifyError::MethodDisabled { - method: (*method).into(), - }; - let message = refusal().to_string(); + assert_refused_at_every_site(issuer, method).await; + } +} - let counting = CountingResolver::default(); - let ucan = signed_ucan(issuer, &key, Algorithm::EdDSA); - assert_eq!( - verify_ucan_signature_with(&ucan, counting.clone()).await, - Err(refusal()), - "{issuer}: ucan signature" - ); - assert_eq!(counting.lookups(), 0, "{issuer}: ucan signature lookups"); +#[tokio::test] +async fn non_ed25519_did_jwk_and_oversized_identifiers_are_refused_with_zero_lookups() { + let secp = JWK::generate_secp256k1(); + let p256 = JWK::generate_p256(); + let rsa = r#"{"kty":"RSA","n":"AQAB","e":"AQAB"}"#; + let cases = [ + ( + DID_METHODS.generate(&secp, "jwk").unwrap().to_string(), + "jwk", + ), + ( + DID_METHODS.generate(&p256, "jwk").unwrap().to_string(), + "jwk", + ), + (format!("did:jwk:{}", URL_SAFE_NO_PAD.encode(rsa)), "jwk"), + (format!("did:key:z6Mk{}", "1".repeat(65_532)), "key"), + (format!("did:pkh:eip155:1:{}", "0".repeat(65_536)), "pkh"), + ]; + for (issuer, method) in &cases { + assert_refused_at_every_site(issuer, method).await; + } +} - let counting = CountingResolver::default(); - let revocation = UcanRevocation::sign( - issuer.parse().unwrap(), - ucan.payload().proof[0], - &key, +/// Asserts that `issuer` is refused as `method` with zero lookups at every resolution site. +async fn assert_refused_at_every_site(issuer: &str, method: &str) { + let key = JWK::generate_ed25519().unwrap(); + let refusal = || UcanVerifyError::MethodDisabled { + method: method.into(), + }; + let message = refusal().to_string(); + + let counting = CountingResolver::default(); + let ucan = signed_ucan(issuer, &key, Algorithm::EdDSA); + assert_eq!( + verify_ucan_signature_with(&ucan, counting.clone()).await, + Err(refusal()), + "{issuer}: ucan signature" + ); + assert_eq!(counting.lookups(), 0, "{issuer}: ucan signature lookups"); + + let counting = CountingResolver::default(); + let revocation = UcanRevocation::sign( + issuer.parse().unwrap(), + ucan.payload().proof[0], + &key, + Algorithm::EdDSA, + ) + .unwrap(); + assert_eq!( + verify_ucan_revocation_signature_with( + &revocation, Algorithm::EdDSA, + None, + counting.clone() ) - .unwrap(); - assert_eq!( - verify_ucan_revocation_signature_with( - &revocation, - Algorithm::EdDSA, - None, - counting.clone() - ) - .await, - Err(refusal()), - "{issuer}: revocation" - ); - assert_eq!(counting.lookups(), 0, "{issuer}: revocation lookups"); + .await, + Err(refusal()), + "{issuer}: revocation" + ); + assert_eq!(counting.lookups(), 0, "{issuer}: revocation lookups"); - let did: DIDBuf = issuer.parse().unwrap(); - let counting = CountingResolver::default(); - let result = NodeDidResolver::new(counting.clone()) - .resolve_representation(&did, Options::default()) - .await; - assert!( - matches!(&result, Err(ResolutionError::Internal(m)) if *m == message), - "{issuer}: direct representation" - ); - assert_eq!( - counting.lookups(), - 0, - "{issuer}: direct representation lookups" - ); + let did: DIDBuf = issuer.parse().unwrap(); + let counting = CountingResolver::default(); + let result = NodeDidResolver::new(counting.clone()) + .resolve_representation(&did, Options::default()) + .await; + assert!( + matches!(&result, Err(ResolutionError::Internal(m)) if *m == message), + "{issuer}: direct representation" + ); + assert_eq!( + counting.lookups(), + 0, + "{issuer}: direct representation lookups" + ); - let url: DIDURLBuf = format!("{issuer}#key").parse().unwrap(); - let counting = CountingResolver::default(); - let result = NodeDidResolver::new(counting.clone()) - .dereference(&url) - .await; - assert!( - matches!( - &result, - Err(DerefError::Resolution(ResolutionError::Internal(m))) if *m == message - ), - "{issuer}: direct dereference" - ); - assert_eq!( - counting.lookups(), - 0, - "{issuer}: direct dereference lookups" - ); - } + let url: DIDURLBuf = format!("{issuer}#key").parse().unwrap(); + let counting = CountingResolver::default(); + let result = NodeDidResolver::new(counting.clone()) + .dereference(&url) + .await; + assert!( + matches!( + &result, + Err(DerefError::Resolution(ResolutionError::Internal(m))) if *m == message + ), + "{issuer}: direct dereference" + ); + assert_eq!( + counting.lookups(), + 0, + "{issuer}: direct dereference lookups" + ); } #[tokio::test] diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index b3bb8987..19069947 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1524,9 +1524,22 @@ impl Default for DatabaseConfig { } /// UCAN issuer resolution. The method allow-list is fixed in `tinycloud_core::did_resolution` -/// and has no settings. The section is still accepted so existing configs keep parsing. +/// and has no settings. Retired keys are still accepted so existing configs keep parsing. #[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] -pub struct DidResolutionConfig {} +pub struct DidResolutionConfig { + /// Retired and ignored: network DID methods are never resolved. `true` when the key is + /// present, with any value, so startup can warn that it has no effect. + #[serde(default, skip_serializing, deserialize_with = "retired_key_present")] + pub network_methods: bool, +} + +fn retired_key_present<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + serde::de::IgnoredAny::deserialize(deserializer)?; + Ok(true) +} impl DatabaseConfig { /// Reject settings that would make the node unusable (TC-1317): a zero @@ -1713,6 +1726,21 @@ mod tests { } } + #[test] + fn retired_did_resolution_network_methods_is_detected_for_any_value_and_never_written_back() { + let absent: DidResolutionConfig = serde_json::from_str("{}").unwrap(); + assert!(!absent.network_methods); + for value in [r#"["web"]"#, r#""web""#, "true", "{}"] { + let config: DidResolutionConfig = + serde_json::from_str(&format!(r#"{{"network_methods":{value}}}"#)).unwrap(); + assert!(config.network_methods, "{value}"); + } + let written = DidResolutionConfig { + network_methods: true, + }; + assert_eq!(serde_json::to_string(&written).unwrap(), "{}"); + } + /// TC-1274: a running backfill needs a positive rate and progress /// interval; a disabled one boots whatever its pacing says. #[test] diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 046241c2..4ef73a87 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -539,6 +539,11 @@ pub async fn app_with_control( allowed_methods = ?tinycloud_core::did_resolution::ALLOWED_DID_METHODS, "did resolution policy" ); + if tinycloud_config.did_resolution.network_methods { + ::tracing::warn!( + "did_resolution.network_methods is retired and ignored; network DID methods are never resolved" + ); + } let routes = production_routes(); #[cfg(feature = "tc-bench-v1")] diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index ad0f7e2c..88f4b2e7 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -6598,12 +6598,14 @@ mod tests { listener.local_addr().unwrap().port() ); let ucan = signed_ucan(&issuer, &JWK::generate_ed25519().unwrap(), Algorithm::EdDSA); - assert!( - super::verify_signed_delegation(&TinyCloudDelegation::Ucan(Box::new(ucan))) - .await - .is_err() - ); + // Bound the test if the refusal regresses and resolution waits for HTTP. + let result = tokio::time::timeout( + std::time::Duration::from_secs(1), + super::verify_signed_delegation(&TinyCloudDelegation::Ucan(Box::new(ucan))), + ) + .await; assert_no_connections(&[&listener]).await; + assert!(matches!(result, Ok(Err(_)))); } use super::*; use base64::encode_config; diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index 710e2fd3..ec94f8ba 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -3398,12 +3398,14 @@ mod tests { listener.local_addr().unwrap().port() ); let ucan = signed_ucan(&issuer, &JWK::generate_ed25519().unwrap(), Algorithm::EdDSA); - assert!( - super::verify_delegation_signature(&TinyCloudDelegation::Ucan(Box::new(ucan))) - .await - .is_err() - ); + // Bound the test if the refusal regresses and resolution waits for HTTP. + let result = tokio::time::timeout( + std::time::Duration::from_secs(1), + super::verify_delegation_signature(&TinyCloudDelegation::Ucan(Box::new(ucan))), + ) + .await; assert_no_connections(&[&listener]).await; + assert!(matches!(result, Ok(Err(_)))); } use super::*; use rocket::local::asynchronous::Client; diff --git a/tinycloud-node-server/tests/did_resolution_single_entry.rs b/tinycloud-node-server/tests/did_resolution_single_entry.rs index dd6f3719..dd805608 100644 --- a/tinycloud-node-server/tests/did_resolution_single_entry.rs +++ b/tinycloud-node-server/tests/did_resolution_single_entry.rs @@ -108,12 +108,51 @@ fn network_did_module(name: &str) -> bool { matches!(name, "web" | "tz" | "ion" | "ethr") } +// Signature primitives whose production call sites are reviewed. A new call +// site must be added to REVIEWED_SIGNATURE_SITES with its gate, or the test fails. +const SIGNATURE_PRIMITIVES: &[&str] = &[ + "verify_detached_ed25519", + "verify_bytes", + "recover_from_prehash", + "did_key_public_key", +]; +const REVIEWED_SIGNATURE_SITES: &[&str] = &[ + "tinycloud-auth/src/share_email_evidence.rs :: enforce_holder_equation :: did_key_public_key", + "tinycloud-auth/src/share_email_evidence.rs :: verify_detached_ed25519 :: did_key_public_key", + "tinycloud-auth/src/share_email_evidence.rs :: verify_ed25519 :: verify_bytes", + "tinycloud-auth/src/share_email_evidence.rs :: verify_holder_binding_artifact :: did_key_public_key", + "tinycloud-core/src/encryption_network/service.rs :: did_key_public_key :: did_key_public_key", + "tinycloud-core/src/encryption_network/service.rs :: verify_decryption_signature :: did_key_public_key", + "tinycloud-core/src/policy_authority/verifier.rs :: verify_ed25519 :: verify_detached_ed25519", + "tinycloud-core/src/policy_authority/verifier.rs :: verify_eip191 :: recover_from_prehash", + "tinycloud-core/src/share_email/authority.rs :: load_statuses :: verify_detached_ed25519", + "tinycloud-core/src/share_email/authority.rs :: validate_attestation :: verify_detached_ed25519", + "tinycloud-core/src/share_email/authority.rs :: verify_wrapper :: verify_detached_ed25519", + "tinycloud-core/src/share_email/data_plane.rs :: did_key_public_key :: did_key_public_key", + "tinycloud-core/src/share_email/data_plane.rs :: verify_request :: did_key_public_key", + "tinycloud-node-server/src/policy_v3.rs :: ingest_status_checkpoint_unmounted :: verify_detached_ed25519", + "tinycloud-node-server/src/policy_v3.rs :: ingest_status_checkpoint_unmounted :: verify_detached_ed25519", + "tinycloud-node-server/src/policy_v3.rs :: v3_envelope_delivery_projection :: verify_detached_ed25519", + "tinycloud-node-server/src/policy_v3.rs :: validate_claim_and_presentation :: verify_detached_ed25519", + "tinycloud-node-server/src/policy_v3.rs :: validate_policy_document :: verify_detached_ed25519", + "tinycloud-node-server/src/policy_v3.rs :: validate_presentation_signature :: verify_detached_ed25519", + "tinycloud-node-server/src/policy_v3.rs :: validate_stored_root_status :: verify_detached_ed25519", + "tinycloud-node-server/src/policy_v3.rs :: verify_issuer_signature :: verify_bytes", + "tinycloud-node-server/src/policy_v3.rs :: verify_signed_json :: verify_detached_ed25519", + "tinycloud-node-server/src/share_v2.rs :: verify_enforcement_delegation :: verify_detached_ed25519", + "tinycloud-node-server/src/share_v2.rs :: verify_outer_envelope :: verify_detached_ed25519", + "tinycloud-node-server/src/share_v2.rs :: verify_policy_proof :: verify_detached_ed25519", + "tinycloud-node-server/src/share_v2.rs :: verify_runtime_delegation :: verify_detached_ed25519", + "tinycloud-node-server/src/share_v2.rs :: verify_v2_proof :: verify_detached_ed25519", +]; + #[derive(Default)] struct Guard { file: String, function: String, violations: Vec, helper_calls: usize, + signature_sites: Vec, test_modules: Vec, module_dir: PathBuf, } @@ -158,6 +197,13 @@ impl Guard { ) } + fn signature_site(&mut self, name: &str) { + if SIGNATURE_PRIMITIVES.contains(&name) { + self.signature_sites + .push(format!("{} :: {} :: {name}", self.file, self.function)); + } + } + fn auth_resolver_import(&self, item: &syn::ItemUse) -> bool { self.file == "tinycloud-auth/src/resolver.rs" && canonical_tokens(item) == "use ssi :: dids :: AnyDidMethod ;" @@ -358,6 +404,7 @@ impl<'ast> Visit<'ast> for Guard { { self.violations.push(call.to_token_stream().to_string()); } + self.signature_site(&ident_name(&call.method)); // Ordinary verify() also serves fixed-key cryptography; any resolver // types, factories, or calls in its receiver/arguments are still visited. visit::visit_expr_method_call(self, call); @@ -392,6 +439,7 @@ impl<'ast> Visit<'ast> for Guard { ) { self.helper_calls += 1; } + self.signature_site(&name); } visit::visit_expr_call(self, call); } @@ -451,6 +499,65 @@ fn ucan_resolution_has_one_production_entry_point() { ); } +fn production_signature_sites() -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let mut files = Vec::new(); + for dir in [ + "tinycloud-core/src", + "tinycloud-node-server/src", + "tinycloud-auth/src", + ] { + source_files(&root.join(dir), &mut files); + } + let mut scans = Vec::new(); + for path in files { + let relative = path.strip_prefix(root).unwrap().to_str().unwrap(); + if relative == "tinycloud-core/src/did_resolution.rs" { + continue; + } + let source = std::fs::read_to_string(&path).unwrap(); + let ast = + syn::parse_file(&source).unwrap_or_else(|error| panic!("{}: {error}", path.display())); + let mut guard = Guard::new(relative); + guard.visit_file(&ast); + scans.push(guard); + } + let test_modules: Vec<_> = scans.iter().flat_map(|scan| &scan.test_modules).collect(); + scans + .iter() + .filter(|scan| { + !test_modules + .iter() + .any(|path| Path::new(&scan.file).starts_with(path)) + }) + .flat_map(|scan| scan.signature_sites.clone()) + .collect() +} + +#[test] +fn signature_primitives_are_pinned_to_reviewed_sites() { + let mut sites = production_signature_sites(); + sites.sort(); + let mut expected: Vec = REVIEWED_SIGNATURE_SITES + .iter() + .map(|site| site.to_string()) + .collect(); + expected.sort(); + assert_eq!( + sites, expected, + "signature primitive call site changed; review its gate before pinning it" + ); +} + +#[test] +fn signature_primitive_outside_the_reviewed_list_is_reported() { + let guard = scan_fixture("fn new_site() { verify_detached_ed25519(a, b, c); }"); + assert_eq!( + guard.signature_sites, + vec!["fixture.rs :: new_site :: verify_detached_ed25519".to_string()] + ); +} + #[test] fn only_test_items_are_excluded_and_all_raw_entry_points_are_checked() { let source = r#" From 987accefac7a4f83fb4dde2dd73bf8ba9041bc94 Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 01:42:23 +0000 Subject: [PATCH 04/11] TC-1500: update DID allow-list docs --- CHANGELOG.md | 2 +- README.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6e103d..abdd5246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changes -- UCAN DID resolution uses one fixed allow-list: `did:key` (Ed25519 keys only), `did:pkh` (`eip155` namespace only) and `did:jwk`, all offline. Every other DID method, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, is refused with `did_method_disabled:` before any resolution or network lookup. The `did_resolution.network_methods` setting and `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS` are removed and no longer enable any method. `GET /info` keeps `did_resolution.network_methods` as a constant `false` for wire compatibility. +- UCAN DID resolution uses one fixed allow-list: `did:key` (Ed25519 keys only, exact Ed25519 identifier length), `did:pkh` (`eip155` namespace only, identifier of at most 70 characters) and `did:jwk` (Ed25519 OKP keys only), all offline. Other `did:jwk` key types are refused, matching `did:key`. Every other DID method, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, is refused with `did_method_disabled:` before any resolution or network lookup. EIP-191 issuers are parsed as `did:pkh:eip155` before signature recovery. The `did_resolution.network_methods` setting and `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS` are retired: a configuration that still sets them logs a warning, and neither enables any method. `GET /info` keeps `did_resolution.network_methods` as a constant `false` for wire compatibility. - Schema-changing SQL statements now require `tinycloud.sql/schema` (TC-1581). `tinycloud.sql/write` covers data statements only (`INSERT`, `UPDATE`, `DELETE`, `SELECT`). `CREATE`, `ALTER` and `DROP` of tables, views and indexes need a schema-conferring ability: `sql/schema`, `sql/admin` or `sql/*`. This is judged for every statement of a request against all the SQL abilities the invocation holds, in `query`, `execute`, the `schema` list of `execute`, `batch` and named statements, and again by the SQLite authorizer, which now also refuses `CREATE TRIGGER`. A client that holds both `write` and `schema` (the SDK sends both for `execute({ schema })` and for migrations) is unaffected; the SDK has requested `sql/schema` for DDL since 2.5.0. `PRAGMA` still needs `sql/admin`. The storage-quota gate classifies a statement as a write whoever may run it. Configurable with `[global.storage.sql] ddl_requires_schema = "enforce" | "log" | "off"` (default `enforce`): `log` runs the statement and records it, with one constant warning per request and a counter by service and statement kind, and no SQL text, space ids, DIDs or table names; `off` restores the earlier rule that `write` also suffices for the statements `log` counts; the other changes in this release (the `CREATE TRIGGER` refusal, the nested-block-comment refusal, the CTE write classification, the schema-table and PRAGMA restrictions) apply in every mode. `log` counts only statements the parser classifies as schema-changing. A request that mixes `schema` entries and `sql` is not transactional: the schema entries run first and can stay applied if a later statement is refused. No migration; the default needs no compose change. See `docs/sql-schema-authority.md`. - Fixed `WITH … INSERT` and `WITH … UPDATE` SQL statements being classified as reads (TC-1585). They skipped the storage-quota gate, emitted no write-hook events and reported no touched table, so a write that changed the database was not made durable until a later write did. Any statement tree that contains an `INSERT` or `UPDATE` is now a write: it is quota-gated, publishes write events for its table, is persisted, and is refused to a read-tier grant and to a `readOnly` caveat. `WITH … DELETE` is still not accepted by the SQL parser, for any ability. - Connection-level SQLite state no longer outlives a request on a database's shared connection (TC-1586). An `sql/admin` holder may now run only an allow-list of PRAGMAs: the plain schema reports, the database header values `user_version` and `application_id` (set or read), the check pragmas `integrity_check`, `quick_check` and `foreign_key_check`, and read-only reports such as `journal_mode`, `foreign_keys` and `page_count`. Any other PRAGMA, including every form that sets connection-level or process-wide settings, is refused. After any request in which the authorizer allowed a PRAGMA outside the plain schema reports, the node also resets the connection to its opening state, for in-memory and file-backed databases alike; if that reset fails, the database's actor is retired and the next request rebuilds it from the durable copy. The SQLite authorizer also no longer lets a data statement write `sqlite_master`, `sqlite_schema` or their temp counterparts: only a schema change (which writes them internally), a pure query, and `admin` do. `sql/write` keeps writing application tables. diff --git a/README.md b/README.md index 13428e59..a5a64a12 100644 --- a/README.md +++ b/README.md @@ -381,8 +381,10 @@ TinyCloud Protocol is most easily used via the TinyCloud Protocol SDK. See the e ### UCAN DID resolution (TC-1080) -UCAN signature verification resolves only `did:key`, `did:pkh` and `did:jwk` -by default. Other methods fail before resolution with `did_method_disabled:`. +UCAN signature verification resolves only `did:key` (Ed25519 keys), `did:pkh` +(EVM, `eip155` namespace) and `did:jwk` (Ed25519 OKP keys) by default. Other +methods, and other key types or namespaces, fail before resolution with +`did_method_disabled:`. Issuer DID URLs carrying a query fail with `did_url_query_rejected:` under every setting. DID parsing and string comparisons, including share-email `did:web` identifiers, are unchanged. Existing route status codes and policy machine codes From d8eea302c3d4d6491e0e3c914d04c68225f11de3 Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 01:46:11 +0000 Subject: [PATCH 05/11] TC-1500: drop 'by default' from DID allow-list README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a5a64a12..b5f0a0df 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,7 @@ TinyCloud Protocol is most easily used via the TinyCloud Protocol SDK. See the e ### UCAN DID resolution (TC-1080) UCAN signature verification resolves only `did:key` (Ed25519 keys), `did:pkh` -(EVM, `eip155` namespace) and `did:jwk` (Ed25519 OKP keys) by default. Other +(EVM, `eip155` namespace) and `did:jwk` (Ed25519 OKP keys). Other methods, and other key types or namespaces, fail before resolution with `did_method_disabled:`. Issuer DID URLs carrying a query fail with `did_url_query_rejected:` under every From a4f0c1eefd47246f640cd0d75488895d5e2b700e Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 03:52:31 +0000 Subject: [PATCH 06/11] Bound did:jwk identifier length and key size before decoding --- tinycloud-core/src/did_resolution.rs | 62 +++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/tinycloud-core/src/did_resolution.rs b/tinycloud-core/src/did_resolution.rs index 931d4ce0..1e16e359 100644 --- a/tinycloud-core/src/did_resolution.rs +++ b/tinycloud-core/src/did_resolution.rs @@ -123,14 +123,32 @@ fn namespace_allowed(did: &DID) -> bool { } } +/// Longest `did:jwk` identifier that can carry a canonical Ed25519 JWK: +/// `{"crv":"Ed25519","kty":"OKP","x":"<43 chars>"}` is 79 bytes, which is +/// 106 unpadded base64url characters. Longer identifiers are refused before decoding. +const ED25519_DID_JWK_ID_LEN_MAX: usize = 106; + +#[cfg(test)] +thread_local! { + static JWK_DECODE_ATTEMPTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Decodes a `did:jwk` identifier as the SSI resolver does (unpadded base64url, then JWK JSON). /// Admits only an OKP `Ed25519` key, the same key type as `did:key`. fn jwk_is_ed25519(id: &str) -> bool { + if id.len() > ED25519_DID_JWK_ID_LEN_MAX { + return false; + } + #[cfg(test)] + JWK_DECODE_ATTEMPTS.with(|c| c.set(c.get() + 1)); let Ok(bytes) = URL_SAFE_NO_PAD.decode(id) else { return false; }; match serde_json::from_slice::(&bytes) { - Ok(jwk) => matches!(&jwk.params, Params::OKP(okp) if okp.curve == "Ed25519"), + Ok(jwk) => matches!( + &jwk.params, + Params::OKP(okp) if okp.curve == "Ed25519" && okp.public_key.0.len() == 32 + ), Err(_) => false, } } @@ -354,6 +372,48 @@ mod tests { assert!(!admitted(&format!("did:key:z6Mk{}", "1".repeat(65_532)))); } + /// Canonical `did:jwk` for an OKP Ed25519 key whose `x` is `key_len` bytes. + fn jwk_did(key_len: usize) -> String { + let x = URL_SAFE_NO_PAD.encode(vec![7u8; key_len]); + let json = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{x}"}}"#); + format!("did:jwk:{}", URL_SAFE_NO_PAD.encode(json)) + } + + #[test] + fn did_jwk_identifier_is_capped_at_the_canonical_ed25519_length() { + let canonical = jwk_did(32); + let id = canonical.strip_prefix("did:jwk:").unwrap(); + assert_eq!(id.len(), ED25519_DID_JWK_ID_LEN_MAX); + assert!(admitted(&canonical)); + // One extra space: still valid JSON for the same key, but 107 characters. + let json = format!( + r#"{{"crv":"Ed25519", "kty":"OKP","x":"{}"}}"#, + URL_SAFE_NO_PAD.encode([7u8; 32]) + ); + let padded = format!("did:jwk:{}", URL_SAFE_NO_PAD.encode(json)); + assert_eq!(padded.len() - "did:jwk:".len(), 107); + assert!(!admitted(&padded)); + } + + #[test] + fn did_jwk_is_admitted_only_with_a_32_byte_ed25519_key() { + assert!(admitted(&jwk_did(32))); + // Shorter key: fits the cap, so only the 32-byte check refuses it. + assert!(!admitted(&jwk_did(31))); + assert!(!admitted(&jwk_did(0))); + } + + #[test] + fn oversized_did_jwk_is_refused_without_decoding() { + let before = JWK_DECODE_ATTEMPTS.with(std::cell::Cell::get); + let oversized = format!("did:jwk:{}", "A".repeat(400_000)); + assert!(!admitted(&oversized)); + assert_eq!(JWK_DECODE_ATTEMPTS.with(std::cell::Cell::get), before); + // Positive control: a canonical key does reach the decoder. + assert!(admitted(&jwk_did(32))); + assert_eq!(JWK_DECODE_ATTEMPTS.with(std::cell::Cell::get), before + 1); + } + #[test] fn did_pkh_is_admitted_only_as_bounded_evm() { assert!(admitted( From b4b8a66054c1a44aad7f8667f73a002fef211d12 Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 03:52:31 +0000 Subject: [PATCH 07/11] Pin did:key length gates and signature-namespace tests --- tinycloud-auth/src/share_email_evidence.rs | 25 +++++++++++ .../src/encryption_network/service.rs | 17 +++++++ .../src/policy_authority/verifier.rs | 45 +++++++++++++------ tinycloud-core/src/share_email/data_plane.rs | 10 +++++ 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/tinycloud-auth/src/share_email_evidence.rs b/tinycloud-auth/src/share_email_evidence.rs index 29ec1e82..dec97c1b 100644 --- a/tinycloud-auth/src/share_email_evidence.rs +++ b/tinycloud-auth/src/share_email_evidence.rs @@ -569,6 +569,10 @@ pub fn verify_detached_ed25519( /// Length of an Ed25519 `did:key` string: the `did:key:` prefix plus a 48-character identifier. const ED25519_DID_KEY_LEN: usize = "did:key:".len() + 48; +#[cfg(test)] +thread_local! { + static DID_KEY_DECODE_ATTEMPTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} /// Decodes an Ed25519 `did:key` into its 32-byte public key. The length is checked before base58 /// decoding, and only base58btc multibase over the `0xed 0x01` multicodec is accepted. pub fn did_key_public_key(did: &str) -> Result<[u8; 32], EvidenceError> { @@ -578,6 +582,8 @@ pub fn did_key_public_key(did: &str) -> Result<[u8; 32], EvidenceError> { let encoded = did .strip_prefix("did:key:z") .ok_or(EvidenceError::Invalid)?; + #[cfg(test)] + DID_KEY_DECODE_ATTEMPTS.with(|c| c.set(c.get() + 1)); let (_, bytes) = crate::ipld_core::cid::multibase::decode(format!("z{encoded}")) .map_err(|_| EvidenceError::Invalid)?; if bytes.len() != 34 || bytes[..2] != [0xed, 0x01] { @@ -808,6 +814,25 @@ fn utf16_cmp(left: &str, right: &str) -> Ordering { mod tests { use super::*; + #[test] + fn did_key_public_key_refuses_wrong_lengths_without_decoding() { + let key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; + let short = format!("did:key:{}", &key[..47]); + let long = format!("did:key:{key}1"); + let oversized = format!("did:key:z6Mk{}", "1".repeat(65_532)); + let before = DID_KEY_DECODE_ATTEMPTS.with(std::cell::Cell::get); + for did in [&short, &long, &oversized] { + assert!(did_key_public_key(did).is_err()); + } + assert_eq!(DID_KEY_DECODE_ATTEMPTS.with(std::cell::Cell::get), before); + // Positive control: the canonical identifier reaches the decoder exactly once. + assert!(did_key_public_key(&format!("did:key:{key}")).is_ok()); + assert_eq!( + DID_KEY_DECODE_ATTEMPTS.with(std::cell::Cell::get), + before + 1 + ); + } + #[test] fn email_normalization_preserves_local_and_only_lowercases_domain() { assert_eq!( diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index c878766c..6de93341 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -1092,3 +1092,20 @@ fn did_key_public_key(did: &str) -> Result { .map_err(|err| err.to_string())?; Ok(ed.into()) } + +#[cfg(test)] +mod tests { + use super::did_key_public_key; + + const ISSUER: &str = "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw"; + + #[test] + fn did_key_issuer_is_admitted_only_at_the_ed25519_identifier_length() { + assert!(did_key_public_key(ISSUER).is_ok()); + assert!(did_key_public_key(&ISSUER[..ISSUER.len() - 1]).is_err()); + assert!(did_key_public_key(&format!("{ISSUER}1")).is_err()); + assert!(did_key_public_key(&format!("did:key:z6Mk{}", "1".repeat(65_532))).is_err()); + // Right length and prefix, but not the Ed25519 multicodec. + assert!(did_key_public_key(&format!("did:key:z6Mk{}", "1".repeat(44))).is_err()); + } +} diff --git a/tinycloud-core/src/policy_authority/verifier.rs b/tinycloud-core/src/policy_authority/verifier.rs index d59a8f24..805e649f 100644 --- a/tinycloud-core/src/policy_authority/verifier.rs +++ b/tinycloud-core/src/policy_authority/verifier.rs @@ -341,19 +341,36 @@ mod tests { value: String::new(), }, }; - let signature = [0u8; 65]; - for issuer in [ - "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw", - "did:pkh:solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:0x0000000000000000000000000000000000000001", - "did:pkh:eip155:0:0x0000000000000000000000000000000000000001", - ] { - assert!( - matches!( - verify_eip191(&artifact(issuer), b"unsigned", &signature), - Err(super::super::AuthorityError::SchemaInvalid) - ), - "{issuer} must be refused before recovery" - ); - } + // A real secp256k1 signature over the preimage `verify_eip191` checks. Recovery + // succeeds for it, so only the issuer-namespace check can refuse a non-eip155 issuer. + let signing_key = k256::ecdsa::SigningKey::from_slice(&[0x11; 32]).unwrap(); + let public = signing_key.verifying_key().to_encoded_point(false); + let address = hex::encode(&Keccak256::digest(&public.as_bytes()[1..])[12..]); + let digest = Sha256::digest([ROOT_DOMAIN, b"unsigned".as_slice()].concat()); + let mut preimage = b"\x19Ethereum Signed Message:\n32".to_vec(); + preimage.extend_from_slice(&digest); + let hash = Keccak256::digest(preimage); + let (sig, recovery) = signing_key.sign_prehash_recoverable(&hash).unwrap(); + let mut signature = [0u8; 65]; + signature[..64].copy_from_slice(&sig.to_bytes()); + signature[64] = recovery.to_byte(); + // Positive control: the same signature verifies under an eip155 issuer. + assert!(verify_eip191( + &artifact(&format!("did:pkh:eip155:1:0x{address}")), + b"unsigned", + &signature + ) + .is_ok()); + // Same signer address under a non-eip155 issuer: refused by the namespace check. + assert!(matches!( + verify_eip191( + &artifact(&format!( + "did:pkh:solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:0x{address}" + )), + b"unsigned", + &signature + ), + Err(super::super::AuthorityError::SchemaInvalid) + )); } } diff --git a/tinycloud-core/src/share_email/data_plane.rs b/tinycloud-core/src/share_email/data_plane.rs index 0effb503..825273a4 100644 --- a/tinycloud-core/src/share_email/data_plane.rs +++ b/tinycloud-core/src/share_email/data_plane.rs @@ -726,6 +726,16 @@ mod tests { const HOLDER: &str = "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw"; + #[test] + fn did_key_issuer_is_admitted_only_at_the_ed25519_identifier_length() { + assert!(did_key_public_key(HOLDER).is_ok()); + assert!(did_key_public_key(&HOLDER[..HOLDER.len() - 1]).is_err()); + assert!(did_key_public_key(&format!("{HOLDER}1")).is_err()); + assert!(did_key_public_key(&format!("did:key:z6Mk{}", "1".repeat(65_532))).is_err()); + // Right length and prefix, but not the Ed25519 multicodec. + assert!(did_key_public_key(&format!("did:key:z6Mk{}", "1".repeat(44))).is_err()); + } + fn scope() -> ShareScope { let space = Did::parse("did:pkh:eip155:1:0x1111111111111111111111111111111111111111").unwrap(); From 87de5f1e1ab39238470250915f8220780af2f5d1 Mon Sep 17 00:00:00 2001 From: Naomi Date: Wed, 21 Oct 2026 03:52:31 +0000 Subject: [PATCH 08/11] Route share email did:key decoding through bounded decoder and extend guard --- tinycloud-node-server/src/share_email.rs | 11 ++++------- .../tests/did_resolution_single_entry.rs | 7 +++++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tinycloud-node-server/src/share_email.rs b/tinycloud-node-server/src/share_email.rs index 04e11d9e..a6adb4e4 100644 --- a/tinycloud-node-server/src/share_email.rs +++ b/tinycloud-node-server/src/share_email.rs @@ -1585,13 +1585,10 @@ fn verify_did_key_signature( { return Err(()); } - let encoded = signer.as_str().strip_prefix("did:key:").ok_or(())?; - let (_, bytes) = tinycloud_auth::ipld_core::cid::multibase::decode(encoded).map_err(|_| ())?; - let key_bytes = match bytes.as_slice() { - [0xed, 0x01, rest @ ..] if rest.len() == 32 => rest, - _ => return Err(()), - }; - let key = tinycloud_core::libp2p::identity::ed25519::PublicKey::try_from_bytes(key_bytes) + // Bounded decode: length is checked before base58, and only the Ed25519 multicodec is accepted. + let key_bytes = tinycloud_auth::share_email_evidence::did_key_public_key(signer.as_str()) + .map_err(|_| ())?; + let key = tinycloud_core::libp2p::identity::ed25519::PublicKey::try_from_bytes(&key_bytes) .map_err(|_| ())?; let signature = decode_config(&proof.signature, URL_SAFE_NO_PAD).map_err(|_| ())?; if signature.len() != 64 { diff --git a/tinycloud-node-server/tests/did_resolution_single_entry.rs b/tinycloud-node-server/tests/did_resolution_single_entry.rs index dd805608..57226088 100644 --- a/tinycloud-node-server/tests/did_resolution_single_entry.rs +++ b/tinycloud-node-server/tests/did_resolution_single_entry.rs @@ -139,6 +139,7 @@ const REVIEWED_SIGNATURE_SITES: &[&str] = &[ "tinycloud-node-server/src/policy_v3.rs :: validate_stored_root_status :: verify_detached_ed25519", "tinycloud-node-server/src/policy_v3.rs :: verify_issuer_signature :: verify_bytes", "tinycloud-node-server/src/policy_v3.rs :: verify_signed_json :: verify_detached_ed25519", + "tinycloud-node-server/src/share_email.rs :: verify_did_key_signature :: did_key_public_key", "tinycloud-node-server/src/share_v2.rs :: verify_enforcement_delegation :: verify_detached_ed25519", "tinycloud-node-server/src/share_v2.rs :: verify_outer_envelope :: verify_detached_ed25519", "tinycloud-node-server/src/share_v2.rs :: verify_policy_proof :: verify_detached_ed25519", @@ -219,8 +220,10 @@ impl Guard { } fn offline_cacao_verification(&self, call: &syn::ExprMethodCall) -> bool { - // CACAO's argument-free SIWE verification is offline. Pin these - // existing expressions; never exempt a file, function, or receiver. + // CACAO's argument-free SIWE verification is offline: EIP-191 recovery over the + // supplied message, with no resolver or RPC path. Pin these existing expressions; + // never exempt a file, function, or receiver. The client-side verifier crate is + // outside this server-side guard's scope. matches!( ( self.file.as_str(), From 57b5906ed09bf95045f007e2f1af95242a0a0d88 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 04:40:46 +0000 Subject: [PATCH 09/11] Move the DID allow-list changelog entry to Unreleased --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4159796..621182bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - SQL local durability and reservation before commit (`docs/sql-local-durability.md`). Postgres stays the master copy. File-backed SQL databases run `PRAGMA synchronous = FULL` (`[global.storage.sql] synchronous = "full"|"normal"`, env `TINYCLOUD_STORAGE__SQL__SYNCHRONOUS`; `normal` selects the earlier fsync behaviour), and the directories and renames that make a database file or a hydrated cache visible are fsynced. A write is checked before COMMIT and rolled back whole if it would pass the owner's remaining storage budget (`402`), and, when configured, the per-database cap (`max_database_bytes`, `507 sql_database_size_limit`) or the disk headroom (`min_free_bytes`, bounded by the WAL and checkpoint growth the transaction can cause and shared by all databases on the node, `507 sql_disk_headroom`). `max_database_bytes` and `min_free_bytes` default to `0` (off) and are set by config. A COMMIT that fails on disk answers `500 sql_commit_failed` and the node re-hydrates the database from Postgres. A write that fails because the disk is full answers `507 sql_disk_headroom`, whatever `min_free_bytes` is, not the owner's quota or the size limit. Every write request runs in one transaction (a single statement, a batch, a nested write (`WITH … INSERT`) and a request that carries a `PRAGMA` included), so such a request counts two more native SQL calls in request telemetry, and all of them get the same owner budget, size cap and disk gate. A request that wrote nothing is not gated and reserves nothing. The growth of the main file that the next checkpoint will cause stays held in the disk reservation from the commit until a checkpoint runs or the database's connection closes. No migration. Details: TC-1491. - `MemorySink` caps its size hint at 64 MiB and treats a failed reservation as no hint. Details: TC-1491. - A local SQL commit failure (answered 500) records the same diagnostic failure note as other SQL 500 responses. Details: TC-1491. +- UCAN DID resolution uses one fixed allow-list: `did:key` (Ed25519 keys only, exact Ed25519 identifier length), `did:pkh` (`eip155` namespace only, identifier of at most 70 characters) and `did:jwk` (Ed25519 OKP keys only), all offline. Other `did:jwk` key types are refused, matching `did:key`. Every other DID method, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, is refused with `did_method_disabled:` before any resolution or network lookup. EIP-191 issuers are parsed as `did:pkh:eip155` before signature recovery. The `did_resolution.network_methods` setting and `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS` are retired: a configuration that still sets them logs a warning, and neither enables any method. `GET /info` keeps `did_resolution.network_methods` as a constant `false` for wire compatibility. ## [1.22.0] - 2026-10-23 @@ -99,7 +100,6 @@ ### Changes -- UCAN DID resolution uses one fixed allow-list: `did:key` (Ed25519 keys only, exact Ed25519 identifier length), `did:pkh` (`eip155` namespace only, identifier of at most 70 characters) and `did:jwk` (Ed25519 OKP keys only), all offline. Other `did:jwk` key types are refused, matching `did:key`. Every other DID method, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, is refused with `did_method_disabled:` before any resolution or network lookup. EIP-191 issuers are parsed as `did:pkh:eip155` before signature recovery. The `did_resolution.network_methods` setting and `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS` are retired: a configuration that still sets them logs a warning, and neither enables any method. `GET /info` keeps `did_resolution.network_methods` as a constant `false` for wire compatibility. - Load each invocation's authorization graph in one SQL round trip on PostgreSQL, SQLite and MySQL rather than four. Revocations in that statement resolve through the persisted CID-alias index exactly like `revocation::effective_rows`, so a delegation revoked under another accepted encoding is still refused (TC-1071). Bound metadata lookups to 65 nodes for indexed access on large tables while the unchanged 64-node authorization limit still denies oversized chains. Sort abilities by `(resource, ability)` on every backend; authorization decisions are unchanged, but the diagnostic reason when multiple caveats all reject may normalize. No schema change (TC-735). - Schema-changing SQL statements now require `tinycloud.sql/schema` (TC-1581). `tinycloud.sql/write` covers data statements only (`INSERT`, `UPDATE`, `DELETE`, `SELECT`). `CREATE`, `ALTER` and `DROP` of tables, views and indexes need a schema-conferring ability: `sql/schema`, `sql/admin` or `sql/*`. This is judged for every statement of a request against all the SQL abilities the invocation holds, in `query`, `execute`, the `schema` list of `execute`, `batch` and named statements, and again by the SQLite authorizer, which now also refuses `CREATE TRIGGER`. A client that holds both `write` and `schema` (the SDK sends both for `execute({ schema })` and for migrations) is unaffected; the SDK has requested `sql/schema` for DDL since 2.5.0. `PRAGMA` still needs `sql/admin`. The storage-quota gate classifies a statement as a write whoever may run it. Configurable with `[global.storage.sql] ddl_requires_schema = "enforce" | "log" | "off"` (default `enforce`): `log` runs the statement and records it, with one constant warning per request and a counter by service and statement kind, and no SQL text, space ids, DIDs or table names; `off` restores the earlier rule that `write` also suffices for the statements `log` counts; the other changes in this release (the `CREATE TRIGGER` refusal, the nested-block-comment refusal, the CTE write classification, the schema-table and PRAGMA restrictions) apply in every mode. `log` counts only statements the parser classifies as schema-changing. A request that mixes `schema` entries and `sql` is not transactional: the schema entries run first and can stay applied if a later statement is refused. No migration; the default needs no compose change. See `docs/sql-schema-authority.md`. - Fixed `WITH … INSERT` and `WITH … UPDATE` SQL statements being classified as reads (TC-1585). They skipped the storage-quota gate, emitted no write-hook events and reported no touched table, so a write that changed the database was not made durable until a later write did. Any statement tree that contains an `INSERT` or `UPDATE` is now a write: it is quota-gated, publishes write events for its table, is persisted, and is refused to a read-tier grant and to a `readOnly` caveat. `WITH … DELETE` is still not accepted by the SQL parser, for any ability. From 01e3c151944e6027f5f8dfe3c304257e1bf81c41 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 08:04:55 +0000 Subject: [PATCH 10/11] Restore the environment-level check of the ignored did_resolution key; neutral wording; changelog details --- CHANGELOG.md | 2 +- README.md | 6 +++--- docs/delegation-flow.md | 6 +++--- tinycloud-node-server/src/runtime.rs | 4 ++++ 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 621182bc..174746c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - SQL local durability and reservation before commit (`docs/sql-local-durability.md`). Postgres stays the master copy. File-backed SQL databases run `PRAGMA synchronous = FULL` (`[global.storage.sql] synchronous = "full"|"normal"`, env `TINYCLOUD_STORAGE__SQL__SYNCHRONOUS`; `normal` selects the earlier fsync behaviour), and the directories and renames that make a database file or a hydrated cache visible are fsynced. A write is checked before COMMIT and rolled back whole if it would pass the owner's remaining storage budget (`402`), and, when configured, the per-database cap (`max_database_bytes`, `507 sql_database_size_limit`) or the disk headroom (`min_free_bytes`, bounded by the WAL and checkpoint growth the transaction can cause and shared by all databases on the node, `507 sql_disk_headroom`). `max_database_bytes` and `min_free_bytes` default to `0` (off) and are set by config. A COMMIT that fails on disk answers `500 sql_commit_failed` and the node re-hydrates the database from Postgres. A write that fails because the disk is full answers `507 sql_disk_headroom`, whatever `min_free_bytes` is, not the owner's quota or the size limit. Every write request runs in one transaction (a single statement, a batch, a nested write (`WITH … INSERT`) and a request that carries a `PRAGMA` included), so such a request counts two more native SQL calls in request telemetry, and all of them get the same owner budget, size cap and disk gate. A request that wrote nothing is not gated and reserves nothing. The growth of the main file that the next checkpoint will cause stays held in the disk reservation from the commit until a checkpoint runs or the database's connection closes. No migration. Details: TC-1491. - `MemorySink` caps its size hint at 64 MiB and treats a failed reservation as no hint. Details: TC-1491. - A local SQL commit failure (answered 500) records the same diagnostic failure note as other SQL 500 responses. Details: TC-1491. -- UCAN DID resolution uses one fixed allow-list: `did:key` (Ed25519 keys only, exact Ed25519 identifier length), `did:pkh` (`eip155` namespace only, identifier of at most 70 characters) and `did:jwk` (Ed25519 OKP keys only), all offline. Other `did:jwk` key types are refused, matching `did:key`. Every other DID method, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, is refused with `did_method_disabled:` before any resolution or network lookup. EIP-191 issuers are parsed as `did:pkh:eip155` before signature recovery. The `did_resolution.network_methods` setting and `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS` are retired: a configuration that still sets them logs a warning, and neither enables any method. `GET /info` keeps `did_resolution.network_methods` as a constant `false` for wire compatibility. +- UCAN DID resolution uses one fixed allow-list: `did:key` (Ed25519 keys only, exact Ed25519 identifier length), `did:pkh` (`eip155` namespace only, identifier of at most 70 characters) and `did:jwk` (Ed25519 OKP keys only), all offline. Other `did:jwk` key types are refused, matching `did:key`. Every other DID method, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, is refused with `did_method_disabled:` before any resolution or network lookup. EIP-191 issuers are parsed as `did:pkh:eip155` before signature recovery. The `did_resolution.network_methods` setting and `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS` have no effect: a configuration that sets them logs a startup warning, and neither enables any method. `GET /info` reports `did_resolution.network_methods` as a constant `false`. Details: TC-1500. ## [1.22.0] - 2026-10-23 diff --git a/README.md b/README.md index 5864a485..141291b4 100644 --- a/README.md +++ b/README.md @@ -394,9 +394,9 @@ Network DID methods, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, ar never resolved for UCAN verification. No setting or environment variable enables them. -**Full TC-1080 Part B hardening is REQUIRED before any future enablement of network -DID resolution.** The unhardened SSI client is not an acceptable path. Part B is not -implemented here, and there is no Part B development bypass. The required node-side +Enabling network DID resolution requires the hardening described in TC-1080 Part B. +The unhardened SSI client is not an option. Part B is not implemented in this node, +and there is no Part B development bypass. The required node-side fetcher, destination enforcement, resource limits and local-listener acceptance checks are described in the [developer gate](docs/delegation-flow.md#network-did-method-future-enable-gate-tc-1132). diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index b66eb76a..7f78f05f 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -32,9 +32,9 @@ another method. DID identifiers used for parsing or identity comparisons (for ex share-email `did:web` values) are unaffected; this gate concerns network resolution during verification. -**Full TC-1080 plan Part B is REQUIRED before any future enablement of a network DID -method.** Do not restore the unhardened SSI fetcher. Part B is not implemented by -TC-1132 Parts C/D, and no Part B development flag or bypass is provided. +Enabling a network DID method requires all of TC-1080 plan Part B. The unhardened +SSI fetcher is not an option. Part B is not implemented by TC-1132 Parts C/D, and +no Part B development flag or bypass is provided. Before a future change enables `did:web`, it must implement and verify all of Part B: diff --git a/tinycloud-node-server/src/runtime.rs b/tinycloud-node-server/src/runtime.rs index 0a12ffe3..4871b0b7 100644 --- a/tinycloud-node-server/src/runtime.rs +++ b/tinycloud-node-server/src/runtime.rs @@ -590,6 +590,7 @@ mod tests { let _telemetry_canonical = EnvGuard::set("TINYCLOUD_TELEMETRY__ENABLED", "true"); let _telemetry_legacy = EnvGuard::unset("TINYCLOUD_TELEMETRY_ENABLED"); let _pool_canonical = EnvGuard::set("TINYCLOUD_DATABASE__MAX_CONNECTIONS", "200"); + let _did_network = EnvGuard::set("TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS", "true"); let _timeout_mode = EnvGuard::set("TINYCLOUD_DATABASE__STATEMENT_TIMEOUT_MODE", "local"); let _user_timeout = EnvGuard::set("TINYCLOUD_DATABASE__TCP_USER_TIMEOUT_MS", "20000"); let _keys_env = clear_keys_env(); @@ -605,6 +606,9 @@ mod tests { assert!(cfg.telemetry.enabled); // The pool knob is env-overridable via the double-underscore form. assert_eq!(cfg.database.max_connections, 200); + // The retired key is detected from the environment (it has no effect + // on resolution, and startup warns about it). + assert!(cfg.did_resolution.network_methods); // Absent config falls back to the hardcoded default of 100. assert_eq!(config::DatabaseConfig::default().max_connections, 100); assert_eq!(config::Config::default().database.max_connections, 100); From ce7f53371284af35b9b7fc41da8eaada39038e67 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 10:53:34 +0000 Subject: [PATCH 11/11] Neutral wording in docs and comments --- README.md | 6 +++--- docs/delegation-flow.md | 6 +++--- tinycloud-core/src/did_resolution.rs | 2 +- tinycloud-node-server/src/config.rs | 4 ++-- tinycloud-node-server/src/runtime.rs | 2 +- tinycloud-node-server/tests/did_resolution_app_startup.rs | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 141291b4..f3d37d4e 100644 --- a/README.md +++ b/README.md @@ -394,9 +394,9 @@ Network DID methods, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, ar never resolved for UCAN verification. No setting or environment variable enables them. -Enabling network DID resolution requires the hardening described in TC-1080 Part B. -The unhardened SSI client is not an option. Part B is not implemented in this node, -and there is no Part B development bypass. The required node-side +Network DID resolution requires the hardening described in TC-1080 Part B. This node +does not implement Part B, and no setting or development flag provides it. The +required node-side fetcher, destination enforcement, resource limits and local-listener acceptance checks are described in the [developer gate](docs/delegation-flow.md#network-did-method-future-enable-gate-tc-1132). diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index 7f78f05f..6eeb4f86 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -32,9 +32,9 @@ another method. DID identifiers used for parsing or identity comparisons (for ex share-email `did:web` values) are unaffected; this gate concerns network resolution during verification. -Enabling a network DID method requires all of TC-1080 plan Part B. The unhardened -SSI fetcher is not an option. Part B is not implemented by TC-1132 Parts C/D, and -no Part B development flag or bypass is provided. +Enabling a network DID method requires all of TC-1080 plan Part B. Part B is not +implemented by TC-1132 Parts C/D, and no development flag provides it. The SSI +fetcher is not an acceptable resolver. Before a future change enables `did:web`, it must implement and verify all of Part B: diff --git a/tinycloud-core/src/did_resolution.rs b/tinycloud-core/src/did_resolution.rs index 1e16e359..591d35e6 100644 --- a/tinycloud-core/src/did_resolution.rs +++ b/tinycloud-core/src/did_resolution.rs @@ -385,7 +385,7 @@ mod tests { let id = canonical.strip_prefix("did:jwk:").unwrap(); assert_eq!(id.len(), ED25519_DID_JWK_ID_LEN_MAX); assert!(admitted(&canonical)); - // One extra space: still valid JSON for the same key, but 107 characters. + // One extra space: valid JSON for the same key, but 107 characters. let json = format!( r#"{{"crv":"Ed25519", "kty":"OKP","x":"{}"}}"#, URL_SAFE_NO_PAD.encode([7u8; 32]) diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index be94eb67..d112aa91 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1816,10 +1816,10 @@ impl Default for DatabaseConfig { } /// UCAN issuer resolution. The method allow-list is fixed in `tinycloud_core::did_resolution` -/// and has no settings. Retired keys are still accepted so existing configs keep parsing. +/// and has no settings. A config that sets `network_methods` parses; the key is ignored. #[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] pub struct DidResolutionConfig { - /// Retired and ignored: network DID methods are never resolved. `true` when the key is + /// Ignored: network DID methods are never resolved. `true` when the key is /// present, with any value, so startup can warn that it has no effect. #[serde(default, skip_serializing, deserialize_with = "retired_key_present")] pub network_methods: bool, diff --git a/tinycloud-node-server/src/runtime.rs b/tinycloud-node-server/src/runtime.rs index 4871b0b7..2327988c 100644 --- a/tinycloud-node-server/src/runtime.rs +++ b/tinycloud-node-server/src/runtime.rs @@ -606,7 +606,7 @@ mod tests { assert!(cfg.telemetry.enabled); // The pool knob is env-overridable via the double-underscore form. assert_eq!(cfg.database.max_connections, 200); - // The retired key is detected from the environment (it has no effect + // The ignored key is detected from the environment (it has no effect // on resolution, and startup warns about it). assert!(cfg.did_resolution.network_methods); // Absent config falls back to the hardcoded default of 100. diff --git a/tinycloud-node-server/tests/did_resolution_app_startup.rs b/tinycloud-node-server/tests/did_resolution_app_startup.rs index 86358308..f6935166 100644 --- a/tinycloud-node-server/tests/did_resolution_app_startup.rs +++ b/tinycloud-node-server/tests/did_resolution_app_startup.rs @@ -1,4 +1,4 @@ -//! Real app startup. The retired `network_methods` environment key must not enable any DID +//! Real app startup. The ignored `network_methods` environment key must not enable any DID //! method outside the allow-list. One test in its own process isolates the env fixture. #[path = "../../tinycloud-core/tests/support/did_resolution.rs"] mod support; @@ -93,7 +93,7 @@ async fn app_startup_ignores_retired_network_methods_env() { assert_web_refused().await; drop(client); - // The retired key set to true must not enable web or report a different policy. + // The ignored key set to true must not enable web or report a different policy. std::env::set_var(NETWORK_METHODS_ENV, "true"); let (client, _dir) = app().await; assert!(!network_methods(&client).await);