// SPDX-FileCopyrightText: 2022 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0

package org.pgpainless.decryption_verification;

import static org.bouncycastle.bcpg.PacketTags.COMPRESSED_DATA;
import static org.bouncycastle.bcpg.PacketTags.EXPERIMENTAL_1;
import static org.bouncycastle.bcpg.PacketTags.EXPERIMENTAL_2;
import static org.bouncycastle.bcpg.PacketTags.EXPERIMENTAL_3;
import static org.bouncycastle.bcpg.PacketTags.EXPERIMENTAL_4;
import static org.bouncycastle.bcpg.PacketTags.LITERAL_DATA;
import static org.bouncycastle.bcpg.PacketTags.MARKER;
import static org.bouncycastle.bcpg.PacketTags.MOD_DETECTION_CODE;
import static org.bouncycastle.bcpg.PacketTags.ONE_PASS_SIGNATURE;
import static org.bouncycastle.bcpg.PacketTags.PUBLIC_KEY;
import static org.bouncycastle.bcpg.PacketTags.PUBLIC_KEY_ENC_SESSION;
import static org.bouncycastle.bcpg.PacketTags.PUBLIC_SUBKEY;
import static org.bouncycastle.bcpg.PacketTags.RESERVED;
import static org.bouncycastle.bcpg.PacketTags.SECRET_KEY;
import static org.bouncycastle.bcpg.PacketTags.SECRET_SUBKEY;
import static org.bouncycastle.bcpg.PacketTags.SIGNATURE;
import static org.bouncycastle.bcpg.PacketTags.SYMMETRIC_KEY_ENC;
import static org.bouncycastle.bcpg.PacketTags.SYMMETRIC_KEY_ENC_SESSION;
import static org.bouncycastle.bcpg.PacketTags.SYM_ENC_INTEGRITY_PRO;
import static org.bouncycastle.bcpg.PacketTags.TRUST;
import static org.bouncycastle.bcpg.PacketTags.USER_ATTRIBUTE;
import static org.bouncycastle.bcpg.PacketTags.USER_ID;

import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;

import org.bouncycastle.bcpg.BCPGInputStream;
import org.bouncycastle.openpgp.PGPCompressedData;
import org.bouncycastle.openpgp.PGPEncryptedData;
import org.bouncycastle.openpgp.PGPLiteralData;
import org.bouncycastle.openpgp.PGPOnePassSignature;
import org.pgpainless.algorithm.CompressionAlgorithm;
import org.pgpainless.algorithm.HashAlgorithm;
import org.pgpainless.algorithm.PublicKeyAlgorithm;
import org.pgpainless.algorithm.SignatureType;
import org.pgpainless.algorithm.StreamEncoding;
import org.pgpainless.algorithm.SymmetricKeyAlgorithm;

public class OpenPgpInputStream extends BufferedInputStream {

    @SuppressWarnings("CharsetObjectCanBeUsed")
    private static final byte[] ARMOR_HEADER = "-----BEGIN PGP ".getBytes(Charset.forName("UTF8"));

    // Buffer beginning bytes of the data
    public static final int MAX_BUFFER_SIZE = 8192 * 2;

    private final byte[] buffer;
    private final int bufferLen;

    private boolean containsArmorHeader;
    private boolean containsOpenPgpPackets;
    private boolean isLikelyOpenPgpMessage;

    public OpenPgpInputStream(InputStream in, boolean check) throws IOException {
        super(in, MAX_BUFFER_SIZE);

        mark(MAX_BUFFER_SIZE);
        buffer = new byte[MAX_BUFFER_SIZE];
        bufferLen = read(buffer);
        reset();

        if (check) {
            inspectBuffer();
        }
    }

    public OpenPgpInputStream(InputStream in) throws IOException {
        this(in, true);
    }

    private void inspectBuffer() throws IOException {
        if (checkForAsciiArmor()) {
            return;
        }

        checkForBinaryOpenPgp();
    }

    private boolean checkForAsciiArmor() {
        if (startsWithIgnoringWhitespace(buffer, ARMOR_HEADER, bufferLen)) {
            containsArmorHeader = true;
            return true;
        }
        return false;
    }

    /**
     * This method is still brittle.
     * Basically we try to parse OpenPGP packets from the buffer.
     * If we run into exceptions, then we know that the data is non-OpenPGP'ish.
     *
     * This breaks down though if we read plausible garbage where the data accidentally makes sense,
     * or valid, yet incomplete packets (remember, we are still only working on a portion of the data).
     */
    private void checkForBinaryOpenPgp() throws IOException {
        if (bufferLen == -1) {
            // Empty data
            return;
        }

        ByteArrayInputStream bufferIn = new ByteArrayInputStream(buffer, 0, bufferLen);
        nonExhaustiveParseAndCheckPlausibility(bufferIn);
    }

    private void nonExhaustiveParseAndCheckPlausibility(ByteArrayInputStream bufferIn) throws IOException {
        // Read the packet header
        int hdr = bufferIn.read();
        if (hdr < 0 || (hdr & 0x80) == 0) {
            return;
        }

        boolean newPacket = (hdr & 0x40) != 0;
        int        tag = 0;
        int        bodyLen = 0;
        boolean    partial = false;

        // Determine the packet length
        if (newPacket) {
            tag = hdr & 0x3f;

            int    l = bufferIn.read();
            if (l < 192) {
                bodyLen = l;
            } else if (l <= 223) {
                int b = bufferIn.read();
                bodyLen = ((l - 192) << 8) + (b) + 192;
            } else if (l == 255) {
                bodyLen = (bufferIn.read() << 24) | (bufferIn.read() << 16) |  (bufferIn.read() << 8)  | bufferIn.read();
            } else {
                partial = true;
                bodyLen = 1 << (l & 0x1f);
            }
        } else {
            int lengthType = hdr & 0x3;
            tag = (hdr & 0x3f) >> 2;
            switch (lengthType) {
                case 0:
                    bodyLen = bufferIn.read();
                    break;
                case 1:
                    bodyLen = (bufferIn.read() << 8) | bufferIn.read();
                    break;
                case 2:
                    bodyLen = (bufferIn.read() << 24) | (bufferIn.read() << 16) | (bufferIn.read() << 8) | bufferIn.read();
                    break;
                case 3:
                    partial = true;
                    break;
                default:
                    return;
            }
        }

        // Negative body length -> garbage
        if (bodyLen < 0) {
            return;
        }

        // Try to unexhaustively parse the first packet bit by bit and check for plausibility
        BCPGInputStream bcpgIn = new BCPGInputStream(bufferIn);
        switch (tag) {
            case RESERVED:
                // How to handle this? Probably discard as garbage...
                return;

            case PUBLIC_KEY_ENC_SESSION:
                int pkeskVersion = bcpgIn.read();
                if (pkeskVersion <= 0 || pkeskVersion > 5) {
                    return;
                }

                // Skip Key-ID
                for (int i = 0; i < 8; i++) {
                    bcpgIn.read();
                }

                int pkeskAlg = bcpgIn.read();
                if (PublicKeyAlgorithm.fromId(pkeskAlg) == null) {
                    return;
                }

                containsOpenPgpPackets = true;
                isLikelyOpenPgpMessage = true;
                break;

            case SIGNATURE:
                int sigVersion = bcpgIn.read();
                int sigType;
                if (sigVersion == 2 || sigVersion == 3) {
                    int l = bcpgIn.read();
                    sigType = bcpgIn.read();
                } else if (sigVersion == 4 || sigVersion == 5) {
                    sigType = bcpgIn.read();
                } else {
                    return;
                }

                try {
                    SignatureType.valueOf(sigType);
                } catch (IllegalArgumentException e) {
                    return;
                }

                containsOpenPgpPackets = true;
                break;

            case SYMMETRIC_KEY_ENC_SESSION:
                int skeskVersion = bcpgIn.read();
                if (skeskVersion == 4) {
                    int skeskAlg = bcpgIn.read();
                    if (SymmetricKeyAlgorithm.fromId(skeskAlg) == null) {
                        return;
                    }
                    // TODO: Parse S2K?
                } else {
                    return;
                }
                containsOpenPgpPackets = true;
                isLikelyOpenPgpMessage = true;
                break;

            case ONE_PASS_SIGNATURE:
                int opsVersion = bcpgIn.read();
                if (opsVersion == 3) {
                    int opsSigType = bcpgIn.read();
                    try {
                        SignatureType.valueOf(opsSigType);
                    } catch (IllegalArgumentException e) {
                        return;
                    }
                    int opsHashAlg = bcpgIn.read();
                    if (HashAlgorithm.fromId(opsHashAlg) == null) {
                        return;
                    }
                    int opsKeyAlg = bcpgIn.read();
                    if (PublicKeyAlgorithm.fromId(opsKeyAlg) == null) {
                        return;
                    }
                } else {
                    return;
                }

                containsOpenPgpPackets = true;
                isLikelyOpenPgpMessage = true;
                break;

            case SECRET_KEY:
            case PUBLIC_KEY:
            case SECRET_SUBKEY:
            case PUBLIC_SUBKEY:
                int keyVersion = bcpgIn.read();
                for (int i = 0; i < 4; i++) {
                    // Creation time
                    bcpgIn.read();
                }
                if (keyVersion == 3) {
                    long validDays = (in.read() << 8) | in.read();
                    if (validDays < 0) {
                        return;
                    }
                } else if (keyVersion == 4) {

                } else if (keyVersion == 5) {

                } else {
                    return;
                }
                int keyAlg = bcpgIn.read();
                if (PublicKeyAlgorithm.fromId(keyAlg) == null) {
                    return;
                }

                containsOpenPgpPackets = true;
                break;

            case COMPRESSED_DATA:
                int compAlg = bcpgIn.read();
                if (CompressionAlgorithm.fromId(compAlg) == null) {
                    return;
                }

                containsOpenPgpPackets = true;
                isLikelyOpenPgpMessage = true;
                break;

            case SYMMETRIC_KEY_ENC:
                // No data to compare :(
                containsOpenPgpPackets = true;
                // While this is a valid OpenPGP message, enabling the line below would lead to too many false positives
                // isLikelyOpenPgpMessage = true;
                break;

            case MARKER:
                byte[] marker = new byte[3];
                bcpgIn.readFully(marker);
                if (marker[0] != 0x50 || marker[1] != 0x47 || marker[2] != 0x50) {
                    return;
                }

                containsOpenPgpPackets = true;
                break;

            case LITERAL_DATA:
                int format = bcpgIn.read();
                if (StreamEncoding.fromCode(format) == null) {
                    return;
                }

                containsOpenPgpPackets = true;
                isLikelyOpenPgpMessage = true;
                break;

            case TRUST:
            case USER_ID:
            case USER_ATTRIBUTE:
                // Not much to compare
                containsOpenPgpPackets = true;
                break;

            case SYM_ENC_INTEGRITY_PRO:
                int seipVersion = bcpgIn.read();
                if (seipVersion != 1) {
                    return;
                }
                isLikelyOpenPgpMessage = true;
                containsOpenPgpPackets = true;
                break;

            case MOD_DETECTION_CODE:
                byte[] digest = new byte[20];
                bcpgIn.readFully(digest);

                containsOpenPgpPackets = true;
                break;

            case EXPERIMENTAL_1:
            case EXPERIMENTAL_2:
            case EXPERIMENTAL_3:
            case EXPERIMENTAL_4:
                return;
            default:
                containsOpenPgpPackets = false;
                break;
        }
    }

    private boolean startsWithIgnoringWhitespace(byte[] bytes, byte[] subsequence, int bufferLen) {
        if (bufferLen == -1) {
            return false;
        }

        for (int i = 0; i < bufferLen; i++) {
            // Working on bytes is not trivial with unicode data, but its good enough here
            if (Character.isWhitespace(bytes[i])) {
                continue;
            }

            if ((i + subsequence.length) > bytes.length) {
                return false;
            }

            for (int j = 0; j < subsequence.length; j++) {
                if (bytes[i + j] != subsequence[j]) {
                    return false;
                }
            }
            return true;
        }
        return false;
    }

    public boolean isAsciiArmored() {
        return containsArmorHeader;
    }

    /**
     * Return true, if the data is possibly binary OpenPGP.
     * The criterion for this are less strict than for {@link #isLikelyOpenPgpMessage()},
     * as it also accepts other OpenPGP packets at the beginning of the data stream.
     *
     * Use with caution.
     *
     * @return true if data appears to be binary OpenPGP data
     */
    public boolean isBinaryOpenPgp() {
        return containsOpenPgpPackets;
    }

    /**
     * Returns true, if the underlying data is very likely (more than 99,9%) an OpenPGP message.
     * OpenPGP Message means here that it starts with either an {@link PGPEncryptedData},
     * {@link PGPCompressedData}, {@link PGPOnePassSignature} or {@link PGPLiteralData} packet.
     * The plausability of these data packets is checked as far as possible.
     *
     * @return true if likely OpenPGP message
     */
    public boolean isLikelyOpenPgpMessage() {
        return isLikelyOpenPgpMessage;
    }

    public boolean isNonOpenPgp() {
        return !isAsciiArmored() && !isBinaryOpenPgp();
    }
}
