DoH wire format implementation, use in dnslink-fetch plugin

merge-requests/23/head
Michał "rysiek" Woźniak 2023-10-07 03:14:42 +00:00
rodzic 60dfb907cb
commit ef881eee9a
6 zmienionych plików z 2065 dodań i 101 usunięć

15
lib/COPYRIGHT.md 100644
Wyświetl plik

@ -0,0 +1,15 @@
# Copyright
Copyrights and licenses for files in this directory are as following:
## JS-IPFS Project
The following files are part of the [js-ipfs](https://github.com/ipfs/js-ipfs) project, and are licensed under the MIT license:
- `ipfs.js`
## GunDB Project
The following files are part of the [GunDB](https://github.com/amark/gun) project, and are licensed under the Apache license:
- `gun.js`
- `sea.js`
- `webrtc.js`

403
lib/doh.js 100644
Wyświetl plik

@ -0,0 +1,403 @@
//
// implementing some helper functions for DNS-over-HTTPS
//
/**
* retrieving the alternative endpoints list from DNS TXT records
* using a DNS-over-HTTPS binary DNS wire format endpoint
*
* returns an array of strings, each being a valid endpoint,
* in the form of:
* scheme://example.org[/optional/path]
*
* for an explanation of what the heck is going on here, see:
* https://courses.cs.duke.edu/fall16/compsci356/DNS/DNS-primer.pdf
*
* @param domain (string) domain name to resolve TXT records for
* @param config (object) config object with fields: dohProvider, dohMethod, dohMediaType
* @param log (function) logging function to use (optional)
*/
window.resolveEndpointsBinary = async (domain, config, log=()=>{} ) => {
// encoder and decoder
let enc = new TextEncoder();
let dec = new TextDecoder('utf8');
// then we need an Uint8Array for the whole thing
//
// how large? glad you asked!
// - header part is 12 bytes
// - question part is (domain.length + 2) + 4 bytes
//
// why? even more glad you asked!
//
// qname part is the part that will contain the name we are trying to resolve.
// this means all the labels, without the dots.
// each label will be preceeded by one-byte field containing the length of the label
// after all labels are included, a one-byte null terminator follows.
//
// so, for "_dnslink.example.org" (length: 20 bytes) we get:
// [len:8]_dnslink[len:7]example[len:3]org[null] (length: 22 bytes)
let qbuf = new Uint8Array(12 + domain.length + 6)
// let's build ourselves a header
qbuf.set([
// ID, 16-bit — request identifier
Math.floor(Math.random() * 256), Math.floor(Math.random() * 256),
// 16 bits of flags
//
//,-------------- 1-bit QR header field, 0 means "query"
//| ,------------ 4-bit OPCODE header field, we want 0 ("standard query")
//| | ,--------- 1-bit AA header field, only relevant in response (1 means the response is authoritative)
//| | |,-------- 1-bit TC header field, we want 0 signifying the message was not truncated
//| | ||,------- 1-bit RD header field, we want 1 signifying we want recursive resolution
//| | |||
//| | ||| ,------- 1-bit RA header field, only relevant in response (1 means recursion is available)
//| | ||| | ,----- 3-bit Z header field, reserved for future use, always 0
//| | ||| | | ,- 4-bit RCODE header field, to be set to 0, and inspected in response for error codes
//| |\ ||| | | /|
//|/ \||| |/ \/ \
0b00000001, 0b00000000,
// QDCOUNT, 16-bit — number of questions (we only have one)
0x00, 0x01,
// ANCOUNT, 16-bit — we are not providing answers, only asking questions
0x00, 0x00,
// NSCOUNT, 16-bit — we are not providing any nameserver info
0x00, 0x00,
// ARCOUNT, 16-bit — no additional records from our side!
0x00, 0x00
])
// let's build ourselves a QNAME!
let qbufpos = 12 // starting at byte 12, just as the header ends
domain
.split('.')
.forEach(
(label)=>{
// set the byte of the buffer at the current position to the length of the label
qbuf[qbufpos] = label.length
// set the encoded label starting from the following byte
qbuf.set(enc.encode(label), qbufpos + 1)
// increment the position by the length of the label plus 1 byte
qbufpos += label.length + 1
})
// in qbufpos at this point we have the position of the byte
// directly following the last letter in the last label
// we need a null byte there to signify end of QNAME
qbuf[qbufpos] = 0x00
qbufpos += 1
// let's finish it all off
qbuf.set([
// QTYPE, 160-bit — we want TXT records, which is 16 decimal
// https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-4
0x00, 0x10,
// QCLASS, 16-bit — we want Internet (IN)
// https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-2
0x00, 0x01
], qbufpos)
// request data
let request_data = {
url: config.dohProvider,
options: {
// TODO: we need to find a way to make "no-cors" requests
//mode: "no-cors",
method: config.dohMethod,
headers: {
accept: config.dohMediaType
}
}
}
// GET or POST?
// https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-wireformat/
if (config.dohMethod === "POST") {
// binary data in POST body, easy peasy
request_data.options.body = qbuf;
request_data.options.headers["content-length"] = qbuf.length.toString()
request_data.options.headers["content-type"] = config.dohMediaType
} else if (config.dohMethod === "GET") {
// we need base64-encoded data, since we're putting it the URL directly
// https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
request_data.url += "?dns=" + btoa(String.fromCodePoint(...qbuf));
} else {
// that's not right
throw new Error('dohMethod can only be "GET" or "POST"; got: ' + config.dohMethod)
}
// make the request
let response = await fetch(request_data.url, request_data.options)
// did we get application/dns-message?
if (response.headers.get("content-type") !== config.dohMediaType) {
throw new Error(`Response Content-Type should be: ${config.dohMediaType}; is: ${response.headers.get("content-type")}.`)
}
// let's get the actual response
let rbuf = new Uint8Array(await response.arrayBuffer())
// the answer cannot be shorter than the question
if (rbuf.length < qbuf.length) {
throw new Error('Invalid response: response cannot be shorter than request!')
}
// first two bytes have to be the ID, so identical to qbuf's first two bytes
if ( (rbuf[0] !== qbuf[0]) || (rbuf[1] !== qbuf[1]) ) {
throw new Error('Response ID does not match Request ID!')
}
// is this a response? QR bit needs to be 1
if ( (rbuf[2] & 0b10000000) != 0b10000000) {
throw new Error('Invalid response: QR bit does not indicate a response!')
}
// is the OPCODE sane? it needs to be 0b0000
if ( (rbuf[2] & 0b01111000) != 0b00000000) {
throw new Error('Invalid response: OPCODE contains an unexpected value (should be zero)!')
}
// there's no good reason for us to get a truncated response
if ( (rbuf[2] & 0b00000010) == 0b00000010) {
throw new Error('Invalid response: Got a truncated response. There is no reason for it, and we cannot handle it.')
}
// recursive resolution was requested — is that reflected?
if ( (rbuf[2] & 0b0000001) != 0b00000001) {
throw new Error('Invalid response: Recursive resolution was requested but this is not reflected in response.')
}
// 3-bit Z field is "reserverved for future use" and should be zero
if ( (rbuf[3] & 0b01110000) != 0b00000000) {
throw new Error("Invalid response: Response's Z field is not zeroed out!")
}
// 4-bit RCODE field should be zero, anything else indicates errors
if ( (rbuf[3] & 0b00001111) != 0b00000000) {
let rcode = (rbuf[3] & 0b00001111)
if (rcode == 1) {
throw new Error("Response's RCODE field indicates a format error!")
}
if (rcode == 2) {
throw new Error("Response's RCODE field indicates a server failure!")
}
if (rcode == 3) {
throw new Error("Response's RCODE field indicates a the name does not exist!")
}
if (rcode == 4) {
throw new Error("Response's RCODE field indicates a not implemented error!")
}
if (rcode == 5) {
throw new Error("Response's RCODE field indicates the request was refused!")
}
throw new Error(`Response's RCODE field indicates an error (code: ${rcode})!`)
}
// we only asked one question
if ( (rbuf[4] != 0) || (rbuf[5] != 1) ) {
throw new Error("Response's QDCOUNT is different than request's QDCOUNT (should be 1)!")
}
// how many resource records did we get in the response?
let ancount = (rbuf[6] << 8) + rbuf[7];
if (ancount < 1) {
throw new Error("Response's ANCOUNT indicates no resource records received in response!")
} else {
log("number of resource records received: " + ancount)
}
// we are ignoring NSCOUNT and ARCOUNT 16-bit fields (rbuf[8] through rbuf[11]),
// and moving on to question section
let rbufpos = 12
// let's see if the question section in the response
// matches the question section in the request
//
// header is the same size in request and response: 12 bytes
//
// qbufpos indicates the end of the QNAME section of the request
// this is followed by four bytes: two for TYPE and two for CLASS
//
// these (QNAME, QTYPE, QCLASS) should all be byte-for-byte equal
// in both request and response
while (rbufpos < qbufpos + 4) {
if (rbuf[rbufpos] != qbuf[rbufpos]) {
throw new Error("Invalid response: QNAME, QTYPE, or QCLASS do not match between request and response.")
}
rbufpos += 1
}
// let's make some space for answers
// this is going to be an array of string
let answers = []
// consume the response buffer byte by byte
while (rbufpos < rbuf.length) {
// we are not ignoring answers by default
let ignore_answer = false
// at this point we should have our NAME (same as QNAME) in the response
// if it starts with 0x11nn, it's a pointer to a QNAME/NAME,
// and the rest of the two bytes is the offset from the start of the response
// is this a pointer or a NAME?
if ( (rbuf[rbufpos] & 0b11000000) == 0b11000000 ) {
// a pointer! we should expect the rest of the two bytes
// to be an offset from the start of the response
// and it only makes sense here for the offset to be equal
// to 12 — the first byte after the header
if ( ((rbuf[rbufpos] & 0b00111111) << 8) + rbuf[rbufpos+1] != 12) {
throw new Error(`Invalid response: unexpected name pointer offset ${((rbuf[rbufpos] & 0b00111111) << 8) + rbuf[rbufpos+1]} (expected: 12)`)
}
rbufpos += 2;
} else if ( (rbuf[rbufpos] & 0b11000000) == 0b00000000 ) {
// so we get a full name, that should be identical
// to QNAME from the question section — length of which is (qbufpos - 12) bytes,
// when accounted for the header
for (let i=0; i < (qbufpos - 12); i++) {
if (rbuf[rbufpos + i] != qbuf[i + 12]) {
throw new Error("Invalid response: NAME in an answer does not match the QNAME from the request.")
}
}
rbufpos += (qbufpos - 12)
// this should never happen
} else {
throw new Error('Invalid response: answer\'s NAME starts with something else than 0b11 or 0b00.')
}
// two bytes of TYPE; we are asking for TXT, which is 16
if ( ((rbuf[rbufpos] << 8) + rbuf[rbufpos+1]) != 16) {
// we don't care about any other type
// but we can't just bail, we need to verify the validity of the whole answer section
ignore_answer = true
log("Not a TXT record, ignoring.")
}
rbufpos += 2
// two bytes of CLASS; we are only interested in IN ("Internet") class, which is 1
if ( ((rbuf[rbufpos] << 8) + rbuf[rbufpos+1]) != 1) {
throw new Error("Invalid response: unexpected CLASS: " + ((rbuf[rbufpos] << 8) + rbuf[rbufpos+1]))
}
rbufpos += 2
// four bytes of TTL.!
let ttl = ((rbuf[rbufpos] << 24) + (rbuf[rbufpos+1] << 16) + (rbuf[rbufpos+2] << 8) + rbuf[rbufpos+3])
rbufpos += 4
// 16-bit length of the RDATA field. that we definitely care about!
let rdlength = ((rbuf[rbufpos] << 8) + rbuf[rbufpos+1])
// "An empty TXT record containing zero strings is not allowed [RFC1035]."
if (rdlength < 1) {
throw new Error("Invalid response: RDLENGTH is zero")
}
rbufpos += 2
// are we interested in this answer at all?
if (ignore_answer) {
// skip!
rbufpos += rdlength;
continue;
}
// "The format of each constituent string within the DNS TXT record is a
// single length byte, followed by 0-255 bytes of text data."
// https://www.ietf.org/rfc/rfc6763.txt
//
// in other words we have the length of the TXT record itself specified again within RDATA
// this obviously should be 1 less than RDLENGTH
//
// TODO: we are assuming here that the RDATA only contains one string;
// TODO: *technically* RDATA *can* contain multiple strings for TXT RRs, but that seems to not be used much?
if (rdlength != rbuf[rbufpos] + 1) {
throw new Error("Invalid response: RDLENGTH does not match TXT record length")
}
rbufpos += 1
// we are interested! okay!
answers.push(dec.decode(rbuf.slice(rbufpos, rbufpos + rdlength - 1)))
log(`relevant answer received: '${answers[answers.length - 1]}' (TTL: ${ttl})`)
rbufpos += rdlength - 1;
// did we get all the answers we expected based on ANCOUNT?
if (ancount <= answers.length) {
// great, we can ignore the rest of the response packet
log(`got all ${ancount} expected answers.`)
break
}
}
// this should be an array of strings
return answers;
}
/**
* retrieving the alternative endpoints list from DNS TXT records
* using a DNS-over-HTTPS JSON endpoint
*
* returns an array of strings, one per each TXT record
*
* @param domain (string) domain name to resolve TXT records for
* @param config (object) config object with fields: dohProvider, dohMethod, dohMediaType
*/
window.resolveEndpointsJSON = async (domain, config) => {
// pretty self-explanatory:
// DoH provider, domain, TXT type, pretty please
var query = `${config.dohProvider}?name=${domain}&type=TXT`
// make the query, get the response
var response = await fetch(
query, {
// TODO: we need to find a way to make "no-cors" requests
//mode: "no-cors",
headers: {
'accept': 'application/json',
}
})
// this will throw an error if the response fails to parse as JSON
.then(r=>r.json())
// we need an object here
// we could get a "JSON" that is just a string, as long as it is in quote-marks, apparently
if (typeof response !== 'object') {
throw new Error('Response is not a valid JSON')
}
// only Status == 0 is acceptable
// https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
if (!('Status' in response) || response.Status != 0) {
throw new Error(`DNS request failure, status code: ${response.Status}`)
}
// we also do need the Answer section please
if (!('Answer' in response) || (typeof response.Answer !== 'object') || (!Array.isArray(response.Answer))) {
throw new Error(`DNS response did not contain a valid Answer section`)
}
// only get TXT records, and extract the data from them
response = response
.Answer
.filter(r => r.type == 16)
.map(r => r.data);
// did we get anything of value? anything at all?
if (response.length < 1) {
throw new Error(`Answer section of the DNS response did not contain any TXT records`)
}
// this should be an array of strings, containing the TXT records
return response
}

Wyświetl plik

@ -25,15 +25,19 @@ The `dnslink-fetch` plugin supports the following configuration options:
CloudFlare's DoH JSON API endpoint
- "`https://mozilla.cloudflare-dns.com/dns-query`"
Mozilla's DoH JSON API endpoint, operated in co-operation with CloudFlare.
- `dohMediaType` (default: `application/dns-json`)
Media type to use in requests. When set to `application/dns-message`, requests will be made using the binary DNS wire format. If set to anything else, they will be made using the JSON format. JSON format is much less popular (many fewer available public DoH resolvers support it), but handles internationalized domain names (IDNs) better.
- `ecsMasked` (default: `true`)
Should the [EDNS Client Subnet](https://en.wikipedia.org/wiki/EDNS_Client_Subnet) be masked from authoritative DNS servers for privacy. See also: `edns_client_subnet` [parameter of the DoH JSON API](https://developers.google.com/speed/public-dns/docs/doh/json#supported_parameters).
- `dohMethod` (default: `GET`)
The HTTP method to use when making DNS-over-HTTPS requests. It can only be "`POST`" or "`GET`". It is only meaningful when `dohMediaType` is set to `application/dns-message` -- that is, only when the binary DNS wire format is being used. DoH JSON API requests can only be made using HTTP `GET`, so `dohMethod` setting is ignored when the `dohMediaType` is set to anything other than "`application/dns-message`".
## Operation
When fetching an URL, `dnslink-fetch` removes the scheme and domain component. Then, for each alternative endpoint that is used for this particular request (up to `concurrency` of endpoints, as described above), it concatenates the endpoint with the remaining URL part. Finally, it performs a [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) request for every URL construed in such a way.
DNS requests are made either using the DNS-over-HTTPS JSON format (default), or using the DNS wire format (when `dohMediaType` is set to "`application/dns-message`"). JSON format handles internationalized domain names (IDNs) better, but there are fewer available public resolvers that support it.
Let's say the plugin is deployed for website `https://example.com`, with `concurrency` set to `2` and these are the alternative endpoints specified in relevant TXT records according to the DNSLink specification (so, in [multiaddr form](https://github.com/multiformats/multiaddr#encapsulation-based-on-context)):
- `dnslink=/https/example.org`
- `dnslink=/https/example.net/alt-example`

Wyświetl plik

@ -6,8 +6,16 @@
self.log = console.log
</script>
<script src="./index.js"></script>
<script src="../../lib/doh.js"></script>
<script>
var theplugin = LibResilientPluginConstructors.get('dnslink-fetch')(self)
// these are the defaults, change them as needed for testing
let init = {
//concurrency: 3,
//dohMediaType: "application/dns-json",
//dohMethod: "GET",
//dohProvider: "https://dns.hostux.net/dns-query"
}
var theplugin = LibResilientPluginConstructors.get('dnslink-fetch')(self, init)
</script>
</head>
<body>

Wyświetl plik

@ -25,12 +25,40 @@
// how many simultaneous connections to different endpoints do we want
//
// more concurrency means higher chance of a request succeeding
// but uses more bandwidth and other resources;
// but uses more bandwidth and other resources.
//
// this is not about DoH resolution, only one DoH endpoint *can* be
// configured and used at any time. this is about concurrency once the
// alternative endpoints are already resolved. that is, how many endpoints
// are used simultaneously to pull the actual content.
//
// 3 seems to be a reasonable default
concurrency: 3,
// DNS-over-HTTPS JSON API provider
// DNS-over-HTTPS media type
//
// if this is set to "application/dns-message", the binary DNS wire format
// will be used and expected.
//
// if this is set to anything else, JSON format will be used and expected.
// when using the JSON format, it is probably safest to set it to "application/dns-json"
// as this media type seems to be accepted the widest and is required by CloudFlare.
//
// other often-used media types for DoH JSON API requests:
// - application/dns+json (RFC8427, registered with IANA)
// - application/x-javascript (Google, but accepts other media types as well)
// - application/json
//
// by default DoH JSON API is used
dohMediaType: "application/dns-json",
// should the requests to the DoH server be made using GET or POST HTTP method?
//
// this is *only* valid when dohMediaType above is set to "application/dns-message";
// otherwise it is ignored and GET HTTP method is used
dohMethod: "GET",
// DNS-over-HTTPS provider
//
// by default using Hostux DoH provider, info here:
// - https://dns.hostux.net/en/
@ -39,78 +67,93 @@
// - 'https://cloudflare-dns.com/dns-query'
// - 'https://mozilla.cloudflare-dns.com/dns-query'
// - 'https://dns.google/resolve'
dohProvider: 'https://dns.hostux.net/dns-query',
// should the EDNS Client Subnet be masked from authoritative DNS servers for privacy?
// - https://en.wikipedia.org/wiki/EDNS_Client_Subnet
// - https://developers.google.com/speed/public-dns/docs/doh/json#supported_parameters
ecsMasked: true
// - 'https://dns.alidns.com/resolve'
// - 'https://dns.quad9.net:5053/dns-query'
dohProvider: 'https://dns.hostux.net/dns-query'
}
// merge the defaults with settings from the init var
let config = {...defaultConfig, ...init}
// reality check: dohMediaType must be a string
if (typeof(config.dohMediaType) !== "string" || (config.dohMediaType == '')) {
let err = new Error("dohMediaType not confgured")
console.error(err)
throw err
}
// reality check: dohMethod must be a string, and either "GET" or "POST"
// also, "POST" only makes sense when using "application/dns-message" dohMediaType
if (typeof(config.dohMethod) !== "string" || (config.dohMethod == '')) {
let err = new Error("dohMethod not confgured")
console.error(err)
throw err
} else {
// this should be uppercase
config.dohMethod = config.dohMethod.toUpperCase()
// and only GET or POST
if (config.dohMethod != "GET" && config.dohMethod != "POST") {
let err = new Error(`dohMethod can only be "GET" or "POST", but is set to: ${config.dohMethod}`)
console.error(err)
throw err
}
// "POST" only makes sense when dohMediaType is "application/dns-message"
if (config.dohMethod === "POST" && config.dohMediaType != "application/dns-message") {
// this is just a warning though, "POST" is just going to be ignored
LR.log(pluginName, 'Warning: "POST" dohMethod is going to be ignored as dohMediaType is not "application/dns-message"')
}
}
// reality check: dohProvider must be a string
if (typeof(config.dohProvider) !== "string" || (config.dohProvider == '')) {
let err = new Error("dohProvider not confgured")
console.error(err)
throw err
}
// externally defined functions
if (typeof self.importScripts !== 'undefined') {
LR.log(pluginName, 'importing doh.js')
self.importScripts("./lib/doh.js")
}
// check if doh.js was properly loaded
if (typeof resolveEndpointsBinary !== "function") {
throw new Error("resolveEndpointsBinary() is not defined, is doh.js loaded?")
}
if (typeof resolveEndpointsJSON !== "function") {
throw new Error("resolveEndpointsJSON() is not defined, is doh.js loaded?")
}
/**
* retrieving the alternative endpoints list from dnslink
* using DNS-over-HTTPS
*
* returns an array of strings, each being a valid endpoint, in the form of
* returns an array of strings, each being a valid endpoint,
* in the form of
* scheme://example.org[/optional/path]
*/
let resolveEndpoints = async (domain) => {
// pretty self-explanatory:
// DoH provider, _dnslink label in the domain, TXT type, pretty please
var query = `${config.dohProvider}?name=_dnslink.${domain}&type=TXT`
// do we want to mask the EDNS Client Subnet?
// first, we need the actual name we're asking about
//
// this protects user privacy somewhat by telling the DoH provider not to disclose
// the subnet from which the DNS request came to authoritiative nameservers
if (config.ecsMasked) {
query += '&edns_client_subnet=0.0.0.0/0'
}
// this needs to be ASCII-encoded, so if we want an IDN domain
// it needs to be punycode-encoded!
// TODO: we should *probably* do that ourselves here...
let dnslink_name = `_dnslink.${domain}`
// make the query, get the response
var response = await fetch(
query, {
headers: {
'accept': 'application/json',
}
})
.then(r=>r.json())
// we need an object here
if (typeof response !== 'object') {
throw new Error('Response is not a valid JSON')
}
// response we will need to process a bit
let response = []
// only Status == 0 is acceptable
// https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
if (!('Status' in response) || response.Status != 0) {
throw new Error(`DNS request failure, status code: ${response.Status}`)
}
// we can either resolve them using the binary DoH DNS wire format request...
if (config.dohMediaType === 'application/dns-message') {
// defined in doh.js
response = await resolveEndpointsBinary(dnslink_name, config, (msg)=>{LR.log(pluginName, msg)})
// we also do need the Answer section please
if (!('Answer' in response) || (typeof response.Answer !== 'object') || (!Array.isArray(response.Answer))) {
throw new Error(`DNS response did not contain a valid Answer section`)
}
// only get TXT records, and extract the data from them
response = response
.Answer
.filter(r => r.type == 16)
.map(r => r.data);
// did we get anything of value? anything at all?
if (response.length < 1) {
throw new Error(`Answer section of the DNS response did not contain any TXT records`)
// ...or a DoH JSON API request
} else {
// defined in doh.js
response = await resolveEndpointsJSON(dnslink_name, config)
}
// filter by 'dnslink="/https?/', morph into scheme://...
@ -126,7 +169,7 @@
}
// in case we need some debugging
LR.log(pluginName, '+-- alternative endpoints from DNSLink:\n - ', response.join('\n - '))
LR.log(pluginName, '+-- alternative endpoints from DNSLink:\n - ' + response.join('"\n - '))
// this should be what we're looking for - an array of URLs
return response