dohdec
Version:
DNS over HTTPS and DNS over TLS
352 lines (311 loc) • 9.42 kB
JavaScript
import * as crypto from 'node:crypto';
import * as packet from 'dns-packet';
import * as tls from 'node:tls';
import {Buffer} from 'node:buffer';
import {default as DNSutils} from './dnsUtils.js';
import {NoFilter} from 'nofilter';
import assert from 'node:assert';
import util from 'node:util';
const randomBytes = util.promisify(crypto.randomBytes);
const DEFAULT_SERVER = '1.1.1.1';
/**
* Options for doing DOT lookups.
*
* @typedef {import('./dnsUtils.js').LookupOptions} DOT_LookupOptions
*/
/**
* @typedef {import('./dnsUtils.js').Writable} Writable
*/
/**
* @callback pendingResolve
* @param {Buffer|object} results The results of the DNS query.
*/
/**
* @callback pendingError
* @param {Error} error The error that occurred.
*/
/**
* @typedef {object} Pending
* @property {pendingResolve} resolve Callback for success.
* @property {pendingError} reject Callback for error.
* @property {DOT_LookupOptions} opts The original options for the request.
*/
/**
* A class that manages a connection to a DNS-over-TLS server. The first time
* [lookup]{@link DNSoverTLS#lookup} is called, a connection will be created.
* If that connection is timed out by the server, a new connection will be
* created as needed.
*
* If you want to do certificate pinning, make sure that the `hash` and
* `hashAlg` options are set correctly to a hash of the DER-encoded
* certificate that the server will offer.
*/
export class DNSoverTLS extends DNSutils {
size = -1;
/** @type {tls.TLSSocket|undefined} */
socket = undefined;
/** @type {Record<number, Pending>} */
pending = Object.create(null);
/** @type {NoFilter|undefined} */
nof = undefined;
/** @type {Buffer[]} */
bufs = [];
/**
* Construct a new DNSoverTLS.
*
* @param {object} opts Options.
* @param {string} [opts.host='1.1.1.1'] Server to connect to.
* @param {number} [opts.port=853] TCP port number for server.
* @param {string} [opts.hash] Hex-encoded hash of the DER-encoded cert
* expected from the server. If not specified, no pinning checks are
* performed.
* @param {string} [opts.hashAlg='sha256'] Hash algorithm for cert pinning.
* @param {boolean} [opts.rejectUnauthorized=true] Should the server
* certificate even be checked using the normal TLS approach?
* @param {number} [opts.verbose=0] How verbose do you want your logging?
* @param {Writable} [opts.verboseStream=process.stderr] Where to write
* verbose output.
*/
constructor(opts = {}) {
const {
verbose,
verboseStream,
...rest
} = opts;
super({verbose, verboseStream});
this.opts = {
host: DNSoverTLS.server,
port: 853,
hashAlg: 'sha256',
rejectUnauthorized: true,
checkServerIdentity: this._checkServerIdentity.bind(this),
...rest,
};
this.verbose(1, 'DNSoverTLS options:', this.opts);
}
_reset() {
this.size = -1;
this.socket = undefined;
this.pending = Object.create(null);
this.nof = undefined;
this.bufs = [];
}
/**
* @returns {Promise<void>}
* @private
*/
_connect() {
return new Promise((resolve, reject) => {
if (this.socket) {
resolve();
return;
}
/**
* Fired right before connection is attempted.
*
* @event DNSoverTLS#connect
* @property {object} cert [lookup]{@link DNSoverTLS#lookup} options.
*/
this.emit('connect', this.opts);
this.verbose(1, 'CONNECT:', this.opts);
this.nof = new NoFilter();
this.socket = tls.connect(this.opts, resolve);
this.socket.on('data', this._data.bind(this));
this.socket.on('error', reject);
this.socket.on('end', this._disconnected.bind(this));
});
}
/**
* @param {string} host
* @param {tls.PeerCertificate} cert
* @returns {Error | undefined}
* @private
*/
_checkServerIdentity(host, cert) {
// Same as cert.fingerprint256, but with hash agility
const hash = DNSoverTLS.hashCert(cert, this.opts.hashAlg);
/**
* Fired on connection when the server sends a certificate.
*
* @event DNSoverTLS#certificate
* @property {crypto.Certificate} cert
* A [crypto.Certificate]{@link https://nodejs.org/api/crypto.html#crypto_class_certificate}
* from the server.
* @property {string} host The hostname the client thinks it is
* connecting to.
* @property {string} hash The hash computed over the cert.
*/
this.emit('certificate', cert, host, hash);
this.verbose(2, 'CERTIFICATE:', () => DNSutils.buffersToB64(cert));
const err = tls.checkServerIdentity(host, cert);
if (!err) {
if (this.opts.hash && (this.opts.hash !== hash)) {
return new Error(`Invalid cert hash for ${this.opts.host}:${this.opts.port}.
Expected: "${this.opts.hash}"
Received: "${hash}"`);
}
} else if (this.opts.hash !== hash) {
return err;
}
return undefined;
}
/**
* Server socket was disconnected. Clean up any pending requests.
*
* @private
*/
_disconnected() {
for (const {reject, opts} of Object.values(this.pending)) {
reject(new Error(`Timeout looking up "${opts.name}":${opts.rrtype}`));
}
this._reset();
/**
* Server disconnected. All pending requests will have failed.
*
* @event DNSoverTLS#disconnect
*/
this.emit('disconnect');
this.verbose(1, 'DISCONNECT');
}
/**
* Parse data if enough is available.
*
* @param {Buffer} b Data read from socket.
* @private
*/
_data(b) {
/**
* A buffer of data has been received from the server. Useful for
* verbose logging, e.g.
*
* @event DNSoverTLS#receive
*/
this.emit('receive', b);
assert(this.nof);
this.nof.write(b);
// There might be multiple results in one read.
while (this.nof.length > 0) {
// No size read yet
if (this.size === -1) {
if (this.nof.length < 2) {
return;
}
this.size = this.nof.readUInt16BE();
}
if (this.nof.length < this.size) {
return;
}
const buf = /** @type {Buffer} */(this.nof.read(this.size));
this.verbose(1, 'RECV:');
this.hexDump(1, buf);
this.size = -1;
const pkt = packet.decode(buf);
assert(pkt.id !== undefined, 'Invalid packet, no id');
const pend = this.pending[pkt.id];
if (!pend) {
// Something bad happened, like an injection attack or a corrupted
// result. Abandon everything pending.
this.close();
return;
}
pend.resolve(pend.opts.decode ? pkt : buf);
}
}
/**
* Generate a currently-unused random ID.
*
* @returns {Promise<number>} A random 2-byte ID number.
* @private
*/
async _id() {
let id = null;
do {
id = (await randomBytes(2)).readUInt16BE();
} while (this.pending[id]);
return id;
}
/**
* Hash a certificate using the given algorithm.
*
* @param {Buffer|tls.PeerCertificate} cert The cert to hash.
* @param {string} [hashAlg="sha256"] The hash algorithm to use.
* @returns {string} Hex string.
* @throws {Error} Unknown certificate type.
*/
static hashCert(cert, hashAlg = 'sha256') {
const hash = crypto.createHash(hashAlg);
if (Buffer.isBuffer(cert)) {
hash.update(cert);
} else if (cert.raw) {
hash.update(cert.raw);
} else {
throw new Error('Unknown certificate type');
}
return hash.digest('hex');
}
/**
* Look up a name in the DNS, over TLS.
*
* @param {DOT_LookupOptions|string} name The DNS name to look up, or opts
* if this is an object.
* @param {DOT_LookupOptions|string} [opts={}] Options for the
* request. If a string is given, it will be used as the rrtype.
* @returns {Promise<Buffer|packet.Packet>} Response.
*/
async lookup(name, opts = {}) {
const nopts = DNSutils.normalizeArgs(name, opts, {
rrtype: 'A',
dnsssec: false,
dnssecCheckingDisabled: false,
decode: true,
stream: true,
});
this.verbose(1, 'DNSoverTLS.lookup options:', nopts);
await this._connect();
if (!nopts.id) {
// eslint-disable-next-line require-atomic-updates
nopts.id = await this._id();
}
return new Promise((resolve, reject) => {
const pkt = DNSutils.makePacket(nopts);
this.verbose(1, 'REQUEST:');
this.hexDump(2, pkt);
this.verbose(
1,
'Length:',
() => pkt.readUInt16BE(0),
() => packet.decode(pkt, 2) // Skip length
);
assert(nopts.id, 'Invalid ID');
this.pending[nopts.id] = {resolve, reject, opts: nopts};
assert(this.socket);
this.socket.write(pkt);
/**
* A buffer of data has been sent to the server. Useful for
* verbose logging, e.g.
*
* @event DNSoverTLS#send
*/
this.emit('send', pkt);
this.verbose(2, 'REQUEST:', pkt);
});
}
/**
* Close the socket.
*
* @returns {Promise<void>} Resolved on socket close.
*/
close() {
return new Promise((resolve, _reject) => {
if (this.socket) {
this.socket.end(() => {
resolve();
});
} else {
resolve();
}
});
}
}
DNSoverTLS.server = DEFAULT_SERVER;
export default DNSoverTLS;