local nmap = require "nmap"
local shortport = require "shortport"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"
local sslcert = require "sslcert"
local tls = require "tls"

description = [[
Enumerates a TLS server's supported application-layer protocols using the ALPN protocol.

Repeated queries are sent to determine which of the registered protocols are supported.

For more information, see:
* https://tools.ietf.org/html/rfc7301
]]

---
-- @usage
-- nmap --script=tls-alpn <targets>
--
--@output
-- 443/tcp open  https
-- | tls-alpn:
-- |   h2
-- |   spdy/3
-- |_  http/1.1
--
-- @xmloutput
-- <elem>h2</elem>
-- <elem>spdy/3</elem>
-- <elem>http/1.1</elem>


author = "Daniel Miller"

license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

categories = {"discovery", "safe", "default"}
dependencies = {"https-redirect"}

portrule = function(host, port)
  return shortport.ssl(host, port) or sslcert.getPrepareTLSWithoutReconnect(port)
end


local ALPN_NAME = "application_layer_protocol_negotiation"

--- Function that sends a client hello packet with the TLS ALPN extension to the
-- target host and returns the response
--@args host The target host table.
--@args port The target port table.
--@return status true if response, false else.
--@return response if status is true.
local client_hello = function(host, port, protos)
  local sock, status, response, err, cli_h

  cli_h = tls.client_hello({
      -- TLSv1.3 does not send this extension plaintext.
      -- TODO: implement key exchange crypto to retrieve encrypted extensions
      protocol = "TLSv1.2",
      ["extensions"] = {
        [ALPN_NAME] = tls.EXTENSION_HELPERS[ALPN_NAME](protos)
      },
    })

  -- Connect to the target server
  local status, err
  local sock
  local specialized = sslcert.getPrepareTLSWithoutReconnect(port)
  if specialized then
    status, sock = specialized(host, port)
    if not status then
      stdnse.debug1("Connection to server failed: %s", sock)
      return false
    end
  else
    sock = nmap.new_socket()
    status, err = sock:connect(host, port)
    if not status then
      stdnse.debug1("Connection to server failed: %s", err)
      return false
    end
  end

  sock:set_timeout(5000)

  -- Send Client Hello to the target server
  status, err = sock:send(cli_h)
  if not status then
    stdnse.debug1("Couldn't send: %s", err)
    sock:close()
    return false
  end

  -- Read response
  status, response, err = tls.record_buffer(sock)
  if not status then
    stdnse.debug1("Couldn't receive: %s", err)
    sock:close()
    return false
  end

  return true, response
end

--- Function that checks for the returned protocols to a ALPN extension request.
--@args response Response to parse.
--@return results List of found protocols.
local check_alpn = function(response)
  local i, record = tls.record_read(response, 1)
  if record == nil then
    stdnse.debug1("Unknown response from server")
    return nil
  end

  if record.type == "handshake" and record.body[1].type == "server_hello" then
    if record.body[1].extensions == nil then
      stdnse.debug1("Server did not return TLS ALPN extension.")
      return nil
    end
    local results = {}
    local alpndata = record.body[1].extensions[ALPN_NAME]
    if alpndata == nil then
      stdnse.debug1("Server did not return TLS ALPN extension.")
      return nil
    end
    -- Parse data
    alpndata = string.unpack(">s2", alpndata, 1)
    i = 1
    while i <= #alpndata do
      if i > 1 then
        stdnse.debug1("Server sent multiple protocols but RFC only permits 1")
      end
      local protocol
      protocol, i = string.unpack(">s1", alpndata, i)
      table.insert(results, protocol)
    end

    if next(results) then
      return results
    else
      stdnse.debug1("Server supports TLS ALPN extension, but no protocols were offered.")
      return nil
    end
  else
    stdnse.debug1("Server response was not server_hello")
    return nil
  end
end

local function find_and_remove(t, value)
  for i, v in ipairs(t) do
    if v == value then
      table.remove(t, i)
      return true
    end
  end
  return false
end

action = function(host, port)
  local alpn_protos = {
    -- IANA-registered names
    -- https://www.iana.org/assignments/tls-extensiontype-values/alpn-protocol-ids.csv
    -- Last-Modified: Thu, 31 Oct 2019 22:30:11 GMT
    "http/0.9",
    "http/1.0",
    "http/1.1",
    "spdy/1",
    "spdy/2",
    "spdy/3",
    "stun.turn",
    "stun.nat-discovery",
    "h2",
    "h2c", -- should never be negotiated over TLS
    "webrtc",
    "c-webrtc",
    "ftp",
    "imap",
    "pop3",
    "managesieve",
    "coap",
    "xmpp-client",
    "xmpp-server",
    "acme-tls/1",
    "mqtt",
    "dot",
    "ntske/1",
    "sunrpc",
    "h3",
    "smb",
    "irc",
    "nntp",
    "nnsp",
    "doq",
    -- Other sources
    "grpc-exp", -- gRPC, see grpc.io
  }

  local chosen = {}
  local unique = {}
  while next(alpn_protos) do
    -- Send crafted client hello
    local status, response = client_hello(host, port, alpn_protos)
    if status and response then
      -- Analyze response
      local result = check_alpn(response)
      if not result then
        stdnse.debug1("None of %d protocols chosen", #alpn_protos)
        goto ALPN_DONE
      end
      for i, p in ipairs(result) do
        if i > 1 then
          stdnse.verbose1("Server violates RFC: sent additional protocol %s", p)
        else
          if not unique[p] then
            chosen[#chosen+1] = p
          end
          unique[p] = true
          if not find_and_remove(alpn_protos, p) then
            stdnse.debug1("Chosen ALPN protocol %s was not offered", p)
            -- Server is forcing this protocol, no need to continue offering.
            goto ALPN_DONE
          end
        end
      end
    else
      stdnse.debug1("Client hello failed with %d protocols", #alpn_protos)
      goto ALPN_DONE
    end
  end
  ::ALPN_DONE::
  if next(chosen) then
    return chosen
  end
end
