#![forbid(unsafe_code)]
#![allow(
clippy::doc_markdown,
clippy::missing_errors_doc,
clippy::module_name_repetitions,
reason = "protocol acronyms, shared EngineError, and explicit facade names are intentional"
)]
use std::fmt;
use std::sync::RwLock;
use hns_browser_observability::{
BrowserStatus, DegradedReason, IcannDnssecStatus, IcannTlsAction, Namespace, OutcomeKind,
ProviderReadiness, RateLimitState, RevocationReason, RootFailureKind, SelectionReason,
StatusError, StatusInput, TransportIdentities, UnsupportedEvidence,
};
pub use hns_browser_runtime::{AuthorityState, RuntimeSessionId};
use hns_browser_runtime::{BrowserRuntime, RuntimeError, RuntimeStamp};
use hns_dane::{DaneLimits, DaneMatch, verify_dane_chain, verify_dane_ee};
use hns_dns_wire::{Message, ParseLimits, Query, Rdata, RecordType, Tlsa};
pub use hns_gateway::{Gateway, GatewayLimits, GatewaySelection};
pub use hns_p2p_transport::{
AdapterFailure, AdmittedDnsResponse, AuthenticatedPeer, DnsRelayRequester,
ExperimentalExchange, ExperimentalRequest, ExperimentalResponse, OdohRequester,
P2pTransportError, PeerIdentity, RequesterLimits, VerifiedOdohTarget,
};
use hns_resolution_policy::{
Admission, ChainAnchor, EvidenceState, Network, PolicyConfig, PolicyController, PolicyError,
PolicySnapshot, PolicyTransition, ResolutionProvenance, ResolutionTransport, TransportPlan,
ValidationEvidence,
};
use hns_resolver::ValidatedTlsa;
pub const ENGINE_API_VERSION: u32 = 2;
pub const MAX_TRANSPORT_IDENTITY_BYTES: usize = 256;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct EngineConfig {
pub runtime_session: RuntimeSessionId,
pub network: Network,
pub policy: PolicySnapshot,
}
impl EngineConfig {
#[must_use]
pub const fn new(
runtime_session: RuntimeSessionId,
network: Network,
policy: PolicySnapshot,
) -> Self {
Self {
runtime_session,
network,
policy,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct EngineSnapshot {
pub schema_version: u16,
pub runtime_session: [u8; 16],
pub runtime_generation: u64,
pub event_sequence: u64,
pub network: Network,
pub authority_state: AuthorityState,
pub policy: PolicySnapshot,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ObservabilityRuntime {
pub registry_fingerprint: [u8; 32],
pub protocol_version: u16,
pub provider_readiness: ProviderReadiness,
pub rate_limits: RateLimitState,
pub namespace_outcome: Option<OutcomeKind>,
pub hns_root_failure: Option<RootFailureKind>,
pub icann_root_failure: Option<RootFailureKind>,
pub selected_namespace: Option<Namespace>,
pub selection_reason: Option<SelectionReason>,
pub decision_fingerprint: Option<[u8; 32]>,
pub icann_tls_action: Option<IcannTlsAction>,
pub icann_dnssec_status: Option<IcannDnssecStatus>,
pub icann_evidence: Option<ValidationEvidence>,
pub degraded_reason: Option<DegradedReason>,
pub revocation_reason: Option<RevocationReason>,
pub unsupported_evidence: Vec<UnsupportedEvidence>,
}
impl ObservabilityRuntime {
#[must_use]
pub fn for_policy(policy: PolicySnapshot) -> Self {
Self {
registry_fingerprint: [0; 32],
protocol_version: 0,
provider_readiness: ProviderReadiness::from_policy(policy),
rate_limits: RateLimitState::default(),
namespace_outcome: None,
hns_root_failure: None,
icann_root_failure: None,
selected_namespace: None,
selection_reason: None,
decision_fingerprint: None,
icann_tls_action: None,
icann_dnssec_status: None,
icann_evidence: None,
degraded_reason: None,
revocation_reason: None,
unsupported_evidence: Vec::new(),
}
}
}
impl Default for ObservabilityRuntime {
fn default() -> Self {
Self::for_policy(PolicySnapshot::default())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolutionAttempt {
runtime_stamp: RuntimeStamp,
admission: Admission,
query: Query,
}
impl ResolutionAttempt {
#[must_use]
pub const fn runtime_session(&self) -> [u8; 16] {
self.runtime_stamp.session()
}
#[must_use]
pub const fn runtime_generation(&self) -> u64 {
self.runtime_stamp.generation()
}
#[must_use]
pub const fn policy_generation(&self) -> u64 {
self.admission.policy_generation
}
#[must_use]
pub const fn transport(&self) -> ResolutionTransport {
self.admission.transport
}
#[must_use]
pub const fn query(&self) -> &Query {
&self.query
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParsedResponse {
attempt_stamp: RuntimeStamp,
message: Message,
untrusted_ad_claim: bool,
}
impl ParsedResponse {
#[must_use]
pub const fn message(&self) -> &Message {
&self.message
}
#[must_use]
pub const fn untrusted_ad_claim(&self) -> bool {
self.untrusted_ad_claim
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GatewayResolution {
attempt: ResolutionAttempt,
response: ParsedResponse,
context: CompletionContext,
}
impl GatewayResolution {
#[must_use]
pub const fn attempt(&self) -> &ResolutionAttempt {
&self.attempt
}
#[must_use]
pub const fn response(&self) -> &ParsedResponse {
&self.response
}
#[must_use]
pub const fn context(&self) -> &CompletionContext {
&self.context
}
#[must_use]
pub fn into_parts(self) -> (ResolutionAttempt, ParsedResponse, CompletionContext) {
(self.attempt, self.response, self.context)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct CompletionContext {
pub chain_anchor: Option<ChainAnchor>,
pub peer_identity: Option<String>,
pub proxy_identity: Option<String>,
pub target_identity: Option<String>,
pub direct_relay_fallback: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LocalDanePrerequisites {
pub hns_proof: EvidenceState,
pub dnssec: EvidenceState,
pub chain_current: EvidenceState,
pub origin_sni: EvidenceState,
}
#[derive(Clone, Copy, Debug)]
pub struct ValidatedDaneInput<'a> {
pub validated: &'a ValidatedTlsa,
pub certificate_chain_der: &'a [&'a [u8]],
pub origin_sni: &'a str,
pub validation_unix_time: i64,
pub limits: DaneLimits,
}
impl LocalDanePrerequisites {
const fn fully_verified(self) -> bool {
matches!(self.hns_proof, EvidenceState::Verified)
&& matches!(self.dnssec, EvidenceState::Verified)
&& matches!(self.chain_current, EvidenceState::Verified)
&& matches!(self.origin_sni, EvidenceState::Verified)
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct DaneCompletion {
provenance: ResolutionProvenance,
dane_match: DaneMatch,
origin_sni: Option<String>,
bridge_valid_from: Option<u64>,
bridge_valid_until: Option<u64>,
}
impl DaneCompletion {
#[must_use]
pub const fn provenance(&self) -> &ResolutionProvenance {
&self.provenance
}
#[must_use]
pub const fn dane_match(&self) -> DaneMatch {
self.dane_match
}
#[must_use]
pub fn origin_sni(&self) -> Option<&str> {
self.origin_sni.as_deref()
}
}
impl fmt::Debug for DaneCompletion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DaneCompletion")
.field("provenance", &self.provenance)
.field("dane_match", &self.dane_match)
.field(
"origin_sni",
&self.origin_sni.as_ref().map(|_| "[redacted]"),
)
.field("bridge_valid_from", &self.bridge_valid_from)
.field("bridge_valid_until", &self.bridge_valid_until)
.finish()
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct BrowserBridgeAuthorization {
runtime_session: [u8; 16],
runtime_generation: u64,
policy_generation: u64,
event_sequence: u64,
valid_from: u64,
valid_until: u64,
origin: String,
}
impl BrowserBridgeAuthorization {
#[must_use]
pub const fn runtime_session(&self) -> [u8; 16] {
self.runtime_session
}
#[must_use]
pub const fn runtime_generation(&self) -> u64 {
self.runtime_generation
}
#[must_use]
pub const fn policy_generation(&self) -> u64 {
self.policy_generation
}
#[must_use]
pub const fn event_sequence(&self) -> u64 {
self.event_sequence
}
#[must_use]
pub const fn valid_from(&self) -> u64 {
self.valid_from
}
#[must_use]
pub const fn valid_until(&self) -> u64 {
self.valid_until
}
#[must_use]
pub fn origin(&self) -> &str {
&self.origin
}
}
impl fmt::Debug for BrowserBridgeAuthorization {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BrowserBridgeAuthorization")
.field("runtime_session", &self.runtime_session)
.field("runtime_generation", &self.runtime_generation)
.field("policy_generation", &self.policy_generation)
.field("event_sequence", &self.event_sequence)
.field("valid_from", &self.valid_from)
.field("valid_until", &self.valid_until)
.field("origin", &"[redacted]")
.finish()
}
}
#[derive(Debug)]
struct EngineState {
runtime: BrowserRuntime,
network: Network,
policy: PolicyController,
last_provenance: Option<ResolutionProvenance>,
last_evidence: ValidationEvidence,
}
#[derive(Debug)]
pub struct Engine {
state: RwLock<EngineState>,
}
impl Engine {
#[must_use]
pub fn new(config: EngineConfig) -> Self {
Self {
state: RwLock::new(EngineState {
runtime: BrowserRuntime::new(config.runtime_session),
network: config.network,
policy: PolicyController::new(config.policy),
last_provenance: None,
last_evidence: ValidationEvidence::not_attempted(),
}),
}
}
pub fn from_persisted(
runtime_session: [u8; 16],
network: Network,
policy: &[u8],
) -> Result<Self, EngineError> {
let runtime_session = RuntimeSessionId::new(runtime_session).map_err(map_runtime_error)?;
let policy = PolicySnapshot::decode(policy)?;
Ok(Self::new(EngineConfig {
runtime_session,
network,
policy,
}))
}
pub fn snapshot(&self) -> Result<EngineSnapshot, EngineError> {
let state = self.state.read().map_err(|_| EngineError::LockPoisoned)?;
let runtime = state.runtime.snapshot();
Ok(EngineSnapshot {
schema_version: runtime.schema_version(),
runtime_session: runtime.session_bytes(),
runtime_generation: runtime.generation(),
event_sequence: runtime.event_sequence(),
network: state.network,
authority_state: runtime.authority_state(),
policy: state.policy.snapshot(),
})
}
pub fn export_policy(&self) -> Result<[u8; 32], EngineError> {
Ok(self.snapshot()?.policy.encode())
}
pub fn transport_plan(&self) -> Result<TransportPlan, EngineError> {
let state = self.state.read().map_err(|_| EngineError::LockPoisoned)?;
Ok(state.policy.transport_plan())
}
pub fn begin_gateway(&self, limits: GatewayLimits) -> Result<Gateway, EngineError> {
let state = self.state.read().map_err(|_| EngineError::LockPoisoned)?;
Gateway::new(state.policy.snapshot(), limits).map_err(EngineError::Gateway)
}
pub fn observability_status(
&self,
runtime: ObservabilityRuntime,
) -> Result<BrowserStatus, EngineError> {
let state = self.state.read().map_err(|_| EngineError::LockPoisoned)?;
let runtime_snapshot = state.runtime.snapshot();
let provenance = state.last_provenance.as_ref();
let selected_icann = runtime.selected_namespace == Some(Namespace::Icann);
let failed_icann = runtime.icann_root_failure.is_some();
let icann_context = selected_icann || failed_icann;
let classification_failed =
runtime.hns_root_failure.is_some() || runtime.icann_root_failure.is_some();
let neither = runtime.namespace_outcome == Some(OutcomeKind::Neither);
let (chain_anchor, actual_transport, identities, evidence) = if icann_context {
let evidence = runtime
.icann_evidence
.ok_or(EngineError::MissingIcannEvidence)?;
(
None,
ResolutionTransport::ValidatingIcannDoh,
TransportIdentities::default(),
evidence,
)
} else if neither || classification_failed {
if runtime.icann_evidence.is_some() {
return Err(EngineError::UnexpectedIcannEvidence);
}
(
None,
ResolutionTransport::Unavailable,
TransportIdentities::default(),
ValidationEvidence::not_attempted(),
)
} else {
if runtime.icann_evidence.is_some() {
return Err(EngineError::UnexpectedIcannEvidence);
}
let identities = provenance.map_or_else(TransportIdentities::default, |provenance| {
TransportIdentities {
peer: provenance.peer_identity.clone(),
proxy: provenance.proxy_identity.clone(),
target: provenance.target_identity.clone(),
direct_relay_fallback: provenance.direct_relay_fallback,
}
});
(
provenance.and_then(|provenance| provenance.chain_anchor),
provenance.map_or(ResolutionTransport::Unavailable, |provenance| {
provenance.transport
}),
identities,
provenance.map_or(state.last_evidence, |provenance| provenance.evidence),
)
};
let experimental_p2p = matches!(
actual_transport,
ResolutionTransport::HandshakeP2pOdoh | ResolutionTransport::HandshakeP2pDnsRelay
);
BrowserStatus::new(StatusInput {
runtime: runtime_snapshot,
network: state.network,
policy: state.policy.snapshot(),
chain_anchor,
actual_transport,
identities,
registry_profile: state.policy.snapshot().config().wire_profile,
registry_fingerprint: if experimental_p2p {
runtime.registry_fingerprint
} else {
[0; 32]
},
protocol_version: if experimental_p2p {
runtime.protocol_version
} else {
0
},
provider_readiness: runtime.provider_readiness,
rate_limits: runtime.rate_limits,
evidence,
namespace_outcome: runtime.namespace_outcome,
hns_root_failure: runtime.hns_root_failure,
icann_root_failure: runtime.icann_root_failure,
selected_namespace: runtime.selected_namespace,
selection_reason: runtime.selection_reason,
decision_fingerprint: runtime.decision_fingerprint,
icann_tls_action: runtime.icann_tls_action,
icann_dnssec_status: runtime.icann_dnssec_status,
degraded_reason: runtime.degraded_reason,
revocation_reason: runtime.revocation_reason,
unsupported_evidence: runtime.unsupported_evidence,
})
.map_err(EngineError::Status)
}
pub fn update_policy(
&self,
expected_policy_generation: u64,
next: PolicyConfig,
) -> Result<PolicyTransition, EngineError> {
let mut state = self.state.write().map_err(|_| EngineError::LockPoisoned)?;
let changed = state.policy.snapshot().config() != next;
if changed {
state
.runtime
.ensure_policy_change_capacity()
.map_err(map_runtime_error)?;
}
let transition = state.policy.replace(expected_policy_generation, next)?;
if transition.changed {
state.runtime.policy_changed().map_err(map_runtime_error)?;
state.last_provenance = None;
state.last_evidence = ValidationEvidence::revoked();
}
Ok(transition)
}
pub fn update_policy_blob(
&self,
expected_policy_generation: u64,
blob: &[u8],
) -> Result<PolicyTransition, EngineError> {
let decoded = PolicySnapshot::decode(blob)?;
if decoded.generation() != expected_policy_generation {
return Err(EngineError::Policy(PolicyError::StaleGeneration));
}
self.update_policy(expected_policy_generation, decoded.config())
}
pub fn advance_authority_state(
&self,
next: AuthorityState,
) -> Result<EngineSnapshot, EngineError> {
let mut state = self.state.write().map_err(|_| EngineError::LockPoisoned)?;
state.runtime.transition(next).map_err(map_runtime_error)?;
match next {
AuthorityState::Degraded => {
state.last_provenance = None;
state.last_evidence = unavailable_evidence();
}
AuthorityState::Revoked | AuthorityState::Stopped => {
state.last_provenance = None;
state.last_evidence = ValidationEvidence::revoked();
}
_ => {}
}
drop(state);
self.snapshot()
}
pub fn admit_resolution(
&self,
transport: ResolutionTransport,
query: Query,
) -> Result<ResolutionAttempt, EngineError> {
let mut state = self.state.write().map_err(|_| EngineError::LockPoisoned)?;
if !resolution_transport_ready(state.runtime.authority_state()) {
return Err(EngineError::AuthorityNotReady);
}
let admission = state.policy.admit(transport)?;
let runtime_stamp = state.runtime.admit_event().map_err(map_runtime_error)?;
Ok(ResolutionAttempt {
runtime_stamp,
admission,
query,
})
}
pub fn parse_response(
&self,
attempt: &ResolutionAttempt,
response: &[u8],
limits: ParseLimits,
) -> Result<ParsedResponse, EngineError> {
let state = self.state.read().map_err(|_| EngineError::LockPoisoned)?;
ensure_current(&state, attempt)?;
let message = Message::parse_with_limits(response, limits)?;
let correlated = attempt.query.correlate(&message)?;
let untrusted_ad_claim = correlated.untrusted_ad_claim();
drop(state);
Ok(ParsedResponse {
attempt_stamp: attempt.runtime_stamp,
message,
untrusted_ad_claim,
})
}
pub fn admit_gateway_selection(
&self,
selection: GatewaySelection,
query: Query,
limits: ParseLimits,
) -> Result<GatewayResolution, EngineError> {
let (policy_generation, transport, response, identities, direct_relay_fallback) =
selection.into_parts();
let message = Message::parse_with_limits(&response, limits)?;
let correlated = query.correlate(&message)?;
let untrusted_ad_claim = correlated.untrusted_ad_claim();
let context = CompletionContext {
chain_anchor: None,
peer_identity: identities.peer,
proxy_identity: identities.proxy,
target_identity: identities.target,
direct_relay_fallback,
};
validate_completion_context(transport, &context)?;
let mut state = self.state.write().map_err(|_| EngineError::LockPoisoned)?;
if state.policy.snapshot().generation() != policy_generation {
return Err(EngineError::StaleGatewaySelection);
}
if !resolution_transport_ready(state.runtime.authority_state()) {
return Err(EngineError::AuthorityNotReady);
}
let admission = state.policy.admit(transport)?;
let runtime_stamp = state.runtime.admit_event().map_err(map_runtime_error)?;
drop(state);
Ok(GatewayResolution {
response: ParsedResponse {
attempt_stamp: runtime_stamp,
message,
untrusted_ad_claim,
},
attempt: ResolutionAttempt {
runtime_stamp,
admission,
query,
},
context,
})
}
pub fn complete_resolution_with_local_dane(
&self,
attempt: &ResolutionAttempt,
response: &ParsedResponse,
prerequisites: LocalDanePrerequisites,
certificate_der: &[u8],
limits: DaneLimits,
context: CompletionContext,
) -> Result<DaneCompletion, EngineError> {
if response.attempt_stamp != attempt.runtime_stamp {
return Err(EngineError::ResponseAttemptMismatch);
}
if response.message.header.flags.rcode() != 0 {
return Err(EngineError::UnsuccessfulDnsResponse);
}
if attempt.query.question.record_type != RecordType::Tlsa
|| attempt.query.question.class != hns_dns_wire::CLASS_IN
{
return Err(EngineError::ExpectedTlsaQuery);
}
if !prerequisites.fully_verified() {
return Err(EngineError::Policy(PolicyError::UnverifiedEvidence));
}
let records = exact_tlsa_answers(attempt, response);
let dane_match = verify_dane_ee(certificate_der, &records, limits)?;
let evidence = ValidationEvidence {
hns_proof: prerequisites.hns_proof,
dnssec: prerequisites.dnssec,
tlsa: EvidenceState::Verified,
dane: EvidenceState::Verified,
chain_current: prerequisites.chain_current,
origin_sni: prerequisites.origin_sni,
};
let provenance = self.complete_resolution(attempt, response, evidence, context)?;
Ok(DaneCompletion {
provenance,
dane_match,
origin_sni: None,
bridge_valid_from: None,
bridge_valid_until: None,
})
}
pub fn complete_resolution_with_validated_tlsa(
&self,
attempt: &ResolutionAttempt,
response: &ParsedResponse,
input: ValidatedDaneInput<'_>,
mut context: CompletionContext,
) -> Result<DaneCompletion, EngineError> {
if response.attempt_stamp != attempt.runtime_stamp {
return Err(EngineError::ResponseAttemptMismatch);
}
if response.message.header.flags.rcode() != 0 {
return Err(EngineError::UnsuccessfulDnsResponse);
}
if attempt.query.question.record_type != RecordType::Tlsa
|| attempt.query.question.class != hns_dns_wire::CLASS_IN
|| attempt.query.question.name != *input.validated.terminal_owner()
{
return Err(EngineError::ExpectedTlsaQuery);
}
let hns_authority = input
.validated
.hns_authority()
.ok_or(EngineError::MissingHnsAuthority)?;
if input.validation_unix_time != i64::from(hns_authority.validation_time()) {
return Err(EngineError::ValidationTimeMismatch);
}
if network_id(self.snapshot()?.network) != hns_authority.anchor().network().id() {
return Err(EngineError::HnsNetworkMismatch);
}
if input.origin_sni != input.validated.base_domain_ascii() {
return Err(EngineError::OriginSniMismatch);
}
let response_records = exact_tlsa_answers(attempt, response);
if response_records != input.validated.records() {
return Err(EngineError::ResponseEvidenceMismatch);
}
let dane_match = verify_dane_chain(
input.certificate_chain_der,
input.validated.base_domain_ascii(),
input.validated.records(),
input.validation_unix_time,
input.limits,
)?;
let derived_anchor = ChainAnchor {
height: hns_authority.anchor().height().get(),
tree_root: hns_authority.anchor().tree_root().into_bytes(),
};
if context
.chain_anchor
.is_some_and(|provided| provided != derived_anchor)
{
return Err(EngineError::ChainAnchorMismatch);
}
context.chain_anchor = Some(derived_anchor);
let evidence = ValidationEvidence {
hns_proof: EvidenceState::Verified,
dnssec: EvidenceState::Verified,
tlsa: EvidenceState::Verified,
dane: EvidenceState::Verified,
chain_current: EvidenceState::Verified,
origin_sni: EvidenceState::Verified,
};
let provenance = self.complete_resolution(attempt, response, evidence, context)?;
Ok(DaneCompletion {
provenance,
dane_match,
origin_sni: Some(input.origin_sni.trim_end_matches('.').to_ascii_lowercase()),
bridge_valid_from: Some(hns_authority.anchor().validated_at().get()),
bridge_valid_until: Some(hns_authority.anchor().valid_until().get()),
})
}
pub fn authorize_browser_bridge(
&self,
completion: &DaneCompletion,
now: u64,
) -> Result<BrowserBridgeAuthorization, EngineError> {
let origin = completion
.origin_sni
.as_ref()
.ok_or(EngineError::LegacyCompletionNotBridgeable)?;
let valid_from = completion
.bridge_valid_from
.ok_or(EngineError::LegacyCompletionNotBridgeable)?;
let valid_until = completion
.bridge_valid_until
.ok_or(EngineError::LegacyCompletionNotBridgeable)?;
if now < valid_from {
return Err(EngineError::CompletionNotYetValid);
}
if now > valid_until {
return Err(EngineError::CompletionExpired);
}
let mut state = self.state.write().map_err(|_| EngineError::LockPoisoned)?;
let runtime_before = state.runtime.snapshot();
if !matches!(
runtime_before.authority_state(),
AuthorityState::DaneOriginVerified
| AuthorityState::BrowserBridgeReady
| AuthorityState::Active
) {
return Err(EngineError::AuthorityNotReady);
}
if state.last_provenance.as_ref() != Some(&completion.provenance)
|| completion.provenance.runtime_session != runtime_before.session_bytes()
|| completion.provenance.runtime_generation != runtime_before.generation()
|| completion.provenance.policy_generation != state.policy.snapshot().generation()
|| !completion.provenance.evidence.fully_verified()
{
return Err(EngineError::CompletionNotCurrent);
}
if runtime_before.authority_state() == AuthorityState::DaneOriginVerified {
state
.runtime
.transition(AuthorityState::BrowserBridgeReady)
.map_err(map_runtime_error)?;
} else {
state.runtime.admit_event().map_err(map_runtime_error)?;
}
let runtime = state.runtime.snapshot();
Ok(BrowserBridgeAuthorization {
runtime_session: runtime.session_bytes(),
runtime_generation: runtime.generation(),
policy_generation: state.policy.snapshot().generation(),
event_sequence: runtime.event_sequence(),
valid_from,
valid_until,
origin: origin.clone(),
})
}
fn complete_resolution(
&self,
attempt: &ResolutionAttempt,
response: &ParsedResponse,
evidence: ValidationEvidence,
context: CompletionContext,
) -> Result<ResolutionProvenance, EngineError> {
validate_completion_context(attempt.transport(), &context)?;
let mut state = self.state.write().map_err(|_| EngineError::LockPoisoned)?;
ensure_current(&state, attempt)?;
if !matches!(
state.runtime.authority_state(),
AuthorityState::DnssecVerified
| AuthorityState::DaneOriginVerified
| AuthorityState::BrowserBridgeReady
| AuthorityState::Active
) {
return Err(EngineError::AuthorityNotReady);
}
if state.runtime.authority_state() == AuthorityState::DnssecVerified {
state
.runtime
.transition(AuthorityState::DaneOriginVerified)
.map_err(map_runtime_error)?;
} else {
state.runtime.admit_event().map_err(map_runtime_error)?;
}
let runtime = state.runtime.snapshot();
let provenance = ResolutionProvenance {
schema_version: 1,
runtime_session: runtime.session_bytes(),
runtime_generation: runtime.generation(),
policy_generation: attempt.admission.policy_generation,
event_sequence: runtime.event_sequence(),
network: state.network,
chain_anchor: context.chain_anchor,
transport: attempt.admission.transport,
peer_identity: context.peer_identity,
proxy_identity: context.proxy_identity,
target_identity: context.target_identity,
direct_relay_fallback: context.direct_relay_fallback,
registry_profile: state.policy.snapshot().config().wire_profile,
evidence,
untrusted_ad_claim: response.untrusted_ad_claim,
};
provenance.require_verified_hns_https()?;
state.last_provenance = Some(provenance.clone());
state.last_evidence = provenance.evidence;
Ok(provenance)
}
}
fn exact_tlsa_answers(attempt: &ResolutionAttempt, response: &ParsedResponse) -> Vec<Tlsa> {
response
.message
.answers
.iter()
.filter(|record| {
record.name == attempt.query.question.name
&& record.record_type == RecordType::Tlsa
&& record.class == attempt.query.question.class
})
.filter_map(|record| match &record.rdata {
Rdata::Tlsa(tlsa) => Some(tlsa.clone()),
_ => None,
})
.collect()
}
const fn network_id(network: Network) -> u8 {
match network {
Network::Mainnet => 0,
Network::Testnet => 1,
Network::Regtest => 2,
Network::Simnet => 3,
}
}
const fn unavailable_evidence() -> ValidationEvidence {
ValidationEvidence {
hns_proof: EvidenceState::Unavailable,
dnssec: EvidenceState::Unavailable,
tlsa: EvidenceState::Unavailable,
dane: EvidenceState::Unavailable,
chain_current: EvidenceState::Unavailable,
origin_sni: EvidenceState::Unavailable,
}
}
fn ensure_current(state: &EngineState, attempt: &ResolutionAttempt) -> Result<(), EngineError> {
if !state.runtime.admits(attempt.runtime_stamp) {
return Err(EngineError::StaleRuntimeGeneration);
}
state.policy.accept_completion(attempt.admission)?;
Ok(())
}
const fn resolution_transport_ready(state: AuthorityState) -> bool {
matches!(
state,
AuthorityState::ResolutionTransportReady
| AuthorityState::DnssecVerified
| AuthorityState::DaneOriginVerified
| AuthorityState::BrowserBridgeReady
| AuthorityState::Active
)
}
const fn map_runtime_error(error: RuntimeError) -> EngineError {
match error {
RuntimeError::ZeroSession => EngineError::InvalidRuntimeSession,
RuntimeError::InvalidAuthorityTransition => EngineError::InvalidAuthorityTransition,
RuntimeError::CounterExhausted => EngineError::GenerationExhausted,
RuntimeError::Stopped | RuntimeError::AuthorityNotReady => EngineError::AuthorityNotReady,
}
}
fn validate_completion_context(
transport: ResolutionTransport,
context: &CompletionContext,
) -> Result<(), EngineError> {
for identity in [
context.peer_identity.as_deref(),
context.proxy_identity.as_deref(),
context.target_identity.as_deref(),
]
.into_iter()
.flatten()
{
if identity.is_empty() || identity.len() > MAX_TRANSPORT_IDENTITY_BYTES {
return Err(EngineError::InvalidTransportIdentity);
}
}
match transport {
ResolutionTransport::HandshakeP2pOdoh => {
let proxy = context
.proxy_identity
.as_deref()
.ok_or(EngineError::MissingTransportIdentity)?;
let target = context
.target_identity
.as_deref()
.ok_or(EngineError::MissingTransportIdentity)?;
if proxy == target {
return Err(EngineError::ProxyTargetNotSeparated);
}
if context.peer_identity.is_some() || context.direct_relay_fallback {
return Err(EngineError::InvalidCompletionContext);
}
}
ResolutionTransport::HandshakeP2pDnsRelay => {
if context.peer_identity.is_none() {
return Err(EngineError::MissingTransportIdentity);
}
if context.proxy_identity.is_some() || context.target_identity.is_some() {
return Err(EngineError::InvalidCompletionContext);
}
}
ResolutionTransport::DirectAuthoritativeUdp
| ResolutionTransport::DirectAuthoritativeTcp
| ResolutionTransport::AuthenticatedAuthoritativeDoh
| ResolutionTransport::UserConfiguredRecursiveHnsDoh => {
if context.peer_identity.is_some()
|| context.proxy_identity.is_some()
|| context.target_identity.is_some()
|| context.direct_relay_fallback
{
return Err(EngineError::InvalidCompletionContext);
}
}
ResolutionTransport::Unavailable
| ResolutionTransport::ValidatingIcannDoh
| ResolutionTransport::LocalHnsProof => {
return Err(EngineError::InvalidCompletionContext);
}
}
Ok(())
}
#[derive(Debug)]
#[non_exhaustive]
pub enum EngineError {
InvalidRuntimeSession,
LockPoisoned,
GenerationExhausted,
InvalidAuthorityTransition,
AuthorityNotReady,
StaleRuntimeGeneration,
StaleGatewaySelection,
ResponseAttemptMismatch,
UnsuccessfulDnsResponse,
ExpectedTlsaQuery,
MissingTransportIdentity,
InvalidTransportIdentity,
ProxyTargetNotSeparated,
InvalidCompletionContext,
ResponseEvidenceMismatch,
OriginSniMismatch,
MissingHnsAuthority,
ValidationTimeMismatch,
HnsNetworkMismatch,
ChainAnchorMismatch,
LegacyCompletionNotBridgeable,
CompletionNotCurrent,
CompletionExpired,
CompletionNotYetValid,
MissingIcannEvidence,
UnexpectedIcannEvidence,
Wire(hns_dns_wire::Error),
Dane(hns_dane::DaneError),
Policy(PolicyError),
Status(StatusError),
Gateway(hns_gateway::GatewayError),
}
impl fmt::Display for EngineError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRuntimeSession => formatter.write_str("runtime session must be nonzero"),
Self::LockPoisoned => formatter.write_str("engine state lock poisoned"),
Self::GenerationExhausted => formatter.write_str("engine generation exhausted"),
Self::InvalidAuthorityTransition => {
formatter.write_str("invalid browser authority state transition")
}
Self::AuthorityNotReady => formatter.write_str("browser authority state is not ready"),
Self::StaleRuntimeGeneration => formatter.write_str("stale runtime generation"),
Self::StaleGatewaySelection => {
formatter.write_str("gateway selection belongs to a stale policy generation")
}
Self::ResponseAttemptMismatch => {
formatter.write_str("DNS response and resolution attempt mismatch")
}
Self::UnsuccessfulDnsResponse => {
formatter.write_str("TLSA response has a nonzero DNS response code")
}
Self::ExpectedTlsaQuery => {
formatter.write_str("local DANE completion requires a class-IN TLSA query")
}
Self::MissingTransportIdentity => {
formatter.write_str("required transport identity is missing")
}
Self::InvalidTransportIdentity => {
formatter.write_str("transport identity is empty or too long")
}
Self::ProxyTargetNotSeparated => {
formatter.write_str("ODoH proxy and target identities are not distinct")
}
Self::InvalidCompletionContext => {
formatter.write_str("completion context conflicts with selected transport")
}
Self::ResponseEvidenceMismatch => {
formatter.write_str("validated TLSA evidence does not match terminal response")
}
Self::OriginSniMismatch => {
formatter.write_str("origin SNI does not match the TLSA base domain")
}
Self::MissingHnsAuthority => {
formatter.write_str("validated TLSA lacks on-chain HNS authority evidence")
}
Self::ValidationTimeMismatch => formatter
.write_str("DANE validation time does not match HNS DNSSEC validation time"),
Self::HnsNetworkMismatch => {
formatter.write_str("HNS authority evidence belongs to another network")
}
Self::ChainAnchorMismatch => {
formatter.write_str("completion context conflicts with derived HNS chain anchor")
}
Self::LegacyCompletionNotBridgeable => {
formatter.write_str("legacy DANE completion cannot authorize a browser bridge")
}
Self::CompletionNotCurrent => {
formatter.write_str("DANE completion is not current for this engine")
}
Self::CompletionExpired => {
formatter.write_str("DANE completion chain-currency window expired")
}
Self::CompletionNotYetValid => {
formatter.write_str("DANE completion chain-currency window has not begun")
}
Self::MissingIcannEvidence => formatter
.write_str("ICANN selection or lookup failure requires validation evidence"),
Self::UnexpectedIcannEvidence => {
formatter.write_str("ICANN evidence requires an ICANN selection or lookup failure")
}
Self::Wire(error) => write!(formatter, "DNS wire error: {error}"),
Self::Dane(error) => write!(formatter, "DANE error: {error}"),
Self::Policy(error) => write!(formatter, "policy error: {error}"),
Self::Status(error) => write!(formatter, "observability status error: {error}"),
Self::Gateway(error) => write!(formatter, "transport gateway error: {error}"),
}
}
}
impl std::error::Error for EngineError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Wire(error) => Some(error),
Self::Dane(error) => Some(error),
Self::Policy(error) => Some(error),
Self::Status(error) => Some(error),
Self::Gateway(error) => Some(error),
_ => None,
}
}
}
impl From<hns_dns_wire::Error> for EngineError {
fn from(value: hns_dns_wire::Error) -> Self {
Self::Wire(value)
}
}
impl From<hns_dane::DaneError> for EngineError {
fn from(value: hns_dane::DaneError) -> Self {
Self::Dane(value)
}
}
impl From<PolicyError> for EngineError {
fn from(value: PolicyError) -> Self {
Self::Policy(value)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
reason = "tests intentionally fail immediately on invalid fixtures"
)]
mod tests {
use super::*;
use hns_browser_testkit::{STRICT_HNS_ORIGIN, StrictRegtestDaneFixture};
use hns_dns_wire::{Flags, Header, Name, ResourceRecord};
use hns_resolution_policy::{DnsRelayRequesterPolicy, EvidenceState, ObliviousDnsPolicy};
const RESPONSE_WITH_UNTRUSTED_AD: &[u8] =
b"\x12\x34\x84\x20\x00\x01\x00\x01\x00\x00\x00\x00\x07example\x00\x00\x01\x00\x01\xc0\x0c\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04\x7f\x00\x00\x01";
fn ready_engine_in_session(runtime_session: [u8; 16], network: Network) -> Engine {
let engine = Engine::new(EngineConfig {
runtime_session: RuntimeSessionId::new(runtime_session).unwrap(),
network,
policy: PolicySnapshot::default(),
});
for state in [
AuthorityState::LocalStateOpened,
AuthorityState::HeaderSyncing,
AuthorityState::HeaderCurrent,
AuthorityState::ProofReady,
AuthorityState::ResolutionTransportReady,
AuthorityState::DnssecVerified,
] {
engine.advance_authority_state(state).unwrap();
}
engine
}
fn ready_engine_on(network: Network) -> Engine {
ready_engine_in_session([7; 16], network)
}
fn ready_engine() -> Engine {
ready_engine_on(Network::Mainnet)
}
fn active_engine_without_dane() -> Engine {
let engine = ready_engine_in_session([13; 16], Network::Mainnet);
engine
.advance_authority_state(AuthorityState::BrowserBridgeReady)
.unwrap();
engine
.advance_authority_state(AuthorityState::Active)
.unwrap();
engine
}
fn icann_observability(
action: IcannTlsAction,
evidence: Option<ValidationEvidence>,
) -> ObservabilityRuntime {
ObservabilityRuntime {
registry_fingerprint: [99; 32],
protocol_version: 99,
namespace_outcome: Some(OutcomeKind::IcannOnly),
selected_namespace: Some(Namespace::Icann),
selection_reason: Some(SelectionReason::SingleRoot),
decision_fingerprint: Some([13; 32]),
icann_tls_action: Some(action),
icann_dnssec_status: Some(if action == IcannTlsAction::WebPkiInsecureDelegation {
IcannDnssecStatus::InsecureDelegation
} else {
IcannDnssecStatus::Secure
}),
icann_evidence: evidence,
..ObservabilityRuntime::default()
}
}
fn failed_icann_observability(
failure: RootFailureKind,
dnssec_status: Option<IcannDnssecStatus>,
evidence: ValidationEvidence,
) -> ObservabilityRuntime {
ObservabilityRuntime {
registry_fingerprint: [99; 32],
protocol_version: 99,
icann_root_failure: Some(failure),
icann_tls_action: Some(IcannTlsAction::FailClosed),
icann_dnssec_status: dnssec_status,
icann_evidence: Some(evidence),
..ObservabilityRuntime::default()
}
}
fn decode_hex(input: &str) -> Vec<u8> {
let compact: Vec<u8> = input
.bytes()
.filter(|byte| !byte.is_ascii_whitespace())
.collect();
assert!(compact.len().is_multiple_of(2));
compact
.chunks_exact(2)
.map(|pair| {
let high = char::from(*pair.first().unwrap()).to_digit(16).unwrap();
let low = char::from(*pair.get(1).unwrap()).to_digit(16).unwrap();
u8::try_from((high << 4) | low).unwrap()
})
.collect()
}
fn certificate() -> Vec<u8> {
decode_hex(include_str!(
"../../../fixtures/dane/self-signed-cert.der.hex"
))
}
fn verified_prerequisites() -> LocalDanePrerequisites {
LocalDanePrerequisites {
hns_proof: EvidenceState::Verified,
dnssec: EvidenceState::Verified,
chain_current: EvidenceState::Verified,
origin_sni: EvidenceState::Verified,
}
}
fn tlsa_exchange(tlsa: Tlsa) -> (Query, Vec<u8>) {
let query = Query::new(
0x1234,
Name::from_ascii("_443._tcp.example").unwrap(),
RecordType::Tlsa,
)
.unwrap();
let response = Message {
header: Header {
id: query.id,
flags: Flags::from_bits(0x8420),
question_count: 1,
answer_count: 1,
authority_count: 0,
additional_count: 0,
},
questions: vec![query.question.clone()],
answers: vec![ResourceRecord {
name: query.question.name.clone(),
record_type: RecordType::Tlsa,
class: hns_dns_wire::CLASS_IN,
ttl: 300,
rdata: Rdata::Tlsa(tlsa),
}],
authorities: Vec::new(),
additionals: Vec::new(),
}
.encode(u16::MAX.into())
.unwrap();
(query, response)
}
fn exact_certificate_exchange() -> (Query, Vec<u8>, Vec<u8>) {
let certificate = certificate();
let (query, response) = tlsa_exchange(Tlsa {
usage: 3,
selector: 0,
matching_type: 0,
association_data: certificate.clone(),
});
(query, response, certificate)
}
fn odoh_gateway_selection(engine: &Engine, response: &[u8]) -> Option<GatewaySelection> {
let policy = engine.snapshot().unwrap().policy;
let mut gateway = engine.begin_gateway(GatewayLimits::default()).unwrap();
let mut now = 100_u64;
loop {
let attempt = gateway.next_attempt(policy, now).unwrap();
let outcome = if attempt.transport() == ResolutionTransport::HandshakeP2pOdoh {
hns_gateway::AttemptOutcome::Response {
bytes: response.to_owned(),
identities: hns_gateway::GatewayIdentities {
proxy: Some("brontide:proxy".to_owned()),
target: Some("brontide:target".to_owned()),
..hns_gateway::GatewayIdentities::default()
},
}
} else {
hns_gateway::AttemptOutcome::Failure(hns_gateway::TransportFailure::Unsupported)
};
match gateway.complete(policy, attempt, outcome, now).unwrap() {
hns_gateway::GatewayStep::RetryAvailable => {
now = now.checked_add(1).unwrap();
}
hns_gateway::GatewayStep::Selected(selection) => return Some(selection),
hns_gateway::GatewayStep::Unavailable => return None,
}
}
}
#[test]
fn correlates_then_derives_local_dane_evidence() {
let engine = ready_engine();
let (query, response, certificate) = exact_certificate_exchange();
let attempt = engine
.admit_resolution(ResolutionTransport::HandshakeP2pOdoh, query)
.unwrap();
let parsed = engine
.parse_response(&attempt, &response, ParseLimits::requester())
.unwrap();
let completed = engine
.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&certificate,
DaneLimits::default(),
CompletionContext {
proxy_identity: Some("proxy-peer".to_owned()),
target_identity: Some("target-peer".to_owned()),
..CompletionContext::default()
},
)
.unwrap();
assert!(completed.provenance().untrusted_ad_claim);
assert!(completed.provenance().evidence.fully_verified());
assert_eq!(completed.dane_match().record_index(), 0);
assert_eq!(completed.dane_match().selector() as u8, 0);
assert_eq!(completed.dane_match().matching_type() as u8, 0);
assert_eq!(completed.origin_sni(), None);
assert!(matches!(
engine.authorize_browser_bridge(&completed, 0),
Err(EngineError::LegacyCompletionNotBridgeable)
));
assert_eq!(
engine.snapshot().unwrap().authority_state,
AuthorityState::DaneOriginVerified
);
}
#[test]
fn rejects_attempt_replay_from_another_runtime_session() {
let first = ready_engine_in_session([7; 16], Network::Mainnet);
let second = ready_engine_in_session([8; 16], Network::Mainnet);
let (query, response, _) = exact_certificate_exchange();
let attempt = first
.admit_resolution(ResolutionTransport::DirectAuthoritativeTcp, query)
.unwrap();
assert_eq!(attempt.runtime_session(), [7; 16]);
assert!(
first
.parse_response(&attempt, &response, ParseLimits::requester())
.is_ok()
);
assert!(matches!(
second.parse_response(&attempt, &response, ParseLimits::requester()),
Err(EngineError::StaleRuntimeGeneration)
));
}
#[test]
#[allow(
clippy::too_many_lines,
reason = "the end-to-end test keeps DNSSEC, resolver, SNI, and DANE evidence in one flow"
)]
fn engine_consumes_local_dnssec_tlsa_and_rejects_sni_mismatch() {
let engine = ready_engine_on(Network::Regtest);
let fixture = StrictRegtestDaneFixture::new().unwrap();
let validation_time = fixture.validation_time();
let attempt = engine
.admit_resolution(
ResolutionTransport::DirectAuthoritativeTcp,
fixture.query().clone(),
)
.unwrap();
let parsed = engine
.parse_response(&attempt, fixture.response(), ParseLimits::requester())
.unwrap();
let validated = fixture.validate_response(parsed.message()).unwrap();
let chain = [fixture.certificate()];
assert!(matches!(
engine.complete_resolution_with_validated_tlsa(
&attempt,
&parsed,
ValidatedDaneInput {
validated: &validated,
certificate_chain_der: &chain,
origin_sni: "wrong.alpha",
validation_unix_time: i64::from(validation_time),
limits: DaneLimits::default(),
},
CompletionContext::default(),
),
Err(EngineError::OriginSniMismatch)
));
let completed = engine
.complete_resolution_with_validated_tlsa(
&attempt,
&parsed,
ValidatedDaneInput {
validated: &validated,
certificate_chain_der: &chain,
origin_sni: STRICT_HNS_ORIGIN,
validation_unix_time: i64::from(validation_time),
limits: DaneLimits::default(),
},
CompletionContext::default(),
)
.unwrap();
assert!(completed.provenance().evidence.fully_verified());
assert!(completed.provenance().untrusted_ad_claim);
assert_eq!(
completed.provenance().chain_anchor,
Some(ChainAnchor {
height: 1,
tree_root: fixture.authority().anchor().tree_root().into_bytes(),
})
);
assert_eq!(
completed.dane_match().usage(),
hns_dane::CertificateUsage::DaneEe
);
assert_eq!(completed.origin_sni(), Some(STRICT_HNS_ORIGIN));
assert!(matches!(
engine.authorize_browser_bridge(&completed, u64::from(validation_time - 1)),
Err(EngineError::CompletionNotYetValid)
));
let bridge = engine
.authorize_browser_bridge(&completed, u64::from(validation_time))
.unwrap();
assert_eq!(bridge.origin(), STRICT_HNS_ORIGIN);
assert_eq!(bridge.runtime_session(), [7; 16]);
assert_eq!(bridge.valid_from(), u64::from(validation_time));
assert!(bridge.valid_until() >= u64::from(validation_time));
assert!(matches!(
engine.authorize_browser_bridge(&completed, bridge.valid_until() + 1),
Err(EngineError::CompletionExpired)
));
assert_eq!(
engine.snapshot().unwrap().authority_state,
AuthorityState::BrowserBridgeReady
);
assert!(!format!("{bridge:?}").contains(STRICT_HNS_ORIGIN));
let status = engine
.observability_status(ObservabilityRuntime {
registry_fingerprint: [8; 32],
protocol_version: 1,
..ObservabilityRuntime::default()
})
.unwrap();
assert_eq!(
status.actual_transport(),
ResolutionTransport::DirectAuthoritativeTcp
);
assert_eq!(status.chain_anchor(), completed.provenance().chain_anchor);
assert!(status.evidence().fully_verified());
assert_eq!(status.registry_fingerprint(), [0; 32]);
assert_eq!(status.protocol_version(), 0);
}
#[test]
fn observability_requires_reasons_and_clears_verified_state_when_degraded() {
let engine = ready_engine();
let initial = engine
.observability_status(ObservabilityRuntime::default())
.unwrap();
assert_eq!(initial.actual_transport(), ResolutionTransport::Unavailable);
assert_eq!(initial.evidence(), ValidationEvidence::not_attempted());
engine
.advance_authority_state(AuthorityState::Degraded)
.unwrap();
assert!(matches!(
engine.observability_status(ObservabilityRuntime::default()),
Err(EngineError::Status(StatusError::MissingFailureReason))
));
let degraded = engine
.observability_status(ObservabilityRuntime {
degraded_reason: Some(DegradedReason::HeaderSyncUnavailable),
..ObservabilityRuntime::default()
})
.unwrap();
assert_eq!(
degraded.degraded_reason(),
Some(DegradedReason::HeaderSyncUnavailable)
);
assert_eq!(degraded.evidence().hns_proof, EvidenceState::Unavailable);
}
#[test]
fn observability_facade_reports_selected_icann_dane_and_webpki() {
let engine = active_engine_without_dane();
let mut dane = ValidationEvidence::not_attempted();
dane.dnssec = EvidenceState::Verified;
dane.tlsa = EvidenceState::Verified;
dane.dane = EvidenceState::Verified;
let status = engine
.observability_status(icann_observability(IcannTlsAction::EnforceDane, Some(dane)))
.unwrap();
assert_eq!(
status.actual_transport(),
ResolutionTransport::ValidatingIcannDoh
);
assert_eq!(status.chain_anchor(), None);
assert_eq!(status.identities(), &TransportIdentities::default());
assert_eq!(status.registry_fingerprint(), [0; 32]);
assert_eq!(status.protocol_version(), 0);
assert_eq!(status.evidence(), dane);
let mut authenticated_absence = ValidationEvidence::not_attempted();
authenticated_absence.dnssec = EvidenceState::Verified;
authenticated_absence.tlsa = EvidenceState::Unavailable;
let status = engine
.observability_status(icann_observability(
IcannTlsAction::WebPkiAuthenticatedAbsence,
Some(authenticated_absence),
))
.unwrap();
assert_eq!(
status.icann_tls_action(),
Some(IcannTlsAction::WebPkiAuthenticatedAbsence)
);
assert_eq!(status.evidence().dane, EvidenceState::NotAttempted);
let mut proven_insecure = authenticated_absence;
proven_insecure.dane = EvidenceState::Unavailable;
let status = engine
.observability_status(icann_observability(
IcannTlsAction::WebPkiInsecureDelegation,
Some(proven_insecure),
))
.unwrap();
assert_eq!(
status.icann_tls_action(),
Some(IcannTlsAction::WebPkiInsecureDelegation)
);
}
#[test]
fn observability_facade_keeps_bogus_and_indeterminate_icann_fail_closed() {
let engine = active_engine_without_dane();
let mut bogus = ValidationEvidence::not_attempted();
bogus.dnssec = EvidenceState::Failed;
let status = engine
.observability_status(failed_icann_observability(
RootFailureKind::BogusDnssec,
Some(IcannDnssecStatus::Bogus),
bogus,
))
.unwrap();
assert_eq!(status.namespace_outcome(), None);
assert_eq!(status.selected_namespace(), None);
assert_eq!(
status.icann_root_failure(),
Some(RootFailureKind::BogusDnssec)
);
assert_eq!(status.icann_tls_action(), Some(IcannTlsAction::FailClosed));
assert_eq!(status.evidence().dnssec, EvidenceState::Failed);
let mut missing_action = failed_icann_observability(
RootFailureKind::BogusDnssec,
Some(IcannDnssecStatus::Bogus),
bogus,
);
missing_action.icann_tls_action = None;
assert!(matches!(
engine.observability_status(missing_action),
Err(EngineError::Status(StatusError::InvalidIcannTlsContext))
));
let mut indeterminate = ValidationEvidence::not_attempted();
indeterminate.dnssec = EvidenceState::Unavailable;
indeterminate.tlsa = EvidenceState::Unavailable;
indeterminate.dane = EvidenceState::Unavailable;
let status = engine
.observability_status(failed_icann_observability(
RootFailureKind::IndeterminateDnssec,
Some(IcannDnssecStatus::Indeterminate),
indeterminate,
))
.unwrap();
assert_eq!(status.evidence().dnssec, EvidenceState::Unavailable);
assert_eq!(status.icann_tls_action(), Some(IcannTlsAction::FailClosed));
}
#[test]
fn observability_facade_requires_evidence_for_selected_or_failed_icann() {
let engine = active_engine_without_dane();
assert!(matches!(
engine.observability_status(icann_observability(
IcannTlsAction::WebPkiAuthenticatedAbsence,
None,
)),
Err(EngineError::MissingIcannEvidence)
));
let runtime = ObservabilityRuntime {
icann_evidence: Some(ValidationEvidence::not_attempted()),
..ObservabilityRuntime::default()
};
assert!(matches!(
engine.observability_status(runtime),
Err(EngineError::UnexpectedIcannEvidence)
));
let mut failed = failed_icann_observability(
RootFailureKind::BogusDnssec,
Some(IcannDnssecStatus::Bogus),
ValidationEvidence {
dnssec: EvidenceState::Failed,
..ValidationEvidence::not_attempted()
},
);
failed.icann_evidence = None;
assert!(matches!(
engine.observability_status(failed),
Err(EngineError::MissingIcannEvidence)
));
}
#[test]
fn neither_outcome_cannot_reuse_prior_hns_provenance() {
let engine = ready_engine();
let (query, response, certificate) = exact_certificate_exchange();
let attempt = engine
.admit_resolution(ResolutionTransport::DirectAuthoritativeTcp, query)
.unwrap();
let parsed = engine
.parse_response(&attempt, &response, ParseLimits::requester())
.unwrap();
engine
.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&certificate,
DaneLimits::default(),
CompletionContext::default(),
)
.unwrap();
let status = engine
.observability_status(ObservabilityRuntime {
namespace_outcome: Some(OutcomeKind::Neither),
decision_fingerprint: Some([21; 32]),
..ObservabilityRuntime::default()
})
.unwrap();
assert_eq!(status.actual_transport(), ResolutionTransport::Unavailable);
assert_eq!(status.chain_anchor(), None);
assert_eq!(status.identities(), &TransportIdentities::default());
assert_eq!(status.evidence(), ValidationEvidence::not_attempted());
}
#[test]
fn rejects_non_tlsa_queries_wrong_owner_and_certificate_mismatch() {
let engine = ready_engine();
let query =
Query::new(0x1234, Name::from_ascii("example").unwrap(), RecordType::A).unwrap();
let attempt = engine
.admit_resolution(ResolutionTransport::DirectAuthoritativeTcp, query)
.unwrap();
let parsed = engine
.parse_response(
&attempt,
RESPONSE_WITH_UNTRUSTED_AD,
ParseLimits::requester(),
)
.unwrap();
assert!(matches!(
engine.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&certificate(),
DaneLimits::default(),
CompletionContext::default(),
),
Err(EngineError::ExpectedTlsaQuery)
));
let certificate = certificate();
let (query, mut response) = tlsa_exchange(Tlsa {
usage: 3,
selector: 0,
matching_type: 0,
association_data: certificate.clone(),
});
*response.get_mut(3).unwrap() |= 3;
let attempt = engine
.admit_resolution(ResolutionTransport::DirectAuthoritativeTcp, query)
.unwrap();
let parsed = engine
.parse_response(&attempt, &response, ParseLimits::requester())
.unwrap();
assert!(matches!(
engine.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&certificate,
DaneLimits::default(),
CompletionContext::default(),
),
Err(EngineError::UnsuccessfulDnsResponse)
));
let (query, mut response) = tlsa_exchange(Tlsa {
usage: 3,
selector: 0,
matching_type: 0,
association_data: certificate.clone(),
});
let answer_owner_first_byte = 12 + query.question.name.wire_len() + 4 + 1;
*response.get_mut(answer_owner_first_byte).unwrap() = b'x';
let attempt = engine
.admit_resolution(ResolutionTransport::DirectAuthoritativeTcp, query)
.unwrap();
let parsed = engine
.parse_response(&attempt, &response, ParseLimits::requester())
.unwrap();
assert!(matches!(
engine.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&certificate,
DaneLimits::default(),
CompletionContext::default(),
),
Err(EngineError::Dane(hns_dane::DaneError::MissingTlsa))
));
let (query, response, mut wrong_certificate) = exact_certificate_exchange();
let last = wrong_certificate.len() - 1;
*wrong_certificate.get_mut(last).unwrap() ^= 1;
let attempt = engine
.admit_resolution(ResolutionTransport::DirectAuthoritativeTcp, query)
.unwrap();
let parsed = engine
.parse_response(&attempt, &response, ParseLimits::requester())
.unwrap();
assert!(matches!(
engine.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&wrong_certificate,
DaneLimits::default(),
CompletionContext::default(),
),
Err(EngineError::Dane(hns_dane::DaneError::Mismatch))
));
}
#[test]
fn policy_update_rejects_stale_response() {
let engine = ready_engine();
let query =
Query::new(0x1234, Name::from_ascii("example").unwrap(), RecordType::A).unwrap();
let attempt = engine
.admit_resolution(ResolutionTransport::HandshakeP2pDnsRelay, query)
.unwrap();
let mut policy = engine.snapshot().unwrap().policy.config();
policy.dns_relay_requester = DnsRelayRequesterPolicy::Disabled;
policy.oblivious_dns = ObliviousDnsPolicy::Required;
engine.update_policy(1, policy).unwrap();
assert!(matches!(
engine.parse_response(
&attempt,
RESPONSE_WITH_UNTRUSTED_AD,
ParseLimits::requester()
),
Err(EngineError::StaleRuntimeGeneration)
));
}
#[test]
fn engine_gateway_revokes_on_policy_generation_change() {
let engine = ready_engine();
let before = engine.snapshot().unwrap().policy;
let mut gateway = engine.begin_gateway(GatewayLimits::default()).unwrap();
let attempt = gateway.next_attempt(before, 100).unwrap();
assert_eq!(
attempt.transport(),
ResolutionTransport::DirectAuthoritativeUdp
);
let mut next = before.config();
next.authenticated_authoritative_doh = false;
engine.update_policy(before.generation(), next).unwrap();
let after = engine.snapshot().unwrap().policy;
assert!(matches!(
gateway.next_attempt(after, 101),
Err(hns_gateway::GatewayError::StalePolicy)
));
assert!(matches!(
gateway.next_attempt(before, 101),
Err(hns_gateway::GatewayError::Terminal)
));
}
#[test]
fn gateway_selection_atomically_binds_response_and_identities() {
let engine = ready_engine();
let (query, response, _) = exact_certificate_exchange();
let selection = odoh_gateway_selection(&engine, &response).unwrap();
let admitted = engine
.admit_gateway_selection(selection, query, ParseLimits::requester())
.unwrap();
assert_eq!(
admitted.attempt().transport(),
ResolutionTransport::HandshakeP2pOdoh
);
assert_eq!(
admitted.context().proxy_identity.as_deref(),
Some("brontide:proxy")
);
assert_eq!(
admitted.context().target_identity.as_deref(),
Some("brontide:target")
);
assert_eq!(admitted.response().message().header.id, 0x1234);
}
#[test]
fn stale_gateway_selection_consumes_no_engine_event() {
let engine = ready_engine();
let (query, response, _) = exact_certificate_exchange();
let selection = odoh_gateway_selection(&engine, &response).unwrap();
let before = engine.snapshot().unwrap();
let mut next = before.policy.config();
next.authenticated_authoritative_doh = !next.authenticated_authoritative_doh;
engine
.update_policy(before.policy.generation(), next)
.unwrap();
let after_update = engine.snapshot().unwrap();
assert!(matches!(
engine.admit_gateway_selection(selection, query, ParseLimits::requester()),
Err(EngineError::StaleGatewaySelection)
));
assert_eq!(
engine.snapshot().unwrap().event_sequence,
after_update.event_sequence
);
}
#[test]
fn odoh_completion_requires_distinct_bounded_identities() {
let engine = ready_engine();
let (query, response, certificate) = exact_certificate_exchange();
let attempt = engine
.admit_resolution(ResolutionTransport::HandshakeP2pOdoh, query)
.unwrap();
let parsed = engine
.parse_response(&attempt, &response, ParseLimits::requester())
.unwrap();
assert!(matches!(
engine.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&certificate,
DaneLimits::default(),
CompletionContext::default()
),
Err(EngineError::MissingTransportIdentity)
));
assert!(matches!(
engine.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&certificate,
DaneLimits::default(),
CompletionContext {
proxy_identity: Some("same-peer".to_owned()),
target_identity: Some("same-peer".to_owned()),
..CompletionContext::default()
}
),
Err(EngineError::ProxyTargetNotSeparated)
));
assert!(matches!(
engine.complete_resolution_with_local_dane(
&attempt,
&parsed,
verified_prerequisites(),
&certificate,
DaneLimits::default(),
CompletionContext {
proxy_identity: Some("p".repeat(MAX_TRANSPORT_IDENTITY_BYTES + 1)),
target_identity: Some("target".to_owned()),
..CompletionContext::default()
}
),
Err(EngineError::InvalidTransportIdentity)
));
}
#[test]
fn completion_identity_topology_matches_observability() {
assert!(matches!(
validate_completion_context(
ResolutionTransport::HandshakeP2pOdoh,
&CompletionContext {
peer_identity: Some("extra-peer".to_owned()),
proxy_identity: Some("proxy".to_owned()),
target_identity: Some("target".to_owned()),
..CompletionContext::default()
}
),
Err(EngineError::InvalidCompletionContext)
));
assert!(matches!(
validate_completion_context(
ResolutionTransport::HandshakeP2pDnsRelay,
&CompletionContext {
peer_identity: Some("relay".to_owned()),
proxy_identity: Some("extra-proxy".to_owned()),
..CompletionContext::default()
}
),
Err(EngineError::InvalidCompletionContext)
));
assert!(matches!(
validate_completion_context(
ResolutionTransport::DirectAuthoritativeTcp,
&CompletionContext {
peer_identity: Some("extra-peer".to_owned()),
..CompletionContext::default()
}
),
Err(EngineError::InvalidCompletionContext)
));
}
#[test]
fn persisted_policy_survives_restart() {
let engine = Engine::new(EngineConfig::new(
RuntimeSessionId::new([8; 16]).unwrap(),
Network::Mainnet,
PolicySnapshot::default(),
));
let mut policy = engine.snapshot().unwrap().policy.config();
policy.dns_relay_requester = DnsRelayRequesterPolicy::Disabled;
engine.update_policy(1, policy).unwrap();
let blob = engine.export_policy().unwrap();
let reopened = Engine::from_persisted([9; 16], Network::Testnet, &blob).unwrap();
assert_eq!(
reopened.snapshot().unwrap().policy,
engine.snapshot().unwrap().policy
);
assert!(
!reopened
.transport_plan()
.unwrap()
.contains(ResolutionTransport::HandshakeP2pDnsRelay)
);
}
#[test]
fn persisted_engine_rejects_zero_runtime_session() {
let policy = PolicySnapshot::default().encode();
assert!(matches!(
Engine::from_persisted([0; 16], Network::Mainnet, &policy),
Err(EngineError::InvalidRuntimeSession)
));
}
}