Skip to content
Draft
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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ native = [
"dep:clap",
]

# Proof-only dependency set for the AMT-01 Hickory feasibility gate.
hickory-proof = ["dep:hickory-proto", "dep:getrandom03"]

[dependencies]
serde = { version = "1.0", features = ["derive"] }
getrandom = "0.2"
hickory-proto = { version = "=0.25.2", default-features = false, features = ["std", "wasm-bindgen"], optional = true }
getrandom03 = { package = "getrandom", version = "0.3", features = ["wasm_js"], optional = true }

# WASM dependencies (optional)
wasm-bindgen = { version = "0.2", optional = true }
Expand Down
90 changes: 90 additions & 0 deletions docs/amt-01-hickory-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# AMT-01 Hickory proof

This branch is a proof artifact for the AMT-01 G0 gate. The production
`DriadResolver`, native resolver, and `JsDriad` exports are unchanged. The
candidate is compiled only with the `hickory-proof` feature.

## Behavior matrix

`tests/hickory_driad_proof.rs` exercises `hickory-proto` 0.25.2 against:

- IPv4 and IPv6 reverse-query names, TYPE260, IN class, transaction IDs, and
trailing-dot normalization;
- compressed answer owners plus IPv4, IPv6, and uncompressed type-3 relay data;
- multiple TYPE260 answers, invalid-record skipping, and lowest-precedence
selection;
- malformed RDATA and type-3 names, mismatched transaction IDs and questions,
and wrong answer owners and classes.

Run the proof and the existing native suite:

```bash
cargo test --test hickory_driad_proof --no-default-features \
--features native,hickory-proof
cargo test --lib --no-default-features --features native,hickory-proof
```

At the pinned baseline `810719fe`, the proof reports 4/4 passing and the
existing suite reports 121/121 passing.

## Dependency and native-build impact

The proof pins `hickory-proto` exactly to 0.25.2 with `default-features = false`
and enables only `std` and `wasm-bindgen`. It explicitly enables
`getrandom` 0.3's `wasm_js` feature, as required by the browser target.

On Rust 1.97.1, clean native release builds measured:

| Build | Normal dependency nodes | Wall time |
|---|---:|---:|
| `native` baseline | 59 | 51.755 s |
| `native,hickory-proof` | 132 | 68.648 s |
| Delta | +73 | +16.893 s (+32.6%) |

The package count is intentionally conservative: it counts unique rendered
normal-dependency tree lines and therefore includes build-visible proc macros.

## WASM measurement command

The candidate exports `hickory_proof_accepts` only when both `wasm` and
`hickory-proof` are enabled, preventing release LTO from eliminating Hickory
from the candidate measurement.

```bash
rm -rf target/amt01-baseline target/amt01-candidate
CARGO_TARGET_DIR=target/amt01-baseline cargo build --lib --release \
--target wasm32-unknown-unknown --no-default-features --features wasm
CARGO_TARGET_DIR=target/amt01-candidate cargo build --lib --release \
--target wasm32-unknown-unknown --no-default-features \
--features wasm,hickory-proof
wc -c target/amt01-{baseline,candidate}/wasm32-unknown-unknown/release/amt_protocol.wasm
gzip -9 -c target/amt01-baseline/wasm32-unknown-unknown/release/amt_protocol.wasm | wc -c
gzip -9 -c target/amt01-candidate/wasm32-unknown-unknown/release/amt_protocol.wasm | wc -c
```

The current proof environment could not install `rust-std` for
`wasm32-unknown-unknown`: `rustup target add wasm32-unknown-unknown` rolled back
with `error opening file for download: cleaning up cached downloads: No such
file or directory`. Raw and gzip WASM deltas therefore remain an explicit G0
blocker until the command above runs in CI or a working Rust target environment.

## Consumer inventory

The existing public boundary remains required:

- Rust: `DriadResolver` is re-exported from `src/lib.rs`; native
`src/native/resolver.rs` uses query construction and TYPE260/A/AAAA parsing.
- WASM/TypeScript: `JsDriad` retains `buildQuery`, `buildDnsQuery`,
`parseDnsResponse`, `buildDnsAQuery`, and `parseDnsAResponse`. The IWA loader,
`DriadDohResolver`, and `AMTGatewayManager` consume those names.
- Native bindings: FFI and JNI use the Rust query-name API. No binding is
changed by this proof.

No persisted DNS state exists. A later cleared implementation can replace the
private framing atomically behind these APIs without a dual parser.

## G0 disposition

**Blocked.** Native behavior feasibility is proven, but the dependency increase
is substantial and the required release-WASM raw/compressed delta is not yet
measured. This branch does not authorize or implement the production cutover.
106 changes: 106 additions & 0 deletions src/hickory_proof.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Feature-gated AMT-01 feasibility proof. This is not used by production DRIAD.

use hickory_proto::op::{Message, MessageType, Query, ResponseCode};
use hickory_proto::rr::{DNSClass, Name, RData, RecordType};
use std::net::IpAddr;

const AMTRELAY_TYPE: RecordType = RecordType::Unknown(260);

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProofRelay {
Ip(IpAddr),
DnsName(String),
}

pub fn build_query(qname: &str, transaction_id: u16) -> Option<Vec<u8>> {
let name = canonical_name(qname)?;
let mut message = Message::new();
message
.set_id(transaction_id)
.set_message_type(MessageType::Query)
.set_recursion_desired(true)
.add_query(Query::query(name, AMTRELAY_TYPE));
message.to_vec().ok()
}

pub fn select_relay(data: &[u8], transaction_id: u16, qname: &str) -> Option<ProofRelay> {
let expected_name = canonical_name(qname)?;
let message = Message::from_vec(data).ok()?;
if message.id() != transaction_id
|| message.message_type() != MessageType::Response
|| message.response_code() != ResponseCode::NoError
|| message.queries().len() != 1
{
return None;
}

let query = &message.queries()[0];
if query.name() != &expected_name
|| query.query_type() != AMTRELAY_TYPE
|| query.query_class() != DNSClass::IN
{
return None;
}

message
.answers()
.iter()
.filter(|answer| {
answer.name() == &expected_name
&& answer.dns_class() == DNSClass::IN
&& answer.record_type() == AMTRELAY_TYPE
})
.filter_map(|answer| match answer.data() {
RData::Unknown { rdata, .. } => {
parse_amtrelay(rdata.anything()).map(|(precedence, relay)| (precedence, relay))
}
_ => None,
})
.min_by_key(|(precedence, _)| *precedence)
.map(|(_, relay)| relay)
}

fn canonical_name(name: &str) -> Option<Name> {
Name::from_ascii(format!("{}.", name.trim_end_matches('.'))).ok()
}

fn parse_amtrelay(rdata: &[u8]) -> Option<(u8, ProofRelay)> {
let (&precedence, payload) = rdata.split_first()?;
let (&relay_type, relay) = payload.split_first()?;
let relay = match relay_type & 0x7f {
1 if relay.len() == 4 => {
ProofRelay::Ip(IpAddr::from([relay[0], relay[1], relay[2], relay[3]]))
}
2 if relay.len() == 16 => {
let mut octets = [0; 16];
octets.copy_from_slice(relay);
ProofRelay::Ip(IpAddr::from(octets))
}
3 => ProofRelay::DnsName(parse_uncompressed_name(relay)?),
_ => return None,
};
Some((precedence, relay))
}

fn parse_uncompressed_name(data: &[u8]) -> Option<String> {
let mut labels = Vec::new();
let mut offset = 0;
loop {
let len = *data.get(offset)? as usize;
offset += 1;
if len == 0 {
return (offset == data.len() && !labels.is_empty()).then(|| labels.join("."));
}
if len > 63 || offset + len > data.len() {
return None;
}
labels.push(std::str::from_utf8(&data[offset..offset + len]).ok()?);
offset += len;
}
}

#[cfg(feature = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hickory_proof_accepts(data: &[u8], transaction_id: u16, qname: &str) -> bool {
select_relay(data, transaction_id, qname).is_some()
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub mod gateway;
pub mod igmp;
pub mod mld;
pub mod driad;
#[cfg(feature = "hickory-proof")]
pub mod hickory_proof;
pub mod subscription;

#[cfg(feature = "wasm")]
Expand Down
140 changes: 140 additions & 0 deletions tests/hickory_driad_proof.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#![cfg(feature = "hickory-proof")]

use amt_protocol::hickory_proof::{build_query, select_relay, ProofRelay};
use std::net::{IpAddr, Ipv4Addr};

const QNAME: &str = "128.95.25.69.in-addr.arpa";
const TXID: u16 = 0x5a17;

fn response(question: &[u8], answers: &[(&[u8], u16, u16, &[u8])]) -> Vec<u8> {
let mut wire = question.to_vec();
wire[2] = 0x81;
wire[3] = 0x80;
wire[6..8].copy_from_slice(&(answers.len() as u16).to_be_bytes());
for (owner, rr_type, class, rdata) in answers {
wire.extend_from_slice(owner);
wire.extend_from_slice(&rr_type.to_be_bytes());
wire.extend_from_slice(&class.to_be_bytes());
wire.extend_from_slice(&60u32.to_be_bytes());
wire.extend_from_slice(&(rdata.len() as u16).to_be_bytes());
wire.extend_from_slice(rdata);
}
wire
}

fn query() -> Vec<u8> {
build_query(QNAME, TXID).expect("Hickory query")
}

#[test]
fn query_is_golden_for_ipv4_ipv6_and_trailing_dot() {
let v4 = query();
assert_eq!(&v4[..4], &[0x5a, 0x17, 0x01, 0x00]);
assert_eq!(&v4[v4.len() - 4..], &[0x01, 0x04, 0x00, 0x01]);
assert_eq!(
build_query(QNAME, TXID),
build_query(&format!("{QNAME}."), TXID)
);

let v6_name = "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa";
let v6 = build_query(v6_name, TXID).expect("IPv6 reverse query");
assert_eq!(&v6[v6.len() - 4..], &[0x01, 0x04, 0x00, 0x01]);
}

#[test]
fn parses_compressed_ipv4_ipv6_and_type3_answers() {
let q = query();
let owner = [0xc0, 0x0c];
let ipv4 = [10, 1, 192, 0, 2, 10];
let wire = response(&q, &[(&owner, 260, 1, &ipv4)]);
assert_eq!(
select_relay(&wire, TXID, QNAME),
Some(ProofRelay::Ip(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10))))
);

let mut ipv6 = vec![20, 2];
ipv6.extend_from_slice(
&"2001:db8::5"
.parse::<std::net::Ipv6Addr>()
.unwrap()
.octets(),
);
let wire = response(&q, &[(&owner, 260, 1, &ipv6)]);
assert_eq!(
select_relay(&wire, TXID, QNAME),
Some(ProofRelay::Ip("2001:db8::5".parse().unwrap()))
);

let hostname = [
30, 3, 5, b's', b'f', b'o', b'1', b'2', 5, b'b', b'c', b'a', b's', b't', 2, b'i', b'd', 0,
];
let wire = response(&q, &[(&owner, 260, 1, &hostname)]);
assert_eq!(
select_relay(&wire, TXID, QNAME),
Some(ProofRelay::DnsName("sfo12.bcast.id".into()))
);
}

#[test]
fn selects_lowest_precedence_and_skips_malformed_records() {
let q = query();
let owner = [0xc0, 0x0c];
let malformed = [1, 1, 192, 0, 2];
let lower_priority = [20, 1, 192, 0, 2, 20];
let preferred = [5, 1, 192, 0, 2, 5];
let wire = response(
&q,
&[
(&owner, 260, 1, &malformed),
(&owner, 260, 1, &lower_priority),
(&owner, 260, 1, &preferred),
],
);
assert_eq!(
select_relay(&wire, TXID, QNAME),
Some(ProofRelay::Ip("192.0.2.5".parse().unwrap()))
);
}

#[test]
fn rejects_wrong_transaction_question_owner_class_and_malformed_names() {
let q = query();
let owner = [0xc0, 0x0c];
let valid = [5, 1, 192, 0, 2, 5];
let wire = response(&q, &[(&owner, 260, 1, &valid)]);
assert_eq!(select_relay(&wire, TXID + 1, QNAME), None);
assert_eq!(select_relay(&wire, TXID, "wrong.example"), None);

let wrong_owner = [5, b'w', b'r', b'o', b'n', b'g', 0];
assert_eq!(
select_relay(
&response(&q, &[(&wrong_owner, 260, 1, &valid)]),
TXID,
QNAME
),
None
);
assert_eq!(
select_relay(&response(&q, &[(&owner, 260, 3, &valid)]), TXID, QNAME),
None
);

let compressed_type3 = [5, 3, 0xc0, 0x0c];
assert_eq!(
select_relay(
&response(&q, &[(&owner, 260, 1, &compressed_type3)]),
TXID,
QNAME
),
None
);
let trailing_garbage = [5, 3, 1, b'a', 0, 0];
assert_eq!(
select_relay(
&response(&q, &[(&owner, 260, 1, &trailing_garbage)]),
TXID,
QNAME
),
None
);
}