/**
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 *
 */
package org.apache.kerby.kerberos.kerb.client.preauth.pkinit;

import org.apache.kerby.KOptions;
import org.apache.kerby.asn1.type.Asn1Integer;
import org.apache.kerby.asn1.type.Asn1ObjectIdentifier;
import org.apache.kerby.cms.type.CertificateChoices;
import org.apache.kerby.cms.type.CertificateSet;
import org.apache.kerby.cms.type.ContentInfo;
import org.apache.kerby.cms.type.SignedData;
import org.apache.kerby.kerberos.kerb.KrbCodec;
import org.apache.kerby.kerberos.kerb.KrbErrorCode;
import org.apache.kerby.kerberos.kerb.KrbException;
import org.apache.kerby.kerberos.kerb.client.KrbContext;
import org.apache.kerby.kerberos.kerb.client.PkinitOption;
import org.apache.kerby.kerberos.kerb.client.preauth.AbstractPreauthPlugin;
import org.apache.kerby.kerberos.kerb.client.request.KdcRequest;
import org.apache.kerby.kerberos.kerb.common.CheckSumUtil;
import org.apache.kerby.kerberos.kerb.common.KrbUtil;
import org.apache.kerby.kerberos.kerb.crypto.dh.DhGroup;
import org.apache.kerby.kerberos.kerb.crypto.dh.DiffieHellmanClient;
import org.apache.kerby.kerberos.kerb.preauth.PaFlag;
import org.apache.kerby.kerberos.kerb.preauth.PaFlags;
import org.apache.kerby.kerberos.kerb.preauth.PluginRequestContext;
import org.apache.kerby.kerberos.kerb.preauth.pkinit.CertificateHelper;
import org.apache.kerby.kerberos.kerb.preauth.pkinit.CmsMessageType;
import org.apache.kerby.kerberos.kerb.preauth.pkinit.PkinitCrypto;
import org.apache.kerby.kerberos.kerb.preauth.pkinit.PkinitIdentity;
import org.apache.kerby.kerberos.kerb.preauth.pkinit.PkinitPlgCryptoContext;
import org.apache.kerby.kerberos.kerb.preauth.pkinit.PkinitPreauthMeta;
import org.apache.kerby.kerberos.kerb.type.KerberosTime;
import org.apache.kerby.kerberos.kerb.type.base.CheckSum;
import org.apache.kerby.kerberos.kerb.type.base.CheckSumType;
import org.apache.kerby.kerberos.kerb.type.base.EncryptionKey;
import org.apache.kerby.kerberos.kerb.type.base.EncryptionType;
import org.apache.kerby.kerberos.kerb.type.base.PrincipalName;
import org.apache.kerby.kerberos.kerb.type.pa.PaData;
import org.apache.kerby.kerberos.kerb.type.pa.PaDataEntry;
import org.apache.kerby.kerberos.kerb.type.pa.PaDataType;
import org.apache.kerby.kerberos.kerb.type.pa.pkinit.AuthPack;
import org.apache.kerby.kerberos.kerb.type.pa.pkinit.DhRepInfo;
import org.apache.kerby.kerberos.kerb.type.pa.pkinit.KdcDhKeyInfo;
import org.apache.kerby.kerberos.kerb.type.pa.pkinit.PaPkAsRep;
import org.apache.kerby.kerberos.kerb.type.pa.pkinit.PaPkAsReq;
import org.apache.kerby.kerberos.kerb.type.pa.pkinit.PkAuthenticator;
import org.apache.kerby.kerberos.kerb.type.pa.pkinit.TrustedCertifiers;
import org.apache.kerby.x509.type.AlgorithmIdentifier;
import org.apache.kerby.x509.type.Certificate;
import org.apache.kerby.x509.type.DhParameter;
import org.apache.kerby.x509.type.SubjectPublicKeyInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.crypto.interfaces.DHPublicKey;
import javax.crypto.spec.DHParameterSpec;
import java.io.IOException;
import java.math.BigInteger;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.List;

public class PkinitPreauth extends AbstractPreauthPlugin {
    private static final Logger LOG = LoggerFactory.getLogger(PkinitPreauth.class);

    private PkinitContext pkinitContext;

    public PkinitPreauth() {
        super(new PkinitPreauthMeta());
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void init(KrbContext context) {
        super.init(context);
        this.pkinitContext = new PkinitContext();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public PluginRequestContext initRequestContext(KdcRequest kdcRequest) {
        PkinitRequestContext reqCtx = new PkinitRequestContext();

        reqCtx.updateRequestOpts(pkinitContext.getPluginOpts());

        return reqCtx;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void setPreauthOptions(KdcRequest kdcRequest,
                                  PluginRequestContext requestContext,
                                  KOptions options) {
        if (options.contains(PkinitOption.X509_IDENTITY)) {
            pkinitContext.getIdentityOpts().setIdentity(options.getStringOption(PkinitOption.X509_IDENTITY));
        }

        if (options.contains(PkinitOption.X509_ANCHORS)) {
            String anchorsString = options.getStringOption(PkinitOption.X509_ANCHORS);

            List<String> anchors;
            if (anchorsString == null) {
                anchors = kdcRequest.getContext().getConfig().getPkinitAnchors();
            } else {
                anchors = Arrays.asList(anchorsString);
            }
            pkinitContext.getIdentityOpts().getAnchors().addAll(anchors);
        }

        if (options.contains(PkinitOption.USING_RSA)) {
            pkinitContext.getPluginOpts().setUsingRsa(options.getBooleanOption(PkinitOption.USING_RSA, true));
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void prepareQuestions(KdcRequest kdcRequest,
                                 PluginRequestContext requestContext) {

        PkinitRequestContext reqCtx = (PkinitRequestContext) requestContext;

        if (!reqCtx.isIdentityInitialized()) {
            PkinitIdentity.initialize(reqCtx.getIdentityOpts(), kdcRequest.getClientPrincipal());
            reqCtx.setIdentityInitialized(true);
        }

        // Might have questions asking for password to access the private key
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void tryFirst(KdcRequest kdcRequest,
                         PluginRequestContext requestContext,
                         PaData outPadata) throws KrbException {

        /* XXX PKINIT RFC says that nonce in PKAuthenticator doesn't have be the
         * same as in the AS_REQ. However, if we pick a different nonce, then we
         * need to remember that info when AS_REP is returned. Here choose to
         * reuse the AS_REQ nonce.
         */
        int nonce = kdcRequest.getChosenNonce();

        // Get the current time
        long now = System.currentTimeMillis();
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(new Date(now));
        int cusec = calendar.get(Calendar.SECOND);
        KerberosTime ctime = new KerberosTime(now);

        /* checksum of the encoded KDC-REQ-BODY */
        CheckSum checkSum = null;
        try {
            checkSum = CheckSumUtil.makeCheckSum(CheckSumType.NIST_SHA,
                KrbCodec.encode(kdcRequest.getKdcReq().getReqBody()));
        } catch (KrbException e) {
            throw new KrbException("Fail to encode checksum.", e);
        }

        PaPkAsReq paPkAsReq = makePaPkAsReq(kdcRequest, (PkinitRequestContext) requestContext,
                cusec, ctime, nonce, checkSum);
        outPadata.addElement(makeEntry(paPkAsReq));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean process(KdcRequest kdcRequest,
                           PluginRequestContext requestContext,
                           PaDataEntry inPadata,
                           PaData outPadata) throws KrbException {

        PkinitRequestContext reqCtx = (PkinitRequestContext) requestContext;
        if (inPadata == null) {
            return false;
        }

        boolean processingRequest = false;
        switch (inPadata.getPaDataType()) {
            case PK_AS_REQ:
                processingRequest = true;
                break;
            case PK_AS_REP:
            default:
                break;
        }

        if (processingRequest) {
            generateRequest(reqCtx, kdcRequest, outPadata);
            return true;
        } else {
            EncryptionType encType = kdcRequest.getEncType();
            processReply(kdcRequest, reqCtx, inPadata, encType);
            return true;
        }
    }

    @SuppressWarnings("unused")
    private void generateRequest(PkinitRequestContext reqCtx, KdcRequest kdcRequest,
                                 PaData outPadata) {

    }

    @SuppressWarnings("unused")
    private PaPkAsReq makePaPkAsReq(KdcRequest kdcRequest,
                                    PkinitRequestContext reqCtx,
                                    int cusec, KerberosTime ctime, int nonce, CheckSum checkSum) throws KrbException {
        LOG.info("Making the PK_AS_REQ.");
        PaPkAsReq paPkAsReq = new PaPkAsReq();
        AuthPack authPack = new AuthPack();
        PkAuthenticator pkAuthen = new PkAuthenticator();

        boolean usingRsa = pkinitContext.getPluginOpts().isUsingRsa();
        reqCtx.setPaType(PaDataType.PK_AS_REQ);

        pkAuthen.setCusec(cusec);
        pkAuthen.setCtime(ctime);
        pkAuthen.setNonce(nonce);
        pkAuthen.setPaChecksum(checkSum.getChecksum());
        authPack.setPkAuthenticator(pkAuthen);
        authPack.setsupportedCmsTypes(pkinitContext.getPluginOpts().createSupportedCMSTypes());

        if (!usingRsa) {
            // DH case
            LOG.info("DH key transport algorithm.");

            String content = "0x06 07 2A 86 48 ce 3e 02 01";
            Asn1ObjectIdentifier dhOid = PkinitCrypto.createOid(content);
            AlgorithmIdentifier dhAlg = new AlgorithmIdentifier();
            dhAlg.setAlgorithm(dhOid.getValue());

            DiffieHellmanClient client = new DiffieHellmanClient();

            DHPublicKey clientPubKey = null;
            try {
                clientPubKey = client.init(DhGroup.MODP_GROUP2);
            } catch (Exception e) {
                LOG.error("DiffieHellmanClient init with failure. " + e);
            }

            reqCtx.setDhClient(client);

            DHParameterSpec type = null;
            try {
                type = clientPubKey.getParams();
            } catch (Exception e) {
                LOG.error("Fail to get params from client public key. " + e);
            }
            BigInteger q = type.getP().shiftRight(1);
            DhParameter dhParameter = new DhParameter();
            dhParameter.setP(type.getP());
            dhParameter.setG(type.getG());
            dhParameter.setQ(q);
            dhAlg.setParameters(dhParameter);

            SubjectPublicKeyInfo pubInfo = new SubjectPublicKeyInfo();
            pubInfo.setAlgorithm(dhAlg);

            Asn1Integer publickey = new Asn1Integer(clientPubKey.getY());
            pubInfo.setSubjectPubKey(KrbCodec.encode(publickey));

            authPack.setClientPublicValue(pubInfo);

            // DhNonce dhNonce = new DhNonce();
            // authPack.setClientDhNonce(dhNonce);
            byte[] signedAuthPack = signAuthPack(authPack);
            paPkAsReq.setSignedAuthPack(signedAuthPack);

        } else {
            LOG.info("RSA key transport algorithm");
            // authPack.setClientPublicValue(null);
        }

        TrustedCertifiers trustedCertifiers = pkinitContext.getPluginOpts().createTrustedCertifiers();
        paPkAsReq.setTrustedCertifiers(trustedCertifiers);

        // byte[] kdcPkId = pkinitContext.pluginOpts.createIssuerAndSerial();
        // paPkAsReq.setKdcPkId(kdcPkId);

        return paPkAsReq;
    }

    private byte[] signAuthPack(AuthPack authPack) throws KrbException {

        String oid = PkinitPlgCryptoContext.getIdPkinitAuthDataOID();

        byte[] signedDataBytes = PkinitCrypto.eContentInfoCreate(
                KrbCodec.encode(authPack), oid);

        return signedDataBytes;
    }

    private void processReply(KdcRequest kdcRequest,
                              PkinitRequestContext reqCtx,
                              PaDataEntry paEntry,
                              EncryptionType encType) throws KrbException {

        // Parse PA-PK-AS-REP message.
        if (paEntry.getPaDataType() == PaDataType.PK_AS_REP) {
            LOG.info("processing PK_AS_REP");

            PaPkAsRep paPkAsRep = KrbCodec.decode(paEntry.getPaDataValue(), PaPkAsRep.class);
            DhRepInfo dhRepInfo = paPkAsRep.getDHRepInfo();

            byte[] dhSignedData = dhRepInfo.getDHSignedData();

            ContentInfo contentInfo = new ContentInfo();
            try {
                contentInfo.decode(dhSignedData);
            } catch (IOException e) {
                LOG.error("Fail to decode dhSignedData. " + e);
            }

            SignedData signedData = contentInfo.getContentAs(SignedData.class);

            PkinitCrypto.verifyCmsSignedData(
                    CmsMessageType.CMS_SIGN_SERVER, signedData);

            if (kdcRequest.getContext().getConfig().getPkinitAnchors().isEmpty()) {
                LOG.error("No PKINIT anchors specified");
                throw new KrbException("No PKINIT anchors specified");
            }
            String anchorFileName = kdcRequest.getContext().getConfig().getPkinitAnchors().get(0);

            X509Certificate x509Certificate = null;
            try {
                List<java.security.cert.Certificate> certs =
                    CertificateHelper.loadCerts(anchorFileName);
                if (certs != null && !certs.isEmpty()) {
                    x509Certificate = (X509Certificate) certs.iterator().next();
                }
            } catch (KrbException e) {
                LOG.error("Fail to load certs from archor file. " + e);
            }

            if (x509Certificate == null) {
                LOG.error("Failed to load PKINIT anchor");
                throw new KrbException("Failed to load PKINIT anchor");
            }

            CertificateSet certificateSet = signedData.getCertificates();
            if (certificateSet == null || certificateSet.getElements().isEmpty()) {
                throw new KrbException("No PKINIT Certs");
            }
            List<Certificate> certificates = new ArrayList<>();
            List<CertificateChoices> certificateChoicesList = certificateSet.getElements();
            for (CertificateChoices certificateChoices : certificateChoicesList) {
                certificates.add(certificateChoices.getCertificate());
            }

            try {
                PkinitCrypto.validateChain(certificates, x509Certificate);
            } catch (Exception e) {
                throw new KrbException(KrbErrorCode.KDC_ERR_INVALID_CERTIFICATE, e);
            }

            PrincipalName kdcPrincipal = KrbUtil.makeTgsPrincipal(
                    kdcRequest.getContext().getConfig().getKdcRealm());
            //TODO USE CertificateSet
            boolean validSan = PkinitCrypto.verifyKdcSan(
                    kdcRequest.getContext().getConfig().getPkinitKdcHostName(), kdcPrincipal,
                    certificates);
            if (!validSan) {
                LOG.error("Did not find an acceptable SAN in KDC certificate");
            }

            LOG.info("skipping EKU check");

            LOG.info("as_rep: DH key transport algorithm");
            KdcDhKeyInfo kdcDhKeyInfo = new KdcDhKeyInfo();
            try {
                kdcDhKeyInfo.decode(signedData.getEncapContentInfo().getContent());
            } catch (IOException e) {
                String errMessage = "failed to decode KdcDhKeyInfo " + e.getMessage();
                LOG.error(errMessage);
                throw new KrbException(errMessage);
            }

            byte[] subjectPublicKey = kdcDhKeyInfo.getSubjectPublicKey().getValue();

            Asn1Integer clientPubKey = KrbCodec.decode(subjectPublicKey, Asn1Integer.class);
            BigInteger y = clientPubKey.getValue();

            DiffieHellmanClient client = reqCtx.getDhClient();
            BigInteger p = client.getDhParam().getP();
            BigInteger g = client.getDhParam().getG();

            DHPublicKey dhPublicKey = PkinitCrypto.createDHPublicKey(p, g, y);

            EncryptionKey secretKey = null;
            try {
                client.doPhase(dhPublicKey.getEncoded());
                secretKey = client.generateKey(null, null, encType);
            } catch (Exception e) {
                LOG.error("DiffieHellmanClient do parse failed. " + e);
            }
            // Set the DH shared key as the client key
            if (secretKey == null) {
                throw new KrbException("Fail to create client key.");
            } else {
                kdcRequest.setAsKey(secretKey);
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean tryAgain(KdcRequest kdcRequest,
                            PluginRequestContext requestContext,
                            PaDataType preauthType,
                            PaData errPadata,
                            PaData outPadata) {

        PkinitRequestContext reqCtx = (PkinitRequestContext) requestContext;
        if (reqCtx.getPaType() != preauthType && errPadata == null) {
            return false;
        }

        boolean doAgain = false;
        for (PaDataEntry pde : errPadata.getElements()) {
            //   switch (pde.getPaDataType()) {
            // TODO
            //    }
            System.out.println(pde.getPaDataType());
        }

        if (doAgain) {
            generateRequest(reqCtx, kdcRequest, outPadata);
        }

        return false;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public PaFlags getFlags(PaDataType paType) {
        PaFlags paFlags = new PaFlags(0);
        paFlags.setFlag(PaFlag.PA_REAL);

        return paFlags;
    }

    /**
     * Make padata entry.
     *
     * @param paPkAsReq The PaPkAsReq
     * @return PaDataEntry to be made.
     */
    private PaDataEntry makeEntry(PaPkAsReq paPkAsReq) throws KrbException {
        PaDataEntry paDataEntry = new PaDataEntry();
        paDataEntry.setPaDataType(PaDataType.PK_AS_REQ);
        paDataEntry.setPaDataValue(KrbCodec.encode(paPkAsReq));
        return paDataEntry;
    }
}
