// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package pki

import (
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/asn1"
	"encoding/base64"
	"fmt"
	"math/big"
	"testing"
	"time"

	"github.com/hashicorp/vault/sdk/helper/testhelpers/schema"

	"github.com/hashicorp/vault/api"
	vaulthttp "github.com/hashicorp/vault/http"
	"github.com/hashicorp/vault/vault"

	"github.com/hashicorp/vault/sdk/logical"
	"github.com/stretchr/testify/require"
)

func TestResignCrls_ForbidSigningOtherIssuerCRL(t *testing.T) {
	t.Parallel()

	// Some random CRL from another issuer
	pem1 := "-----BEGIN X509 CRL-----\nMIIBvjCBpwIBATANBgkqhkiG9w0BAQsFADAbMRkwFwYDVQQDExByb290LWV4YW1w\nbGUuY29tFw0yMjEwMjYyMTI5MzlaFw0yMjEwMjkyMTI5MzlaMCcwJQIUSnVf8wsd\nHjOt9drCYFhWxS9QqGoXDTIyMTAyNjIxMjkzOVqgLzAtMB8GA1UdIwQYMBaAFHki\nZ0XDUQVSajNRGXrg66OaIFlYMAoGA1UdFAQDAgEDMA0GCSqGSIb3DQEBCwUAA4IB\nAQBGIdtqTwemnLZF5AoP+jzvKZ26S3y7qvRIzd7f4A0EawzYmWXSXfwqo4TQ4DG3\nnvT+AaA1zCCOlH/1U+ufN9gSSN0j9ax58brSYMnMskMCqhLKIp0qnvS4jr/gopmF\nv8grbvLHEqNYTu1T7umMLdNQUsWT3Qc+EIjfoKj8xD2FHsZwJ+EMbytwl8Unipjr\nhz4rmcES/65vavfdFpOI6YXfi+UAaHBdkTqmHgg4BdpuXfYtlf+iotFSOkygD5fl\n0D+RVFW9uJv2WfbQ7kRt1X/VcFk/onw0AQqxZRVUzvjoMw+EMcxSq3UKOlXcWDxm\nEFz9rFQQ66L388EP8RD7Dh3X\n-----END X509 CRL-----"

	b, s := CreateBackendWithStorage(t)
	resp, err := CBWrite(b, s, "root/generate/internal", map[string]interface{}{
		"common_name": "test.com",
		"key_type":    "ec",
	})
	requireSuccessNonNilResponse(t, resp, err)

	resp, err = CBWrite(b, s, "issuer/default/resign-crls", map[string]interface{}{
		"crl_number":  "2",
		"next_update": "1h",
		"format":      "pem",
		"crls":        []string{pem1},
	})
	require.ErrorContains(t, err, "was not signed by requested issuer")
}

func TestResignCrls_NormalCrl(t *testing.T) {
	t.Parallel()
	b1, s1 := CreateBackendWithStorage(t)
	b2, s2 := CreateBackendWithStorage(t)

	// Setup two backends, with the same key material/certificate with a different leaf in each that is revoked.
	caCert, serial1, serial2, crl1, crl2 := setupResignCrlMounts(t, b1, s1, b2, s2)

	// Attempt to combine the CRLs
	resp, err := CBWrite(b1, s1, "issuer/default/resign-crls", map[string]interface{}{
		"crl_number":  "2",
		"next_update": "1h",
		"format":      "pem",
		"crls":        []string{crl1, crl2},
	})
	schema.ValidateResponse(t, schema.GetResponseSchema(t, b1.Route("issuer/default/resign-crls"), logical.UpdateOperation), resp, true)
	requireSuccessNonNilResponse(t, resp, err)
	requireFieldsSetInResp(t, resp, "crl")
	pemCrl := resp.Data["crl"].(string)
	combinedCrl, err := decodePemCrl(pemCrl)
	require.NoError(t, err, "failed decoding combined CRL")
	serials := extractSerialsFromCrl(t, combinedCrl)

	require.Contains(t, serials, serial1)
	require.Contains(t, serials, serial2)
	require.Equal(t, 2, len(serials), "serials contained more serials than expected")

	require.Equal(t, big.NewInt(int64(2)), combinedCrl.Number)
	require.Equal(t, combinedCrl.ThisUpdate.Add(1*time.Hour), combinedCrl.NextUpdate)

	extensions := combinedCrl.Extensions
	requireExtensionOid(t, []int{2, 5, 29, 20}, extensions) // CRL Number Extension
	requireExtensionOid(t, []int{2, 5, 29, 35}, extensions) // akidOid
	require.Equal(t, 2, len(extensions))

	err = combinedCrl.CheckSignatureFrom(caCert)
	require.NoError(t, err, "failed signature check of CRL")
}

func TestResignCrls_EliminateDuplicates(t *testing.T) {
	t.Parallel()
	b1, s1 := CreateBackendWithStorage(t)
	b2, s2 := CreateBackendWithStorage(t)

	// Setup two backends, with the same key material/certificate with a different leaf in each that is revoked.
	_, serial1, _, crl1, _ := setupResignCrlMounts(t, b1, s1, b2, s2)

	// Pass in the same CRLs to make sure we do not duplicate entries
	resp, err := CBWrite(b1, s1, "issuer/default/resign-crls", map[string]interface{}{
		"crl_number":  "2",
		"next_update": "1h",
		"format":      "pem",
		"crls":        []string{crl1, crl1},
	})
	requireSuccessNonNilResponse(t, resp, err)
	requireFieldsSetInResp(t, resp, "crl")
	pemCrl := resp.Data["crl"].(string)
	combinedCrl, err := decodePemCrl(pemCrl)
	require.NoError(t, err, "failed decoding combined CRL")

	// Technically this will die if we have duplicates.
	serials := extractSerialsFromCrl(t, combinedCrl)

	// We should have no warnings about collisions if they have the same revoked time
	require.Empty(t, resp.Warnings, "expected no warnings in response")

	require.Contains(t, serials, serial1)
	require.Equal(t, 1, len(serials), "serials contained more serials than expected")
}

func TestResignCrls_ConflictingExpiry(t *testing.T) {
	t.Parallel()
	b1, s1 := CreateBackendWithStorage(t)
	b2, s2 := CreateBackendWithStorage(t)

	// Setup two backends, with the same key material/certificate with a different leaf in each that is revoked.
	_, serial1, serial2, crl1, _ := setupResignCrlMounts(t, b1, s1, b2, s2)

	timeAfterMountSetup := time.Now()

	// Read in serial1 from mount 1
	resp, err := CBRead(b1, s1, "cert/"+serial1)
	requireSuccessNonNilResponse(t, resp, err, "failed reading serial 1's certificate")
	requireFieldsSetInResp(t, resp, "certificate")
	cert1 := resp.Data["certificate"].(string)

	// Wait until at least we have rolled over to the next second to match sure the generated CRL time
	// on backend 2 for the serial 1 will be different
	for {
		if time.Now().After(timeAfterMountSetup.Add(1 * time.Second)) {
			break
		}
	}

	// Use BYOC to revoke the same certificate on backend 2 now
	resp, err = CBWrite(b2, s2, "revoke", map[string]interface{}{
		"certificate": cert1,
	})
	requireSuccessNonNilResponse(t, resp, err, "failed revoking serial 1 on backend 2")

	// Fetch the new CRL from backend2 now
	resp, err = CBRead(b2, s2, "cert/crl")
	requireSuccessNonNilResponse(t, resp, err, "error fetch crl from backend 2")
	requireFieldsSetInResp(t, resp, "certificate")
	crl2 := resp.Data["certificate"].(string)

	// Attempt to combine the CRLs
	resp, err = CBWrite(b1, s1, "issuer/default/resign-crls", map[string]interface{}{
		"crl_number":  "2",
		"next_update": "1h",
		"format":      "pem",
		"crls":        []string{crl2, crl1}, // Make sure we don't just grab the first colliding entry...
	})
	requireSuccessNonNilResponse(t, resp, err)
	requireFieldsSetInResp(t, resp, "crl")
	pemCrl := resp.Data["crl"].(string)
	combinedCrl, err := decodePemCrl(pemCrl)
	require.NoError(t, err, "failed decoding combined CRL")
	combinedSerials := extractSerialsFromCrl(t, combinedCrl)

	require.Contains(t, combinedSerials, serial1)
	require.Contains(t, combinedSerials, serial2)
	require.Equal(t, 2, len(combinedSerials), "serials contained more serials than expected")

	// Make sure we issued a warning about the time collision
	require.NotEmpty(t, resp.Warnings, "expected at least one warning")
	require.Contains(t, resp.Warnings[0], "different revocation times detected")

	// Make sure we have the initial revocation time from backend 1 within the combined CRL.
	decodedCrl1, err := decodePemCrl(crl1)
	require.NoError(t, err, "failed decoding crl from backend 1")
	serialsFromBackend1 := extractSerialsFromCrl(t, decodedCrl1)

	require.Equal(t, serialsFromBackend1[serial1], combinedSerials[serial1])

	// Make sure we have the initial revocation time from backend 1 does not match with backend 2's time
	decodedCrl2, err := decodePemCrl(crl2)
	require.NoError(t, err, "failed decoding crl from backend 2")
	serialsFromBackend2 := extractSerialsFromCrl(t, decodedCrl2)

	require.NotEqual(t, serialsFromBackend1[serial1], serialsFromBackend2[serial1])
}

func TestResignCrls_DeltaCrl(t *testing.T) {
	t.Parallel()

	b1, s1 := CreateBackendWithStorage(t)
	b2, s2 := CreateBackendWithStorage(t)

	// Setup two backends, with the same key material/certificate with a different leaf in each that is revoked.
	caCert, serial1, serial2, crl1, crl2 := setupResignCrlMounts(t, b1, s1, b2, s2)

	resp, err := CBWrite(b1, s1, "issuer/default/resign-crls", map[string]interface{}{
		"crl_number":            "5",
		"delta_crl_base_number": "4",
		"next_update":           "12h",
		"format":                "pem",
		"crls":                  []string{crl1, crl2},
	})
	requireSuccessNonNilResponse(t, resp, err)
	requireFieldsSetInResp(t, resp, "crl")
	pemCrl := resp.Data["crl"].(string)
	combinedCrl, err := decodePemCrl(pemCrl)
	require.NoError(t, err, "failed decoding combined CRL")
	serials := extractSerialsFromCrl(t, combinedCrl)

	require.Contains(t, serials, serial1)
	require.Contains(t, serials, serial2)
	require.Equal(t, 2, len(serials), "serials contained more serials than expected")

	require.Equal(t, big.NewInt(int64(5)), combinedCrl.Number)
	require.Equal(t, combinedCrl.ThisUpdate.Add(12*time.Hour), combinedCrl.NextUpdate)

	extensions := combinedCrl.Extensions
	requireExtensionOid(t, []int{2, 5, 29, 27}, extensions) // Delta CRL Extension
	requireExtensionOid(t, []int{2, 5, 29, 20}, extensions) // CRL Number Extension
	requireExtensionOid(t, []int{2, 5, 29, 35}, extensions) // akidOid
	require.Equal(t, 3, len(extensions))

	err = combinedCrl.CheckSignatureFrom(caCert)
	require.NoError(t, err, "failed signature check of CRL")
}

func TestSignRevocationList(t *testing.T) {
	t.Parallel()

	coreConfig := &vault.CoreConfig{
		LogicalBackends: map[string]logical.Factory{
			"pki": Factory,
		},
	}
	cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
		HandlerFunc: vaulthttp.Handler,
	})
	cluster.Start()
	defer cluster.Cleanup()
	client := cluster.Cores[0].Client

	// Mount PKI, use this form of backend so our request is closer to reality (json parsed)
	err := client.Sys().Mount("pki", &api.MountInput{
		Type: "pki",
		Config: api.MountConfigInput{
			DefaultLeaseTTL: "16h",
			MaxLeaseTTL:     "60h",
		},
	})
	require.NoError(t, err)

	// Generate internal CA.
	resp, err := client.Logical().Write("pki/root/generate/internal", map[string]interface{}{
		"ttl":         "40h",
		"common_name": "myvault.com",
	})
	require.NoError(t, err)
	caCert := parseCert(t, resp.Data["certificate"].(string))

	resp, err = client.Logical().Write("pki/issuer/default/sign-revocation-list", map[string]interface{}{
		"crl_number":  "1",
		"next_update": "12h",
		"format":      "pem",
		"revoked_certs": []map[string]interface{}{
			{
				"serial_number":   "37:60:16:e4:85:d5:96:38:3a:ed:31:06:8d:ed:7a:46:d4:22:63:d8",
				"revocation_time": "1668614976",
				"extensions":      []map[string]interface{}{},
			},
			{
				"serial_number":   "27:03:89:76:5a:d4:d8:19:48:47:ca:96:db:6f:27:86:31:92:9f:82",
				"revocation_time": "2022-11-16T16:09:36.739592Z",
			},
			{
				"serial_number":   "27:03:89:76:5a:d4:d8:19:48:47:ca:96:db:6f:27:86:31:92:9f:81",
				"revocation_time": "2022-10-16T16:09:36.739592Z",
				"extensions": []map[string]interface{}{
					{
						"id":       "2.5.29.100",
						"critical": "true",
						"value":    "aGVsbG8=", // "hello" base64 encoded
					},
					{
						"id":       "2.5.29.101",
						"critical": "false",
						"value":    "Ynll", // "bye" base64 encoded
					},
				},
			},
		},
		"extensions": []map[string]interface{}{
			{
				"id":       "2.5.29.200",
				"critical": "true",
				"value":    "aGVsbG8=", // "hello" base64 encoded
			},
			{
				"id":       "2.5.29.201",
				"critical": "false",
				"value":    "Ynll", // "bye" base64 encoded
			},
		},
	})
	require.NoError(t, err)
	pemCrl := resp.Data["crl"].(string)
	crl, err := decodePemCrl(pemCrl)
	require.NoError(t, err, "failed decoding CRL")
	serials := extractSerialsFromCrl(t, crl)
	require.Contains(t, serials, "37:60:16:e4:85:d5:96:38:3a:ed:31:06:8d:ed:7a:46:d4:22:63:d8")
	require.Contains(t, serials, "27:03:89:76:5a:d4:d8:19:48:47:ca:96:db:6f:27:86:31:92:9f:82")
	require.Contains(t, serials, "27:03:89:76:5a:d4:d8:19:48:47:ca:96:db:6f:27:86:31:92:9f:81")
	require.Equal(t, 3, len(serials), "expected 3 serials within CRL")

	// Make sure extensions on serials match what we expect.
	require.Equal(t, 0, len(crl.RevokedCertificates[0].Extensions), "Expected no extensions on 1st serial")
	require.Equal(t, 0, len(crl.RevokedCertificates[1].Extensions), "Expected no extensions on 2nd serial")
	require.Equal(t, 2, len(crl.RevokedCertificates[2].Extensions), "Expected 2 extensions on 3 serial")
	require.Equal(t, "2.5.29.100", crl.RevokedCertificates[2].Extensions[0].Id.String())
	require.True(t, crl.RevokedCertificates[2].Extensions[0].Critical)
	require.Equal(t, []byte("hello"), crl.RevokedCertificates[2].Extensions[0].Value)

	require.Equal(t, "2.5.29.101", crl.RevokedCertificates[2].Extensions[1].Id.String())
	require.False(t, crl.RevokedCertificates[2].Extensions[1].Critical)
	require.Equal(t, []byte("bye"), crl.RevokedCertificates[2].Extensions[1].Value)

	// CRL Number and times
	require.Equal(t, big.NewInt(int64(1)), crl.Number)
	require.Equal(t, crl.ThisUpdate.Add(12*time.Hour), crl.NextUpdate)

	// Verify top level extensions are present
	extensions := crl.Extensions
	requireExtensionOid(t, []int{2, 5, 29, 20}, extensions)  // CRL Number Extension
	requireExtensionOid(t, []int{2, 5, 29, 35}, extensions)  // akidOid
	requireExtensionOid(t, []int{2, 5, 29, 200}, extensions) // Added value from param
	requireExtensionOid(t, []int{2, 5, 29, 201}, extensions) // Added value from param
	require.Equal(t, 4, len(extensions))

	// Signature
	err = crl.CheckSignatureFrom(caCert)
	require.NoError(t, err, "failed signature check of CRL")
}

func TestSignRevocationList_NoRevokedCerts(t *testing.T) {
	t.Parallel()
	b, s := CreateBackendWithStorage(t)
	resp, err := CBWrite(b, s, "root/generate/internal", map[string]interface{}{
		"common_name": "test.com",
	})
	requireSuccessNonNilResponse(t, resp, err)

	resp, err = CBWrite(b, s, "issuer/default/sign-revocation-list", map[string]interface{}{
		"crl_number":  "10000",
		"next_update": "12h",
		"format":      "pem",
	})
	schema.ValidateResponse(t, schema.GetResponseSchema(t, b.Route("issuer/default/sign-revocation-list"), logical.UpdateOperation), resp, true)
	requireSuccessNonNilResponse(t, resp, err)
	requireFieldsSetInResp(t, resp, "crl")
	pemCrl := resp.Data["crl"].(string)
	crl, err := decodePemCrl(pemCrl)
	require.NoError(t, err, "failed decoding CRL")

	serials := extractSerialsFromCrl(t, crl)
	require.Equal(t, 0, len(serials), "no serials were expected in CRL")

	require.Equal(t, big.NewInt(int64(10000)), crl.Number)
	require.Equal(t, crl.ThisUpdate.Add(12*time.Hour), crl.NextUpdate)
}

func TestSignRevocationList_ReservedExtensions(t *testing.T) {
	t.Parallel()

	reservedOids := []asn1.ObjectIdentifier{
		akOid, deltaCrlOid, crlNumOid,
	}
	// Validate there isn't copy/paste issues with our constants...
	require.Equal(t, asn1.ObjectIdentifier{2, 5, 29, 27}, deltaCrlOid) // Delta CRL Extension
	require.Equal(t, asn1.ObjectIdentifier{2, 5, 29, 20}, crlNumOid)   // CRL Number Extension
	require.Equal(t, asn1.ObjectIdentifier{2, 5, 29, 35}, akOid)       // akidOid

	for _, reservedOid := range reservedOids {
		t.Run(reservedOid.String(), func(t *testing.T) {
			b, s := CreateBackendWithStorage(t)
			resp, err := CBWrite(b, s, "root/generate/internal", map[string]interface{}{
				"common_name": "test.com",
			})
			requireSuccessNonNilResponse(t, resp, err)

			resp, err = CBWrite(b, s, "issuer/default/sign-revocation-list", map[string]interface{}{
				"crl_number":  "1",
				"next_update": "12h",
				"format":      "pem",
				"extensions": []map[string]interface{}{
					{
						"id":       reservedOid.String(),
						"critical": "false",
						"value":    base64.StdEncoding.EncodeToString([]byte("hello")),
					},
				},
			})

			require.ErrorContains(t, err, "is reserved")
		})
	}
}

func setupResignCrlMounts(t *testing.T, b1 *backend, s1 logical.Storage, b2 *backend, s2 logical.Storage) (*x509.Certificate, string, string, string, string) {
	t.Helper()

	// Setup two mounts with the same CA/key material
	resp, err := CBWrite(b1, s1, "root/generate/exported", map[string]interface{}{
		"common_name": "test.com",
	})
	requireSuccessNonNilResponse(t, resp, err)
	requireFieldsSetInResp(t, resp, "certificate", "private_key")
	pemCaCert := resp.Data["certificate"].(string)
	caCert := parseCert(t, pemCaCert)
	privKey := resp.Data["private_key"].(string)

	// Import the above key/cert into another mount
	resp, err = CBWrite(b2, s2, "config/ca", map[string]interface{}{
		"pem_bundle": pemCaCert + "\n" + privKey,
	})
	requireSuccessNonNilResponse(t, resp, err, "error setting up CA on backend 2")

	// Create the same role in both mounts
	resp, err = CBWrite(b1, s1, "roles/test", map[string]interface{}{
		"allowed_domains":  "test.com",
		"allow_subdomains": "true",
		"max_ttl":          "1h",
	})
	requireSuccessNonNilResponse(t, resp, err, "error setting up pki role on backend 1")

	resp, err = CBWrite(b2, s2, "roles/test", map[string]interface{}{
		"allowed_domains":  "test.com",
		"allow_subdomains": "true",
		"max_ttl":          "1h",
	})
	requireSuccessNonNilResponse(t, resp, err, "error setting up pki role on backend 2")

	// Issue and revoke a cert in backend 1
	resp, err = CBWrite(b1, s1, "issue/test", map[string]interface{}{
		"common_name": "test1.test.com",
	})
	requireSuccessNonNilResponse(t, resp, err, "error issuing cert from backend 1")
	requireFieldsSetInResp(t, resp, "serial_number")
	serial1 := resp.Data["serial_number"].(string)

	resp, err = CBWrite(b1, s1, "revoke", map[string]interface{}{"serial_number": serial1})
	requireSuccessNonNilResponse(t, resp, err, "error revoking cert from backend 2")

	// Issue and revoke a cert in backend 2
	resp, err = CBWrite(b2, s2, "issue/test", map[string]interface{}{
		"common_name": "test1.test.com",
	})
	requireSuccessNonNilResponse(t, resp, err, "error issuing cert from backend 2")
	requireFieldsSetInResp(t, resp, "serial_number")
	serial2 := resp.Data["serial_number"].(string)

	resp, err = CBWrite(b2, s2, "revoke", map[string]interface{}{"serial_number": serial2})
	requireSuccessNonNilResponse(t, resp, err, "error revoking cert from backend 2")

	// Fetch PEM CRLs from each
	resp, err = CBRead(b1, s1, "cert/crl")
	requireSuccessNonNilResponse(t, resp, err, "error fetch crl from backend 1")
	requireFieldsSetInResp(t, resp, "certificate")
	crl1 := resp.Data["certificate"].(string)

	resp, err = CBRead(b2, s2, "cert/crl")
	requireSuccessNonNilResponse(t, resp, err, "error fetch crl from backend 2")
	requireFieldsSetInResp(t, resp, "certificate")
	crl2 := resp.Data["certificate"].(string)
	return caCert, serial1, serial2, crl1, crl2
}

func requireExtensionOid(t *testing.T, identifier asn1.ObjectIdentifier, extensions []pkix.Extension, msgAndArgs ...interface{}) {
	t.Helper()

	found := false
	var oidsInExtensions []string
	for _, extension := range extensions {
		oidsInExtensions = append(oidsInExtensions, extension.Id.String())
		if extension.Id.Equal(identifier) {
			found = true
			break
		}
	}

	if !found {
		msg := fmt.Sprintf("Failed to find matching asn oid %s out of %v", identifier.String(), oidsInExtensions)
		require.Fail(t, msg, msgAndArgs)
	}
}

func extractSerialsFromCrl(t *testing.T, crl *x509.RevocationList) map[string]time.Time {
	t.Helper()

	serials := map[string]time.Time{}

	for _, revokedCert := range crl.RevokedCertificates {
		serial := serialFromBigInt(revokedCert.SerialNumber)
		if _, exists := serials[serial]; exists {
			t.Fatalf("Serial number %s was duplicated in CRL", serial)
		}
		serials[serial] = revokedCert.RevocationTime
	}
	return serials
}
