diff --git a/CHANGELOG.md b/CHANGELOG.md index fb211081..174746c7 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` 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 25553d5c..f3d37d4e 100644 --- a/README.md +++ b/README.md @@ -381,24 +381,24 @@ 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). 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 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. + +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). The startup log records the effective policy. Refusals increment diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index 1a9a4cb3..6eeb4f86 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. + +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-auth/src/share_email_evidence.rs b/tinycloud-auth/src/share_email_evidence.rs index 0cd2e92c..dec97c1b 100644 --- a/tinycloud-auth/src/share_email_evidence.rs +++ b/tinycloud-auth/src/share_email_evidence.rs @@ -567,10 +567,23 @@ 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; +#[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> { + if did.len() != ED25519_DID_KEY_LEN { + return Err(EvidenceError::Invalid); + } 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] { @@ -801,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/did_resolution.rs b/tinycloud-core/src/did_resolution.rs index da03b697..591d35e6 100644 --- a/tinycloud-core/src/did_resolution.rs +++ b/tinycloud-core/src/did_resolution.rs @@ -1,44 +1,41 @@ //! 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}, env, sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, + atomic::{AtomicU64, Ordering}, Mutex, OnceLock, }, 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}, }; -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. +/// `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(); -#[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 +58,98 @@ 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 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(()) +} + +/// 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.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, + } +} + +/// 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" && okp.public_key.0.len() == 32 + ), + Err(_) => false, } } @@ -111,7 +159,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 +186,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(), @@ -284,6 +355,103 @@ 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)))); + } + + /// 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: 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( + "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 bfb4cf6b..3fada131 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -1388,17 +1388,26 @@ 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()) } + +#[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 537ba567..805e649f 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,56 @@ 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(), + }, + }; + // 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 92cc6912..825273a4 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()) } @@ -734,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(); diff --git a/tinycloud-core/tests/did_resolution_policy.rs b/tinycloud-core/tests/did_resolution_policy.rs index 3fd547bd..ff49bb79 100644 --- a/tinycloud-core/tests/did_resolution_policy.rs +++ b/tinycloud-core/tests/did_resolution_policy.rs @@ -1,10 +1,13 @@ -//! 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 base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use futures::FutureExt; use std::{ - io::{Read, Write}, - sync::OnceLock, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, time::Duration, }; use support::*; @@ -12,8 +15,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 +24,182 @@ 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) + } } -struct ResetPolicy; -impl Drop for ResetPolicy { - fn drop(&mut self) { - configure(DidResolutionPolicy::default()); + +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() { + for (issuer, method) in REFUSED { + assert_refused_at_every_site(issuer, method).await; } } #[tokio::test] -async fn default_refuses_web_variants_before_connecting() { - let _lock = policy_lock().await; - assert_eq!(policy(), DidResolutionPolicy::default()); +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; + } +} + +/// 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() + ) + .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" + ); +} + +#[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 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 +222,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 +231,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 +246,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 +267,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 +286,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 +346,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 4928da24..d112aa91 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -1815,16 +1815,24 @@ 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. A config that sets `network_methods` parses; the key is ignored. #[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)] + /// 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 /// read-audit wait would answer every authorized read `read_audit_busy`. @@ -2010,6 +2018,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-1491: off by default, 60 s / 8 MiB for every tier, and nothing is /// validated while the feature is off. #[test] diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index c8927dda..6525b514 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -667,11 +667,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, ); @@ -691,10 +686,14 @@ 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"); + ::tracing::info!( + allowed_methods = ?tinycloud_core::did_resolution::ALLOWED_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::warn!( + "did_resolution.network_methods is retired and ignored; network DID methods are never resolved" + ); } let routes = production_routes(tinycloud_config.encryption_network.named_keys_v1); diff --git a/tinycloud-node-server/src/main.rs b/tinycloud-node-server/src/main.rs index 361f22a0..f86c7178 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/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 28337963..f514553b 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -6629,12 +6629,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/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index da081fa4..a41668b9 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -336,7 +336,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..2327988c 100644 --- a/tinycloud-node-server/src/runtime.rs +++ b/tinycloud-node-server/src/runtime.rs @@ -606,6 +606,8 @@ 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 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. assert_eq!(config::DatabaseConfig::default().max_connections, 100); 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/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_app_startup.rs b/tinycloud-node-server/tests/did_resolution_app_startup.rs index 91fd2971..f6935166 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 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; 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 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; - // 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; } diff --git a/tinycloud-node-server/tests/did_resolution_single_entry.rs b/tinycloud-node-server/tests/did_resolution_single_entry.rs index d751ea71..97895b28 100644 --- a/tinycloud-node-server/tests/did_resolution_single_entry.rs +++ b/tinycloud-node-server/tests/did_resolution_single_entry.rs @@ -108,12 +108,52 @@ 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_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", + "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, } @@ -160,6 +200,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 ;" @@ -175,8 +222,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(), @@ -360,6 +409,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); @@ -394,6 +444,7 @@ impl<'ast> Visit<'ast> for Guard { ) { self.helper_calls += 1; } + self.signature_site(&name); } visit::visit_expr_call(self, call); } @@ -453,6 +504,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#" diff --git a/tinycloud.toml b/tinycloud.toml index 1c231a3f..5b484e7d 100644 --- a/tinycloud.toml +++ b/tinycloud.toml @@ -88,10 +88,8 @@ 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. [global.encryption_network] ## Serve named keys and advertise the `encryption-named-keys-v1` feature in