Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ uuid = { version = "1", features = ["v4"] }
affinidi-messaging-didcomm-service = "0.7"
affinidi-tdk = "0.13"
affinidi-secrets-resolver = "0.5"
affinidi-did-resolver-cache-sdk = { version = "0.8", features = ["network"] }
# 0.8.37 is a floor, not cosmetic: it is the first release whose did:web **and**
# did:webvh resolvers refuse non-public hosts by default (didwebvh-rs 0.7's
# `HostPolicy`). A DID names the host its document is fetched from, and the
# DIDComm transport resolves DIDs chosen by whoever sends the gateway a message,
# so resolving below this floor would reintroduce a resolver-side SSRF. Pinning
# the patch stops a fresh `cargo update` from resolving back under it.
affinidi-did-resolver-cache-sdk = { version = "0.8.37", features = ["network"] }
tokio-util = "0.7"
# Async push senders (real delivery — Web Push / APNs — is async HTTP).
async-trait = "0.1"
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ cargo run
# GATEWAY_DID_NETWORK_TIMEOUT_MS=10000 per-resolution timeout (SDK default 5000)
# GATEWAY_DID_RESOLVER_URL=wss://… resolve via a remote resolver service
# instead of locally (unset = local resolution)
# GATEWAY_DID_ALLOW_PRIVATE_HOSTS=1 let did:web/did:webvh resolution reach
# non-public hosts (loopback, RFC 1918, link-local).
# Default off: a DID names the host its document is
# fetched from, and inbound DIDComm senders choose the
# DIDs the gateway resolves. Needed only for a local
# stack whose VTA/mediator DIDs are
# did:webvh:{SCID}:localhost%3A3000 — without it those
# resolve as BlockedHost.
# RUST_LOG=vti_push_gateway=debug
```

Expand Down
99 changes: 96 additions & 3 deletions src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,39 @@
//! takes via `ListenerConfig.tdk_config` (replacing the implicit
//! `TDKConfig::headless()`).
//!
//! ## Which hosts resolution may contact
//!
//! A `did:web`/`did:webvh` identifier *is* a network location: everything after
//! the method prefix is the host the DID document (or verifiable log) is fetched
//! from. On the DIDComm path the gateway resolves DIDs it did not choose — every
//! authcrypt sender that reaches it through the mediator — so an inbound message
//! naming `did:webvh:{SCID}:169.254.169.254` or an internal host would otherwise
//! make the gateway issue that request from inside its own network.
//!
//! `affinidi-did-resolver-cache-sdk` 0.8.37 defaults to
//! [`HostPolicy::PublicOnly`], which refuses loopback, private, CGNAT,
//! link-local and other non-public hosts — both when the DID names one directly
//! and when a public-looking name resolves to one. This module keeps that
//! default and exposes one opt-out for local development, where the gateway's own
//! identity and its mediator are typically `did:webvh:{SCID}:localhost%3A3000`
//! and resolution would otherwise fail with `BlockedHost`.
//!
//! Env knobs (all optional; defaults below):
//! - `GATEWAY_DID_CACHE_CAPACITY` — max cached DID docs (default 250)
//! - `GATEWAY_DID_CACHE_TTL_SECS` — cache entry TTL (default 900 = 15 min)
//! - `GATEWAY_DID_NETWORK_TIMEOUT_MS` — per-resolution timeout (default 10000)
//! - `GATEWAY_DID_RESOLVER_URL` — resolve via a remote resolver service
//! (`ws[s]://…`) instead of locally; unset = local resolution.
//! - `GATEWAY_DID_ALLOW_PRIVATE_HOSTS` — allow did:web/did:webvh resolution to
//! non-public hosts (default off). For local stacks only.

use affinidi_did_resolver_cache_sdk::config::{DIDCacheConfig, DIDCacheConfigBuilder};
use affinidi_did_resolver_cache_sdk::network_resolvers::HostPolicy;
use affinidi_tdk::common::config::TDKConfig;

/// Env var opting resolution out of the public-host-only default.
pub const ENV_ALLOW_PRIVATE_DID_HOSTS: &str = "GATEWAY_DID_ALLOW_PRIVATE_HOSTS";

/// Resolved DID-resolver tuning (post-env).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolverTuning {
Expand All @@ -27,6 +50,9 @@ pub struct ResolverTuning {
pub network_timeout_ms: u32,
/// Remote resolver service address (`ws[s]://…`); `None` = local resolution.
pub service_address: Option<String>,
/// Whether did:web/did:webvh resolution may contact non-public hosts.
/// `false` (the default) is [`HostPolicy::PublicOnly`].
pub allow_private_did_hosts: bool,
}

impl Default for ResolverTuning {
Expand All @@ -39,6 +65,10 @@ impl Default for ResolverTuning {
cache_ttl_secs: 900,
network_timeout_ms: 10_000,
service_address: None,
// Secure default: a DID that names a private or loopback host is
// refused, because inbound DIDComm senders choose the DIDs the
// gateway resolves.
allow_private_did_hosts: false,
}
}
}
Expand All @@ -56,14 +86,28 @@ impl ResolverTuning {
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
allow_private_did_hosts: env_flag(ENV_ALLOW_PRIVATE_DID_HOSTS),
}
}

/// The host policy resolution runs under.
fn host_policy(&self) -> HostPolicy {
if self.allow_private_did_hosts {
HostPolicy::AllowPrivate
} else {
HostPolicy::PublicOnly
}
}

fn did_cache_config(&self) -> DIDCacheConfig {
let mut builder = DIDCacheConfigBuilder::default()
.with_cache_capacity(self.cache_capacity)
.with_cache_ttl(self.cache_ttl_secs)
.with_network_timeout(self.network_timeout_ms);
.with_network_timeout(self.network_timeout_ms)
// Explicit rather than implicit: this is the same value the builder
// defaults to, stated here so the gateway's stance is visible at the
// one place it configures resolution.
.with_host_policy(self.host_policy());
if let Some(addr) = &self.service_address {
builder = builder.with_network_mode(addr);
}
Expand All @@ -85,15 +129,32 @@ impl ResolverTuning {
/// One-line summary for the startup log.
pub fn summary(&self) -> String {
format!(
"cache_capacity={} cache_ttl={}s network_timeout={}ms resolver={}",
"cache_capacity={} cache_ttl={}s network_timeout={}ms resolver={} did_hosts={}",
self.cache_capacity,
self.cache_ttl_secs,
self.network_timeout_ms,
self.service_address.as_deref().unwrap_or("local")
self.service_address.as_deref().unwrap_or("local"),
if self.allow_private_did_hosts {
"private-allowed"
} else {
"public-only"
}
)
}
}

/// Parse a boolean env flag: set to `1`/`true`/`yes`/`on` enables it.
fn env_flag(key: &str) -> bool {
std::env::var(key)
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}

/// Parse a `u32` env var, logging and falling back to `default` when unset or
/// unparseable. Split from the parsing so the latter is unit-testable.
fn env_u32(key: &str, default: u32) -> u32 {
Expand Down Expand Up @@ -140,6 +201,38 @@ mod tests {
assert!(d.service_address.is_none()); // local by default
}

/// The gateway resolves DIDs chosen by inbound DIDComm senders, so the
/// default must be the public-only policy — and must be visible in the
/// startup log.
#[test]
fn did_host_policy_defaults_to_public_only() {
let d = ResolverTuning::default();
assert!(!d.allow_private_did_hosts);
assert_eq!(d.host_policy(), HostPolicy::PublicOnly);
assert!(
d.summary().contains("did_hosts=public-only"),
"{}",
d.summary()
);
}

/// The local-development opt-in flips the policy, and says so in the log.
#[test]
fn private_did_hosts_opt_in_is_visible() {
let t = ResolverTuning {
allow_private_did_hosts: true,
..ResolverTuning::default()
};
assert_eq!(t.host_policy(), HostPolicy::AllowPrivate);
assert!(
t.summary().contains("did_hosts=private-allowed"),
"{}",
t.summary()
);
// Both policies build a usable config.
assert!(t.tdk_config().is_ok());
}

#[test]
fn builds_a_tdk_config_local_and_remote() {
// Local.
Expand Down