// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

'use strict';
const common = require('../common');

if (!common.hasCrypto)
  common.skip('missing crypto');

if (!common.opensslCli)
  common.skip('node compiled without OpenSSL CLI.');

// This is a rather complex test which sets up various TLS servers with node
// and connects to them using the 'openssl s_client' command line utility
// with various keys. Depending on the certificate authority and other
// parameters given to the server, the various clients are
// - rejected,
// - accepted and "unauthorized", or
// - accepted and "authorized".

const assert = require('assert');
const { spawn } = require('child_process');
const { SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION } =
  require('crypto').constants;
const tls = require('tls');
const fixtures = require('../common/fixtures');

const testCases =
  [{ title: 'Do not request certs. Everyone is unauthorized.',
     requestCert: false,
     rejectUnauthorized: false,
     renegotiate: false,
     CAs: ['ca1-cert'],
     clients:
     [{ name: 'agent1', shouldReject: false, shouldAuth: false },
      { name: 'agent2', shouldReject: false, shouldAuth: false },
      { name: 'agent3', shouldReject: false, shouldAuth: false },
      { name: 'nocert', shouldReject: false, shouldAuth: false },
     ] },

   { title: 'Allow both authed and unauthed connections with CA1',
     requestCert: true,
     rejectUnauthorized: false,
     renegotiate: false,
     CAs: ['ca1-cert'],
     clients:
    [{ name: 'agent1', shouldReject: false, shouldAuth: true },
     { name: 'agent2', shouldReject: false, shouldAuth: false },
     { name: 'agent3', shouldReject: false, shouldAuth: false },
     { name: 'nocert', shouldReject: false, shouldAuth: false },
    ] },

   { title: 'Do not request certs at connection. Do that later',
     requestCert: false,
     rejectUnauthorized: false,
     renegotiate: true,
     CAs: ['ca1-cert'],
     clients:
    [{ name: 'agent1', shouldReject: false, shouldAuth: true },
     { name: 'agent2', shouldReject: false, shouldAuth: false },
     { name: 'agent3', shouldReject: false, shouldAuth: false },
     { name: 'nocert', shouldReject: false, shouldAuth: false },
    ] },

   { title: 'Allow only authed connections with CA1',
     requestCert: true,
     rejectUnauthorized: true,
     renegotiate: false,
     CAs: ['ca1-cert'],
     clients:
    [{ name: 'agent1', shouldReject: false, shouldAuth: true },
     { name: 'agent2', shouldReject: true },
     { name: 'agent3', shouldReject: true },
     { name: 'nocert', shouldReject: true },
    ] },

   { title: 'Allow only authed connections with CA1 and CA2',
     requestCert: true,
     rejectUnauthorized: true,
     renegotiate: false,
     CAs: ['ca1-cert', 'ca2-cert'],
     clients:
    [{ name: 'agent1', shouldReject: false, shouldAuth: true },
     { name: 'agent2', shouldReject: true },
     { name: 'agent3', shouldReject: false, shouldAuth: true },
     { name: 'nocert', shouldReject: true },
    ] },


   { title: 'Allow only certs signed by CA2 but not in the CRL',
     requestCert: true,
     rejectUnauthorized: true,
     renegotiate: false,
     CAs: ['ca2-cert'],
     crl: 'ca2-crl',
     clients: [
       { name: 'agent1', shouldReject: true, shouldAuth: false },
       { name: 'agent2', shouldReject: true, shouldAuth: false },
       { name: 'agent3', shouldReject: false, shouldAuth: true },
       // Agent4 has a cert in the CRL.
       { name: 'agent4', shouldReject: true, shouldAuth: false },
       { name: 'nocert', shouldReject: true },
     ] },
  ];

function filenamePEM(n) {
  return fixtures.path('keys', `${n}.pem`);
}

function loadPEM(n) {
  return fixtures.readKey(`${n}.pem`);
}


const serverKey = loadPEM('agent2-key');
const serverCert = loadPEM('agent2-cert');


function runClient(prefix, port, options, cb) {

  // Client can connect in three ways:
  // - Self-signed cert
  // - Certificate, but not signed by CA.
  // - Certificate signed by CA.

  const args = ['s_client', '-connect', `127.0.0.1:${port}`];

  console.log(`${prefix}  connecting with`, options.name);

  switch (options.name) {
    case 'agent1':
      // Signed by CA1
      args.push('-key');
      args.push(filenamePEM('agent1-key'));
      args.push('-cert');
      args.push(filenamePEM('agent1-cert'));
      break;

    case 'agent2':
      // Self-signed
      // This is also the key-cert pair that the server will use.
      args.push('-key');
      args.push(filenamePEM('agent2-key'));
      args.push('-cert');
      args.push(filenamePEM('agent2-cert'));
      break;

    case 'agent3':
      // Signed by CA2
      args.push('-key');
      args.push(filenamePEM('agent3-key'));
      args.push('-cert');
      args.push(filenamePEM('agent3-cert'));
      break;

    case 'agent4':
      // Signed by CA2 (rejected by ca2-crl)
      args.push('-key');
      args.push(filenamePEM('agent4-key'));
      args.push('-cert');
      args.push(filenamePEM('agent4-cert'));
      break;

    case 'nocert':
      // Do not send certificate
      break;

    default:
      throw new Error(`${prefix}Unknown agent name`);
  }

  // To test use: openssl s_client -connect localhost:8000
  const client = spawn(common.opensslCli, args);

  let out = '';

  let rejected = true;
  let authed = false;
  let goodbye = false;

  client.stdout.setEncoding('utf8');
  client.stdout.on('data', function(d) {
    out += d;

    if (!goodbye && /_unauthed/.test(out)) {
      console.error(`${prefix}  * unauthed`);
      goodbye = true;
      client.kill();
      authed = false;
      rejected = false;
    }

    if (!goodbye && /_authed/.test(out)) {
      console.error(`${prefix}  * authed`);
      goodbye = true;
      client.kill();
      authed = true;
      rejected = false;
    }
  });

  client.on('exit', function(code) {
    if (options.shouldReject) {
      assert.strictEqual(
        rejected, true,
        `${prefix}${options.name} NOT rejected, but should have been`);
    } else {
      assert.strictEqual(
        rejected, false,
        `${prefix}${options.name} rejected, but should NOT have been`);
      assert.strictEqual(
        authed, options.shouldAuth,
        `${prefix}${options.name} authed is ${authed} but should have been ${
          options.shouldAuth}`);
    }

    cb();
  });
}


// Run the tests
let successfulTests = 0;
function runTest(port, testIndex) {
  const prefix = `${testIndex} `;
  const tcase = testCases[testIndex];
  if (!tcase) return;

  console.error(`${prefix}Running '${tcase.title}'`);

  const cas = tcase.CAs.map(loadPEM);

  const crl = tcase.crl ? loadPEM(tcase.crl) : null;

  const serverOptions = {
    key: serverKey,
    cert: serverCert,
    ca: cas,
    crl: crl,
    requestCert: tcase.requestCert,
    rejectUnauthorized: tcase.rejectUnauthorized
  };

  // If renegotiating - session might be resumed and openssl won't request
  // client's certificate (probably because of bug in the openssl)
  if (tcase.renegotiate) {
    serverOptions.secureOptions =
        SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION;
    // Renegotiation as a protocol feature was dropped after TLS1.2.
    serverOptions.maxVersion = 'TLSv1.2';
  }

  let renegotiated = false;
  const server = tls.Server(serverOptions, function handleConnection(c) {
    c.on('error', function(e) {
      // child.kill() leads ECONNRESET error in the TLS connection of
      // openssl s_client via spawn(). A test result is already
      // checked by the data of client.stdout before child.kill() so
      // these tls errors can be ignored.
    });
    if (tcase.renegotiate && !renegotiated) {
      renegotiated = true;
      setTimeout(function() {
        console.error(`${prefix}- connected, renegotiating`);
        c.write('\n_renegotiating\n');
        return c.renegotiate({
          requestCert: true,
          rejectUnauthorized: false
        }, function(err) {
          assert.ifError(err);
          c.write('\n_renegotiated\n');
          handleConnection(c);
        });
      }, 200);
      return;
    }

    if (c.authorized) {
      console.error(`${prefix}- authed connection: ${
        c.getPeerCertificate().subject.CN}`);
      c.write('\n_authed\n');
    } else {
      console.error(`${prefix}- unauthed connection: %s`, c.authorizationError);
      c.write('\n_unauthed\n');
    }
  });

  function runNextClient(clientIndex) {
    const options = tcase.clients[clientIndex];
    if (options) {
      runClient(`${prefix}${clientIndex} `, port, options, function() {
        runNextClient(clientIndex + 1);
      });
    } else {
      server.close();
      successfulTests++;
      runTest(0, nextTest++);
    }
  }

  server.listen(port, function() {
    port = server.address().port;
    if (tcase.debug) {
      console.error(`${prefix}TLS server running on port ${port}`);
    } else if (tcase.renegotiate) {
      runNextClient(0);
    } else {
      let clientsCompleted = 0;
      for (let i = 0; i < tcase.clients.length; i++) {
        runClient(`${prefix}${i} `, port, tcase.clients[i], function() {
          clientsCompleted++;
          if (clientsCompleted === tcase.clients.length) {
            server.close();
            successfulTests++;
            runTest(0, nextTest++);
          }
        });
      }
    }
  });
}


let nextTest = 0;
runTest(0, nextTest++);


process.on('exit', function() {
  assert.strictEqual(successfulTests, testCases.length);
});
