diff --git a/hexdump.js b/hexdump.js new file mode 100644 index 0000000..324c2c0 --- /dev/null +++ b/hexdump.js @@ -0,0 +1,63 @@ +const wout = (s) => { + process.stdout.write(s); +} + +const pad_str = (s, len, ch) => { + ch = ch || ' '; + while (s.length < len) { + s = ch + s; + } + return s +} + +const hex_digit = (n, len) => { + return pad_str(n.toString(16), len || 2, '0') +} + +const printable_ch = (c) => { + // [space to '~') + if (c > 0x20 && c <= 0x7e) { + return String.fromCharCode(c); + } else { + return '.'; + } +} + +const hexdump = (buf, len) => { + len = len || buf.length; + + const maxline = 16; + let ascii = new Array(maxline); + let i; + + for (i = 0; i < len; i++) { + if (i % maxline == 0) { + if (i > 0) { + wout(" " + ascii.join("")) + wout("\n"); + } + wout(hex_digit(i, 4) + ": ") + } + + // output the hex digit + wout(hex_digit(buf[i])); + wout(" "); + ascii[i % maxline] = printable_ch(buf[i]); + } + if (i % maxline != 0) { + let diff = maxline - (i % maxline); + wout(" ".repeat(diff)) + } + + wout(" " + ascii.slice(0, (i % maxline)).join("") + "\n") +} + +/* +let buf = Buffer.alloc(500); +for (let i = 0; i < buf.length; i++) { + buf[i] = i & 0xff; +} +hexdump(buf) +*/ + +module.exports = hexdump; diff --git a/index.js b/index.js index a44d87f..9a2d6d2 100644 --- a/index.js +++ b/index.js @@ -1473,6 +1473,372 @@ rtlsa.encodingLength = function (cert) { return 5 + Buffer.byteLength(cert.certificate) } +const svcparam = exports.svcparam = {} + +svcparam.keyToNumber = function(keyName) { + switch (keyName.toLowerCase()) { + case 'mandatory': return 0 + case 'alpn' : return 1 + case 'no-default-alpn' : return 2 + case 'port' : return 3 + case 'ipv4hint' : return 4 + case 'echconfig' : return 5 + case 'ipv6hint' : return 6 + case 'odoh' : return 32769 + case 'key65535' : return 65535 + } + if (!keyName.startsWith('key')) { + throw new Error(`Name must start with key: ${keyName}`) + } + + return Number.parseInt(keyName.substring(3)) +} + +svcparam.numberToKeyName = function(number) { + switch (number) { + case 0 : return 'mandatory' + case 1 : return 'alpn' + case 2 : return 'no-default-alpn' + case 3 : return 'port' + case 4 : return 'ipv4hint' + case 5 : return 'echconfig' + case 6 : return 'ipv6hint' + case 32769 : return 'odoh' + } + + return `key${number}` +} + +svcparam.encode = function(param, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(svcparam.encodingLength(param)) + if (!offset) offset = 0 + + let key = param.key; + if (typeof param.key !== 'number') { + key = svcparam.keyToNumber(param.key) + } + buf.writeUInt16BE(key || 0, offset) + offset += 2 + svcparam.encode.bytes = 2 + + if (key == 0) { // mandatory + let values = param.value + if (!Array.isArray(values)) values = [values] + buf.writeUInt16BE(values.length*2, offset) + offset += 2 + svcparam.encode.bytes += 2 + + for (let val of values) { + if (typeof val !== 'number') { + val = svcparam.keyToNumber(val) + } + buf.writeUInt16BE(val, offset) + offset += 2 + svcparam.encode.bytes += 2 + } + } else if (key == 1) { // alpn + let val = param.value + if (!Array.isArray(val)) val = [val] + // The alpn param is prefixed by its length as a single byte, so the + // initialValue to reduce function is the length of the array. + let total = val.reduce(function(result, id) { + return result += id.length + }, val.length) + + buf.writeUInt16BE(total, offset) + offset += 2 + svcparam.encode.bytes += 2 + + for (let id of val) { + buf.writeUInt8(id.length, offset) + offset += 1 + svcparam.encode.bytes += 1 + + buf.write(id, offset) + offset += id.length + svcparam.encode.bytes += id.length + } + } else if (key == 2) { // no-default-alpn + buf.writeUInt16BE(0, offset) + offset += 2 + svcparam.encode.bytes += 2 + } else if (key == 3) { // port + buf.writeUInt16BE(2, offset) + offset += 2 + svcparam.encode.bytes += 2 + buf.writeUInt16BE(param.value || 0, offset) + offset += 2 + svcparam.encode.bytes += 2 + } else if (key == 4) { //ipv4hint + let val = param.value + if (!Array.isArray(val)) val = [val] + buf.writeUInt16BE(val.length*4, offset) + offset += 2; + svcparam.encode.bytes += 2 + + for (let host of val) { + ip.v4.encode(host, buf, offset) + offset += 4 + svcparam.encode.bytes += 4 + } + } else if (key == 5) { //echconfig + if (svcparam.ech) { + buf.writeUInt16BE(svcparam.ech.length, offset) + offset += 2 + svcparam.encode.bytes += 2 + for (let i = 0; i < svcparam.ech.length; i++) { + buf.writeUInt8(svcparam.ech[i], offset) + offset++ + } + svcparam.encode.bytes += svcparam.ech.length + } else { + buf.writeUInt16BE(param.value.length, offset) + offset += 2 + svcparam.encode.bytes += 2 + buf.write(param.value, offset) + offset += param.value.length + svcparam.encode.bytes += param.value.length + } + } else if (key == 6) { //ipv6hint + let val = param.value + if (!Array.isArray(val)) val = [val]; + buf.writeUInt16BE(val.length*16, offset) + offset += 2 + svcparam.encode.bytes += 2 + + for (let host of val) { + ip.v6.encode(host, buf, offset) + offset += 16 + svcparam.encode.bytes += 16 + } + } else if (key == 32769) { //odoh + if (svcparam.odoh) { + buf.writeUInt16BE(svcparam.odoh.length, offset) + offset += 2 + svcparam.encode.bytes += 2 + for (let i = 0; i < svcparam.odoh.length; i++) { + buf.writeUInt8(svcparam.odoh[i], offset) + offset++ + } + svcparam.encode.bytes += svcparam.odoh.length + svcparam.odoh = null + } else { + buf.writeUInt16BE(param.value.length, offset) + offset += 2 + svcparam.encode.bytes += 2 + buf.write(param.value, offset) + offset += param.value.length + svcparam.encode.bytes += param.value.length + } + } else { + // XXX: why would we ever succeed here, why not fail with an exception to let the user know? + // Unknown option + buf.writeUInt16BE(0, offset) // 0 length since we don't know how to encode + offset += 2 + svcparam.encode.bytes += 2 + } + +} + +svcparam.encode.bytes = 0; + +svcparam.decode = function (buf, offset) { + if (!offset) offset = 0 + let param = {} + let id = buf.readUInt16BE(offset) + param.key = svcparam.numberToKeyName(id) + offset += 2 + svcparam.decode.bytes = 2 + + let len = buf.readUInt16BE(offset) + offset += 2 + svcparam.decode.bytes += 2 + + // decode the svcparam value + switch (param.key.toLowerCase()) { + case 'mandatory': + { + let mp = [] + let paramsoff = offset + let rem = len + while (rem >= 2) { + let paramtype = buf.readUInt16BE(paramsoff) + mp.push(svcparam.numberToKeyName(paramtype)) + paramsoff += 2 + rem -= 2 + } + param.value = mp + } + break + case 'alpn': + { + let names = [] + let nameoff = offset + let rem = len + while (rem >= 2) { + const namelen = buf.readUint8(nameoff) + nameoff++ + rem-- + if (namelen > rem) { + throw new Error(`Invalid SVCB param ALPN length: ${namelen}. Not enough space left in buffer`) + } + names.push(buf.toString('utf-8', nameoff, nameoff+namelen)) + nameoff += namelen + rem -= namelen + } + param.value = names + } + break + case 'no-default-alpn': + // data should be empty + param.value = 0 + break + case 'port': + param.value = buf.readUInt16BE(offset) + break + case 'ipv4hint': + { + let ips = [] + let ipoff = offset + let rem = len + while (rem >= 4) { + ips.push(ip.v4.decode(buf, ipoff)) + ipoff += 4 + rem -= 4 + } + param.value = ips + } + break + case 'ipv6hint': + { + let ips = [] + let ipoff = offset + let rem = len + while (rem >= 16) { + ips.push(ip.v6.decode(buf, ipoff)) + ipoff += 16 + rem -= 16 + } + param.value = ips + } + break + default: + param.value = buf.toString('utf-8', offset, offset + len) + break + } + + offset += len + svcparam.decode.bytes += len + + return param +} + +svcparam.decode.bytes = 0; + +svcparam.encodingLength = function (param) { + // 2 bytes for type, 2 bytes for length, what's left for the value + + switch (param.key) { + case 'mandatory' : return 4 + 2*(Array.isArray(param.value) ? param.value.length : 1) + case 'alpn' : { + let val = param.value + if (!Array.isArray(val)) val = [val] + let total = val.reduce(function(result, id) { + return result += id.length + }, val.length) + return 4 + total + } + case 'no-default-alpn' : return 4 + case 'port' : return 4 + 2 + case 'ipv4hint' : return 4 + 4 * (Array.isArray(param.value) ? param.value.length : 1) + case 'echconfig' : { + if (param.needBase64Decode) { + svcparam.ech = Buffer.from(param.value, "base64") + return 4 + svcparam.ech.length + } + return 4 + param.value.length + } + case 'ipv6hint' : return 4 + 16 * (Array.isArray(param.value) ? param.value.length : 1) + case 'odoh' : { + if (param.needBase64Decode) { + svcparam.odoh = Buffer.from(param.value, "base64") + return 4 + svcparam.odoh.length + } + return 4 + param.value.length + } + case 'key65535' : return 4 + default: return 4 // unknown option + } +} + +const rhttpssvc = exports.httpssvc = {} + +rhttpssvc.encode = function(data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rhttpssvc.encodingLength(data)) + if (!offset) offset = 0 + + buf.writeUInt16BE(rhttpssvc.encodingLength(data) - 2 , offset) + offset += 2 + + buf.writeUInt16BE(data.priority || 0, offset) + rhttpssvc.encode.bytes = 4 + offset += 2 + name.encode(data.name, buf, offset) + rhttpssvc.encode.bytes += name.encode.bytes + offset += name.encode.bytes + + for (const [key, value] of Object.entries(data.values || {})) { + let val = {key, value} + svcparam.encode(val, buf, offset) + offset += svcparam.encode.bytes + rhttpssvc.encode.bytes += svcparam.encode.bytes + } + + return buf +} + +rhttpssvc.encode.bytes = 0 + +rhttpssvc.decode = function (buf, offset) { + if (!offset) offset = 0 + let rdlen = buf.readUInt16BE(offset) + let oldOffset = offset + offset += 2 + let record = {} + record.priority = buf.readUInt16BE(offset) + offset += 2 + rhttpssvc.decode.bytes = 4 + record.name = name.decode(buf, offset) + offset += name.decode.bytes + rhttpssvc.decode.bytes += name.decode.bytes + + while (rdlen > rhttpssvc.decode.bytes - 2) { + let rec1 = svcparam.decode(buf, offset) + offset += svcparam.decode.bytes + rhttpssvc.decode.bytes += svcparam.decode.bytes + if (!record.values) { + record.values = {} + } + record.values[rec1.key] = rec1.value + } + + return record +} + +rhttpssvc.decode.bytes = 0; + +rhttpssvc.encodingLength = function (data) { + let len = + 2 + // rdlen + 2 + // priority + name.encodingLength(data.name) + for (const [key, value] of Object.entries(data.values || {})) { + len += svcparam.encodingLength({key, value}) + } + return len +} + + const renc = exports.record = function (type) { switch (type.toUpperCase()) { case 'A': return ra @@ -1498,6 +1864,7 @@ const renc = exports.record = function (type) { case 'DS': return rds case 'NAPTR': return rnaptr case 'TLSA': return rtlsa + case 'HTTPS': return rhttpssvc } return runknown } diff --git a/test.js b/test.js index b8ccef0..1ce9c92 100644 --- a/test.js +++ b/test.js @@ -6,6 +6,10 @@ const rcodes = require('./rcodes') const opcodes = require('./opcodes') const optioncodes = require('./optioncodes') +const ip = require('@leichtgewicht/ip-codec') + +const hexdump = require('./hexdump') + tape('unknown', function (t) { testEncoder(t, packet.unknown, Buffer.from('hello world')) t.end() @@ -643,6 +647,244 @@ tape('tlsa', function (t) { t.end() }) + +const unhexlify = (hex) => { + return Buffer.from(hex.match(/[\da-f]{2}/gi).map(function (h) {return parseInt(h, 16)})) +} + +// +const debug_https = false +const test_http_decode_encode = (t, testname, packetbuf, expected, skip_encode=false, skip_memcmp_bufs=false) => { + const decoded = packet.httpssvc.decode(packetbuf, 0) + if (debug_https) { + console.log(`${testname}: decode:`) + console.log(JSON.stringify(decoded, null, 2)) + } + t.ok(compare(t, decoded, expected), testname + ' decode') + const encoded = packet.httpssvc.encode(expected) + if (!skip_encode) { + if (debug_https) { + console.log(`${testname}: encode:`) + hexdump(encoded) + } + if (!skip_memcmp_bufs) { + t.ok(compare(t, packetbuf, encoded), testname + ' encode memcmp') + } + // now decode the encoded buffer and check for sameness + const recoded = packet.httpssvc.decode(encoded, 0) + if (debug_https) { + console.log(`${testname}: recode`) + console.log(JSON.stringify(decoded, null, 2)) + } + t.ok(compare(t, recoded, expected), testname + ' recode') + } + return encoded +} + +tape('https svcb', function (t) { + // see https://datatracker.ietf.org/doc/rfc9460/ for the test vectors + + // https AliasMode + test_http_decode_encode(t, 'https rfc9460 case1 (AliasMode)', + unhexlify( + "00 13" + // rdata len + "00 00" + // priority + "03 66 6f 6f 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00" //target + ), + { + priority: 0, + name: 'foo.example.com' + } + ) + + // https target name is "." + test_http_decode_encode(t, 'https rfc9460 case2 (target name ".")', + unhexlify( + "00 03" + // rdata len + "00 01" + // priority + "00" // target (root label) + ), + { + priority: 1, + name: '.' + } + ) + + // https port + test_http_decode_encode(t, 'https rfc9460 case3 (port)', + unhexlify( + "00 19" + // rdata len + "00 10" + // priority + "03 66 6f 6f 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00" + // target + "00 03" + // key 3 + "00 02" + // length 2 + "00 35" // value - target (root label) + ), + { + priority: 16, + name: 'foo.example.com', + values: { + port: 53 + } + } + ) + + // https generic key and value + test_http_decode_encode(t, 'https rfc9460 case4 (generic key,val)', + unhexlify( + "00 1c" + // rdata len + "00 01" + // priority + "03 66 6f 6f 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00" + // target + "02 9b" + // key 667 + "00 05" + // length 5 + "68 65 6c 6c 6f" // value + ), + { + priority: 1, + name: 'foo.example.com', + values: { + key667: 'hello' + } + }, + true //skip_encode, we cannot encode an unknown key + ) + + // https generic key and value with decimal escape + test_http_decode_encode(t, 'https rfc9460 case5 (generic key,val with escape)', + unhexlify( + "00 20" + // rdata len + "00 01" + // priority + "03 66 6f 6f 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00" + // target + "02 9b" + // key 667 + "00 09" + // length 9 + "68 65 6c 6c 6f d2 71 6f 6f" // value + ), + { + priority: 1, + name: 'foo.example.com', + values: { + key667: unhexlify("68 65 6c 6c 6f d2 71 6f 6f").toString("utf-8") + }, + }, + true // skip_encode, we cannot encode an unknown key + ) + + // https two quoted ipv6 hints + test_http_decode_encode(t, 'https rfc9460 case6 (ipv6hint)', + unhexlify( + "00 37" + // rdata len + "00 01" + // priority + "03 66 6f 6f 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00" + // target + "00 06" + // key 6 + "00 20" + // length 32 + "20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 01" + // first address + "20 01 0d b8 00 00 00 00 00 00 00 00 00 53 00 01" // second address + ), + { + priority: 1, + name: "foo.example.com", + values: { + ipv6hint: [ + "2001:db8::1", + "2001:db8::53:1" + ] + } + } + ) + + // https ipv6 hint using embedded ipv4 syntax [2001:db8:122:344::192.0.2.33] + test_http_decode_encode(t, 'https rfc9460 case7 (ipv6hint v4 syntax)', + unhexlify( + "00 23" + // rdata len + "00 01" + // priority + "07 65 78 61 6d 70 6c 65 03 63 6f 6d 00" + // target + "00 06" + // key 6 + "00 10" + // length 16 + "20 01 0d b8 01 22 03 44 00 00 00 00 c0 00 02 21" // address + ), + { + priority: 1, + name: "example.com", + values: { + ipv6hint: [ ip.v6.decode(ip.v6.encode("2001:db8:122:344::192.0.2.33")) ] + } + } + ) + + // case 8 + // https 16 foo.example.org. ( + // alpn=h2,h3-19 mandatory=ipv4hint,alpn + // ipv4hint=192.0.2.1 + // ) + // + // note: this may not encode to the same buffer due to internal js + // ordering of the `values` object storing the params + test_http_decode_encode(t, 'https rfc9460 case8 (alpn,mandatory,ipv4hint)', + unhexlify( + "00 30" + // rdata len + "00 10" + // priority + "03 66 6f 6f 07 65 78 61 6d 70 6c 65 03 6f 72 67 00" + // target + "00 00" + // key 0 + "00 04" + // param length 4 + "00 01" + // value: key 1 + "00 04" + // value: key 4 + "00 01" + // key 1 + "00 09" + // param length 9 + "02" + // alpn length 2 + "68 32" + // alpn value + "05" + // alpn length 5 + "68 33 2d 31 39" + // alpn value + "00 04" + // key 4 + "00 04" + // param length 4 + "c0 00 02 01" // param value + ), + { + priority: 16, + name: "foo.example.org", + values: { + mandatory: [ + "alpn", + "ipv4hint" + ], + alpn: [ + "h2", + "h3-19" + ], + ipv4hint: [ + "192.0.2.1" + ] + } + }, + false, // skip_encode: false + true, // skip_memcmp_bufs: true, do not directly memcmp the resulting bufs, see comment above + ) + + testEncoder(t, packet.httpssvc, { + priority: 16, + name: "foo.example.org", + values: { + mandatory: [ + "alpn", + "ipv4hint" + ], + alpn: [ + "h2", + "h3-19" + ], + ipv4hint: [ + "192.0.2.1" + ] + } + } + ) + + + t.end() +}) + +// + + tape('unpack', function (t) { const buf = Buffer.from([ 0x00, 0x79, diff --git a/types.js b/types.js index 3cd78bd..7850cd2 100644 --- a/types.js +++ b/types.js @@ -45,6 +45,7 @@ exports.toString = function (type) { case 252: return 'AXFR' case 251: return 'IXFR' case 41: return 'OPT' + case 65: return 'HTTPS' case 255: return 'ANY' } return 'UNKNOWN_' + type @@ -95,6 +96,7 @@ exports.toType = function (name) { case 'AXFR': return 252 case 'IXFR': return 251 case 'OPT': return 41 + case 'HTTPS': return 65 case 'ANY': return 255 case '*': return 255 }