Source: dns/resource.js

/*!
 * resource.js - hns records for hsd
 * Copyright (c) 2017-2018, Christopher Jeffrey (MIT License).
 * https://github.com/handshake-org/hsd
 */

'use strict';

const assert = require('bsert');
const {encoding, wire, util} = require('bns');
const base32 = require('bcrypto/lib/encoding/base32');
const {IP} = require('binet');
const bio = require('bufio');
const key = require('./key');
const nsec = require('./nsec');
const {Struct} = bio;
const {
  DUMMY,
  DEFAULT_TTL,
  TYPE_MAP_EMPTY,
  TYPE_MAP_NS,
  TYPE_MAP_TXT,
  hsTypes
} = require('./common');

const {
  sizeName,
  writeNameBW,
  readNameBR,
  sizeString,
  writeStringBW,
  readStringBR,
  isName,
  readIP,
  writeIP
} = encoding;

const {
  Message,
  Record,
  ARecord,
  AAAARecord,
  NSRecord,
  TXTRecord,
  DSRecord,
  types
} = wire;

/**
 * Resource
 * @extends {Struct}
 */

class Resource extends Struct {
  constructor() {
    super();
    this.ttl = DEFAULT_TTL;
    this.records = [];
  }

  hasType(type) {
    assert((type & 0xff) === type);

    for (const record of this.records) {
      if (record.type === type)
        return true;
    }

    return false;
  }

  hasNS() {
    for (const {type} of this.records) {
      if (type < hsTypes.NS || type > hsTypes.SYNTH6)
        continue;

      return true;
    }

    return false;
  }

  hasDS() {
    return this.hasType(hsTypes.DS);
  }

  encode() {
    const bw = bio.write(512);
    this.write(bw, new Map());
    return bw.slice();
  }

  getSize(map) {
    let size = 1;

    for (const rr of this.records)
      size += 1 + rr.getSize(map);

    return size;
  }

  write(bw, map) {
    bw.writeU8(0);

    for (const rr of this.records) {
      bw.writeU8(rr.type);
      rr.write(bw, map);
    }

    return this;
  }

  read(br) {
    const version = br.readU8();

    if (version !== 0)
      throw new Error(`Unknown serialization version: ${version}.`);

    while (br.left()) {
      const RD = typeToClass(br.readU8());

      // Break at unknown records.
      if (!RD)
        break;

      this.records.push(RD.read(br));
    }

    return this;
  }

  toNS(name) {
    const authority = [];
    const set = new Set();

    for (const record of this.records) {
      switch (record.type) {
        case hsTypes.NS:
        case hsTypes.GLUE4:
        case hsTypes.GLUE6:
        case hsTypes.SYNTH4:
        case hsTypes.SYNTH6:
          break;
        default:
          continue;
      }

      const rr = record.toDNS(name, this.ttl);

      if (set.has(rr.data.ns))
        continue;

      set.add(rr.data.ns);
      authority.push(rr);
    }

    return authority;
  }

  toGlue(name) {
    const additional = [];

    for (const record of this.records) {
      switch (record.type) {
        case hsTypes.GLUE4:
        case hsTypes.GLUE6:
          if (!util.isSubdomain(name, record.ns))
            continue;
          break;
        case hsTypes.SYNTH4:
        case hsTypes.SYNTH6:
          break;
        default:
          continue;
      }

      additional.push(record.toGlue(record.ns, this.ttl));
    }

    return additional;
  }

  toDS(name) {
    const answer = [];

    for (const record of this.records) {
      if (record.type !== hsTypes.DS)
        continue;

      answer.push(record.toDNS(name, this.ttl));
    }

    return answer;
  }

  toTXT(name) {
    const answer = [];

    for (const record of this.records) {
      if (record.type !== hsTypes.TXT)
        continue;

      answer.push(record.toDNS(name, this.ttl));
    }

    return answer;
  }

  toZone(name, sign = false) {
    const zone = [];
    const set = new Set();

    for (const record of this.records) {
      const rr = record.toDNS(name, this.ttl);

      if (rr.type === types.NS) {
        if (set.has(rr.data.ns))
          continue;

        set.add(rr.data.ns);
      }

      zone.push(rr);
    }

    if (sign) {
      const set = new Set();

      for (const rr of zone)
        set.add(rr.type);

      const types = [...set].sort();

      for (const type of types)
        key.signZSK(zone, type);
    }

    // Add the glue last.
    for (const record of this.records) {
      switch (record.type) {
        case hsTypes.GLUE4:
        case hsTypes.GLUE6:
        case hsTypes.SYNTH4:
        case hsTypes.SYNTH6: {
          if (!util.isSubdomain(name, record.ns))
            continue;

          zone.push(record.toGlue(record.ns, this.ttl));
          break;
        }
      }
    }

    return zone;
  }

  toReferral(name, type, isTLD) {
    const res = new Message();

    // no DS referrals for TLDs
    const badReferral = isTLD && type === types.DS;

    if (this.hasNS() && !badReferral) {
      res.authority = this.toNS(name).concat(this.toDS(name));

      res.additional = this.toGlue(name);

      if (this.hasDS()) {
        key.signZSK(res.authority, types.DS);
      } else {
        // unsigned zone proof
        res.authority.push(this.toNSEC(name));
        key.signZSK(res.authority, types.NSEC);
      }
    } else {
      // Needs SOA.
      res.aa = true;
      // negative answer proof
      res.authority.push(this.toNSEC(name));
      key.signZSK(res.authority, types.NSEC);
    }

    return res;
  }

  toNSEC(name) {
    let typeMap = TYPE_MAP_EMPTY;

    if (this.hasNS())
      typeMap = TYPE_MAP_NS;
    else if (this.hasType(hsTypes.TXT))
      typeMap = TYPE_MAP_TXT;

    return nsec.create(name, nsec.nextName(name), typeMap);
  }

  toDNS(name, type) {
    assert(util.isFQDN(name));
    assert((type >>> 0) === type);

    const labels = util.split(name);

    // Referral.
    if (labels.length > 1) {
      const tld = util.from(name, labels, -1);
      return this.toReferral(tld, type, false);
    }

    // Potentially an answer.
    const res = new Message();

    // TLDs are authoritative over their own NS & TXT records.
    // The NS records in the root zone are just "hints"
    // and therefore are not signed by the root ZSK.
    // The only records root is authoritative over is DS.
    switch (type) {
      case types.TXT:
        if (!this.hasNS()) {
          res.aa = true;
          res.answer = this.toTXT(name);
          key.signZSK(res.answer, types.TXT);
        }
        break;
      case types.DS:
        res.aa = true;
        res.answer = this.toDS(name);
        key.signZSK(res.answer, types.DS);
        break;
    }

    // Nope, we may need a referral
    if (res.answer.length === 0 && res.authority.length === 0) {
      return this.toReferral(name, type, true);
    }

    return res;
  }

  getJSON(name) {
    const json = { records: [] };

    for (const record of this.records)
      json.records.push(record.getJSON());

    return json;
  }

  fromJSON(json) {
    assert(json && typeof json === 'object', 'Invalid json.');
    assert(Array.isArray(json.records), 'Invalid records.');

    for (const item of json.records) {
      assert(item && typeof item === 'object', 'Invalid record.');

      const RD = stringToClass(item.type);

      if (!RD)
        throw new Error(`Unknown type: ${item.type}.`);

      this.records.push(RD.fromJSON(item));
    }

    return this;
  }
}

/**
 * DS
 * @extends {Struct}
 */

class DS extends Struct {
  constructor() {
    super();
    this.keyTag = 0;
    this.algorithm = 0;
    this.digestType = 0;
    this.digest = DUMMY;
  }

  get type() {
    return hsTypes.DS;
  }

  getSize() {
    return 5 + this.digest.length;
  }

  write(bw) {
    bw.writeU16BE(this.keyTag);
    bw.writeU8(this.algorithm);
    bw.writeU8(this.digestType);
    bw.writeU8(this.digest.length);
    bw.writeBytes(this.digest);
    return this;
  }

  read(br) {
    this.keyTag = br.readU16BE();
    this.algorithm = br.readU8();
    this.digestType = br.readU8();
    this.digest = br.readBytes(br.readU8());
    return this;
  }

  toDNS(name = '.', ttl = DEFAULT_TTL) {
    assert(util.isFQDN(name));
    assert((ttl >>> 0) === ttl);

    const rr = new Record();
    const rd = new DSRecord();

    rr.name = name;
    rr.type = types.DS;
    rr.ttl = ttl;
    rr.data = rd;

    rd.keyTag = this.keyTag;
    rd.algorithm = this.algorithm;
    rd.digestType = this.digestType;
    rd.digest = this.digest;

    return rr;
  }

  getJSON() {
    return {
      type: 'DS',
      keyTag: this.keyTag,
      algorithm: this.algorithm,
      digestType: this.digestType,
      digest: this.digest.toString('hex')
    };
  }

  fromJSON(json) {
    assert(json && typeof json === 'object', 'Invalid DS record.');
    assert(json.type === 'DS',
      'Invalid DS record. Type must be "DS".');
    assert((json.keyTag & 0xffff) === json.keyTag,
      'Invalid DS record. KeyTag must be a uint16.');
    assert((json.algorithm & 0xff) === json.algorithm,
      'Invalid DS record. Algorithm must be a uint8.');
    assert((json.digestType & 0xff) === json.digestType,
      'Invalid DS record. DigestType must be a uint8.');
    assert(typeof json.digest === 'string',
      'Invalid DS record. Digest must be a String.');
    assert((json.digest.length >>> 1) <= 255,
      'Invalid DS record. Digest is too large.');

    this.keyTag = json.keyTag;
    this.algorithm = json.algorithm;
    this.digestType = json.digestType;
    this.digest = util.parseHex(json.digest);

    return this;
  }
}

/**
 * NS
 * @extends {Struct}
 */

class NS extends Struct {
  constructor() {
    super();
    this.ns = '.';
  }

  get type() {
    return hsTypes.NS;
  }

  getSize(map) {
    return sizeName(this.ns, map);
  }

  write(bw, map) {
    writeNameBW(bw, this.ns, map);
    return this;
  }

  read(br) {
    this.ns = readNameBR(br);
    return this;
  }

  toDNS(name = '.', ttl = DEFAULT_TTL) {
    return createNS(name, ttl, this.ns);
  }

  getJSON() {
    return {
      type: 'NS',
      ns: this.ns
    };
  }

  fromJSON(json) {
    assert(json && typeof json === 'object',
      'Invalid NS record.');
    assert(json.type === 'NS',
      'Invalid NS record. Type must be "NS".');
    assert(isName(json.ns),
      'Invalid NS record. ns must be a valid name.');

    this.ns = json.ns;

    return this;
  }
}

/**
 * GLUE4
 * @extends {Struct}
 */

class GLUE4 extends Struct {
  constructor() {
    super();
    this.ns = '.';
    this.address = '0.0.0.0';
  }

  get type() {
    return hsTypes.GLUE4;
  }

  getSize(map) {
    return sizeName(this.ns, map) + 4;
  }

  write(bw, map) {
    writeNameBW(bw, this.ns, map);
    writeIP(bw, this.address, 4);
    return this;
  }

  read(br) {
    this.ns = readNameBR(br);
    this.address = readIP(br, 4);
    return this;
  }

  toDNS(name = '.', ttl = DEFAULT_TTL) {
    return createNS(name, ttl, this.ns);
  }

  toGlue(name = '.', ttl = DEFAULT_TTL) {
    return createA(name, ttl, this.address);
  }

  getJSON() {
    return {
      type: 'GLUE4',
      ns: this.ns,
      address: this.address
    };
  }

  fromJSON(json) {
    assert(json && typeof json === 'object', 'Invalid GLUE4 record.');
    assert(json.type === 'GLUE4',
      'Invalid GLUE4 record. Type must be "GLUE4".');
    assert(isName(json.ns),
      'Invalid GLUE4 record. ns must be a valid name.');
    assert(IP.isIPv4String(json.address),
      'Invalid GLUE4 record. Address must be a valid IPv4 address.');

    this.ns = json.ns;
    this.address = IP.normalize(json.address);

    return this;
  }
}

/**
 * GLUE6
 * @extends {Struct}
 */

class GLUE6 extends Struct {
  constructor() {
    super();
    this.ns = '.';
    this.address = '::';
  }

  get type() {
    return hsTypes.GLUE6;
  }

  getSize(map) {
    return sizeName(this.ns, map) + 16;
  }

  write(bw, map) {
    writeNameBW(bw, this.ns, map);
    writeIP(bw, this.address, 16);
    return this;
  }

  read(br) {
    this.ns = readNameBR(br);
    this.address = readIP(br, 16);
    return this;
  }

  toDNS(name = '.', ttl = DEFAULT_TTL) {
    return createNS(name, ttl, this.ns);
  }

  toGlue(name = '.', ttl = DEFAULT_TTL) {
    return createAAAA(name, ttl, this.address);
  }

  getJSON() {
    return {
      type: 'GLUE6',
      ns: this.ns,
      address: this.address
    };
  }

  fromJSON(json) {
    assert(json && typeof json === 'object', 'Invalid GLUE6 record.');
    assert(json.type === 'GLUE6',
      'Invalid GLUE6 record. Type must be "GLUE6".');
    assert(isName(json.ns),
      'Invalid GLUE6 record. ns must be a valid name.');
    assert(IP.isIPv6String(json.address),
      'Invalid GLUE6 record. Address must be a valid IPv6 address.');

    this.ns = json.ns;
    this.address = IP.normalize(json.address);

    return this;
  }
}

/**
 * SYNTH4
 * @extends {Struct}
 */

class SYNTH4 extends Struct {
  constructor() {
    super();
    this.address = '0.0.0.0';
  }

  get type() {
    return hsTypes.SYNTH4;
  }

  get ns() {
    const ip = IP.toBuffer(this.address).slice(12);
    return `_${base32.encodeHex(ip)}._synth.`;
  }

  getSize() {
    return 4;
  }

  write(bw) {
    writeIP(bw, this.address, 4);
    return this;
  }

  read(br) {
    this.address = readIP(br, 4);
    return this;
  }

  toDNS(name = '.', ttl = DEFAULT_TTL) {
    return createNS(name, ttl, this.ns);
  }

  toGlue(name = '.', ttl = DEFAULT_TTL) {
    return createA(name, ttl, this.address);
  }

  getJSON() {
    return {
      type: 'SYNTH4',
      address: this.address
    };
  }

  fromJSON(json) {
    assert(json && typeof json === 'object', 'Invalid SYNTH4 record.');
    assert(json.type === 'SYNTH4',
      'Invalid SYNTH4 record. Type must be "SYNTH4".');
    assert(IP.isIPv4String(json.address),
      'Invalid SYNTH4 record. Address must be a valid IPv4 address.');

    this.address = IP.normalize(json.address);

    return this;
  }
}

/**
 * SYNTH6
 * @extends {Struct}
 */

class SYNTH6 extends Struct {
  constructor() {
    super();
    this.address = '::';
  }

  get type() {
    return hsTypes.SYNTH6;
  }

  get ns() {
    const ip = IP.toBuffer(this.address);
    return `_${base32.encodeHex(ip)}._synth.`;
  }

  getSize() {
    return 16;
  }

  write(bw) {
    writeIP(bw, this.address, 16);
    return this;
  }

  read(br) {
    this.address = readIP(br, 16);
    return this;
  }

  toDNS(name = '.', ttl = DEFAULT_TTL) {
    return createNS(name, ttl, this.ns);
  }

  toGlue(name = '.', ttl = DEFAULT_TTL) {
    return createAAAA(name, ttl, this.address);
  }

  getJSON() {
    return {
      type: 'SYNTH6',
      address: this.address
    };
  }

  fromJSON(json) {
    assert(json && typeof json === 'object', 'Invalid SYNTH6 record.');
    assert(json.type === 'SYNTH6',
      'Invalid SYNTH6 record. Type must be "SYNTH6".');
    assert(IP.isIPv6String(json.address),
      'Invalid SYNTH6 record. Address must be a valid IPv6 address.');

    this.address = IP.normalize(json.address);

    return this;
  }
}

/**
 * TXT
 * @extends {Struct}
 */

class TXT extends Struct {
  constructor() {
    super();
    this.txt = [];
  }

  get type() {
    return hsTypes.TXT;
  }

  getSize() {
    let size = 1;
    for (const txt of this.txt)
      size += sizeString(txt);
    return size;
  }

  write(bw) {
    bw.writeU8(this.txt.length);

    for (const txt of this.txt)
      writeStringBW(bw, txt);

    return this;
  }

  read(br) {
    const count = br.readU8();

    for (let i = 0; i < count; i++)
      this.txt.push(readStringBR(br));

    return this;
  }

  toDNS(name = '.', ttl = DEFAULT_TTL) {
    assert(util.isFQDN(name));
    assert((ttl >>> 0) === ttl);

    const rr = new Record();
    const rd = new TXTRecord();

    rr.name = name;
    rr.type = types.TXT;
    rr.ttl = ttl;
    rr.data = rd;

    rd.txt.push(...this.txt);

    return rr;
  }

  getJSON() {
    return {
      type: 'TXT',
      txt: this.txt
    };
  }

  fromJSON(json) {
    assert(json && typeof json === 'object',
      'Invalid TXT record.');
    assert(json.type === 'TXT',
      'Invalid TXT record. Type must be "TXT".');
    assert(Array.isArray(json.txt),
      'Invalid TXT record. txt must be an Array.');

    for (const txt of json.txt) {
      assert(typeof txt === 'string',
        'Invalid TXT record. Entries in txt Array must be type String.');
      assert(txt.length <= 255,
        'Invalid TXT record. Entries in txt Array must be <= 255 in length.');

      this.txt.push(txt);
    }

    return this;
  }
}

/*
 * Helpers
 */

function typeToClass(type) {
  assert((type & 0xff) === type);
  switch (type) {
    case hsTypes.DS:
      return DS;
    case hsTypes.NS:
      return NS;
    case hsTypes.GLUE4:
      return GLUE4;
    case hsTypes.GLUE6:
      return GLUE6;
    case hsTypes.SYNTH4:
      return SYNTH4;
    case hsTypes.SYNTH6:
      return SYNTH6;
    case hsTypes.TXT:
      return TXT;
    default:
      return null;
  }
}

function stringToClass(type) {
  assert(typeof type === 'string');

  if (!hsTypes.hasOwnProperty(type))
    return null;

  return typeToClass(hsTypes[type]);
}

function createNS(name, ttl, ns) {
  assert(util.isFQDN(name));
  assert((ttl >>> 0) === ttl);
  assert(util.isFQDN(ns));

  const rr = new Record();
  const rd = new NSRecord();

  rr.name = name;
  rr.ttl = ttl;
  rr.type = types.NS;
  rr.data = rd;
  rd.ns = ns;

  return rr;
}

function createA(name, ttl, address) {
  assert(util.isFQDN(name));
  assert((ttl >>> 0) === ttl);
  assert(IP.isIPv4String(address));

  const rr = new Record();
  const rd = new ARecord();

  rr.name = name;
  rr.ttl = ttl;
  rr.type = types.A;
  rr.data = rd;
  rd.address = address;

  return rr;
}

function createAAAA(name, ttl, address) {
  assert(util.isFQDN(name));
  assert((ttl >>> 0) === ttl);
  assert(IP.isIPv6String(address));

  const rr = new Record();
  const rd = new AAAARecord();

  rr.name = name;
  rr.ttl = ttl;
  rr.type = types.AAAA;
  rr.data = rd;
  rd.address = address;

  return rr;
}

/*
 * Expose
 */

exports.Resource = Resource;
exports.DS = DS;
exports.NS = NS;
exports.GLUE4 = GLUE4;
exports.GLUE6 = GLUE6;
exports.SYNTH4 = SYNTH4;
exports.SYNTH6 = SYNTH6;
exports.TXT = TXT;