diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 983c9f3..ab80500 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -9,6 +9,8 @@ jobs: golangci: name: Lint runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d82b38c..b64289c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,6 +16,8 @@ jobs: - {os: 'linux', platform: 'ubuntu-latest', arch: 'amd64'} - {os: 'windows', platform: 'windows-latest', arch: 'amd64'} runs-on: ${{ matrix.target.platform }} + permissions: + contents: write steps: - name: Setup Go environment uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # ratchet:actions/setup-go@v5 @@ -37,6 +39,8 @@ jobs: name: Create release runs-on: ubuntu-latest needs: [build] + permissions: + contents: write outputs: upload_url: ${{ steps.create_release.outputs.upload_url }} steps: @@ -58,6 +62,8 @@ jobs: name: Add assets runs-on: ubuntu-latest needs: [build, release] + permissions: + contents: write strategy: matrix: target: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 336c8e6..1784c6f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,6 +11,8 @@ jobs: build: name: Unit tests runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 @@ -26,6 +28,8 @@ jobs: integration: name: Integration tests runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Setup Go environment uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # ratchet:actions/setup-go@v5 @@ -44,3 +48,72 @@ jobs: run: go build - name: Run tests run: PATH=$PWD:$PATH cram -v tests/*.t + integration-jceks: + name: JCEKS Integration tests + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + include: + # Oldest version for all tested distributions + - distribution: temurin + java-version: 8 + java-package: jre + - distribution: zulu + java-version: 8 + java-package: jre + - distribution: microsoft + java-version: 11 + java-package: jdk + - distribution: corretto + java-version: 8 + java-package: jdk + - distribution: oracle + java-version: 17 + java-package: jdk + + # Intermediate LTS versions for Temurin + - distribution: temurin + java-version: 11 + java-package: jre + - distribution: temurin + java-version: 17 + java-package: jre + - distribution: temurin + java-version: 21 + java-package: jre + + # Newest versions for all tested distributions + - distribution: temurin + java-version: 24 + java-package: jre + - distribution: zulu + java-version: 24 + java-package: jre + - distribution: microsoft + java-version: 21 + java-package: jdk + - distribution: corretto + java-version: 24 + java-package: jdk + - distribution: oracle + java-version: 24 + java-package: jdk + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 + - name: Setup Go environment + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # ratchet:actions/setup-go@v5 + with: + go-version: 'stable' + check-latest: true + - name: Setup java + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # ratchet:actions/setup-java@v4 + with: + distribution: ${{ matrix.distribution }} + java-version: ${{ matrix.java-version }} + java-package: ${{ matrix.java-package }} + - name: Run tests + run: + go test -v github.com/square/certigo/jceks -test.run "TestIntegrationKeytool*" -jceks.keytool-tests=true diff --git a/jceks/encoder_test.go b/jceks/encoder_test.go index 1be2b43..3ffe793 100644 --- a/jceks/encoder_test.go +++ b/jceks/encoder_test.go @@ -31,11 +31,6 @@ import ( var writeReencoded = flag.Bool("jceks.write-reencoded", false, "write expected re-encoded JCEKS files") -func TestMain(m *testing.M) { - flag.Parse() - m.Run() -} - type discardErrWriter struct { err error writeAfterErr int diff --git a/jceks/jceks_test.go b/jceks/jceks_test.go index 4bc1a55..7e5be69 100644 --- a/jceks/jceks_test.go +++ b/jceks/jceks_test.go @@ -19,6 +19,7 @@ import ( "bytes" "crypto/sha1" "errors" + "flag" "os" "path/filepath" "testing" @@ -26,6 +27,11 @@ import ( "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + flag.Parse() + m.Run() +} + func TestEncodeIntegrityPassword(t *testing.T) { t.Parallel() diff --git a/jceks/keytool_test.go b/jceks/keytool_test.go new file mode 100644 index 0000000..37295f3 --- /dev/null +++ b/jceks/keytool_test.go @@ -0,0 +1,243 @@ +// Copyright 2025 Block, Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed 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 jceks + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "flag" + "math/rand/v2" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/sha3" +) + +var keytoolTests = flag.Bool("jceks.keytool-tests", false, "run Keytool integration tests") + +// TestIntegrationKeytoolPackageRead tests that a JCEKS file written by keytool can be read by this package. +func TestIntegrationKeytoolPackageRead(t *testing.T) { + t.Parallel() + + if !*keytoolTests { + t.Skip("keytool integration tests are not enabled") + } + + p12Filename := filepath.Join(os.TempDir(), "jceks-test-"+strconv.FormatUint(rand.Uint64(), 16)+".p12") + jceksFilename := filepath.Join(os.TempDir(), "jceks-test-"+strconv.FormatUint(rand.Uint64(), 16)+".jceks") + defer func() { + _ = os.Remove(p12Filename) + _ = os.Remove(jceksFilename) + }() + + // Generate a JCEKS file by using openssl to make a PKCS#12 file and then converting it to JCEKS with keytool + + err := exec.Command("openssl", "pkcs12", + "-export", + "-in", "testdata/private-key.crt", + "-inkey", "testdata/private-key.key", + "-certfile", "testdata/private-key-ca.crt", + "-name", "test-private-key", + "-passout", "pass:store-password", + "-out", p12Filename, + ).Run() + require.NoError(t, err) + + err = exec.Command("keytool", "-importkeystore", + "-alias", "test-private-key", + "-srckeystore", p12Filename, + "-srcstoretype", "PKCS12", + "-srcstorepass", "store-password", + "-destkeystore", jceksFilename, + "-storetype", "JCEKS", + "-deststorepass", "store-password", + "-destkeypass", "key-password", + ).Run() + require.NoError(t, err) + + err = exec.Command("keytool", "-importcert", + "-noprompt", + "-alias", "test-trusted-cert", + "-file", "testdata/trusted-cert.crt", + "-destkeystore", jceksFilename, + "-storetype", "JCEKS", + "-deststorepass", "store-password", + ).Run() + require.NoError(t, err) + + // Now load the JCEKS file with the package and make sure that the entries match the source material + + ks, err := LoadFromFile(jceksFilename, []byte("store-password")) + require.NoError(t, err) + + privKey, certs, err := ks.GetPrivateKeyAndCerts("test-private-key", []byte("key-password")) + require.NoError(t, err) + require.NotNil(t, privKey) + require.IsType(t, &rsa.PrivateKey{}, privKey) + rsaKey := privKey.(*rsa.PrivateKey) + + expectedRSAKey, err := LoadPEMKey("testdata/private-key.key") + require.NoError(t, err) + require.Equal(t, expectedRSAKey, rsaKey) + require.True(t, rsaKey.Equal(expectedRSAKey)) + + expectedLeafCert, err := LoadPEMCert("testdata/private-key.crt") + require.NoError(t, err) + expectedCACert, err := LoadPEMCert("testdata/private-key-ca.crt") + require.NoError(t, err) + + require.Len(t, certs, 2) + require.True(t, certs[0].Equal(expectedLeafCert)) + require.True(t, certs[1].Equal(expectedCACert)) + + keyAliases := ks.ListPrivateKeys() + require.Equal(t, []string{"test-private-key"}, keyAliases) + + cert, err := ks.GetCert("test-trusted-cert") + require.NoError(t, err) + + expectedCert, err := LoadPEMCert("testdata/trusted-cert.crt") + require.NoError(t, err) + + require.True(t, cert.Equal(expectedCert)) + + certAliases := ks.ListCerts() + require.Equal(t, []string{"test-trusted-cert"}, certAliases) +} + +// TestIntegrationKeytoolPackageWrite tests that a JCEKS file written by this package can be read by keytool. +func TestIntegrationKeytoolPackageWrite(t *testing.T) { + t.Parallel() + + if !*keytoolTests { + t.Skip("keytool integration tests are not enabled") + } + + rnd := sha3.NewShake128() + now := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) + + p12Filename := filepath.Join(os.TempDir(), "jceks-test-"+strconv.FormatUint(rand.Uint64(), 16)+".p12") + jceksFilename := filepath.Join(os.TempDir(), "jceks-test-"+strconv.FormatUint(rand.Uint64(), 16)+".jceks") + pemFilename := filepath.Join(os.TempDir(), "jceks-test-"+strconv.FormatUint(rand.Uint64(), 16)+".pem") + defer func() { + _ = os.Remove(p12Filename) + _ = os.Remove(jceksFilename) + _ = os.Remove(pemFilename) + }() + + // Create a JCEKS file from source material using this package + + rsaKey, err := LoadPEMKey("testdata/private-key.key") + require.NoError(t, err) + leafCert, err := LoadPEMCert("testdata/private-key.crt") + require.NoError(t, err) + caCert, err := LoadPEMCert("testdata/private-key-ca.crt") + require.NoError(t, err) + cert, err := LoadPEMCert("testdata/trusted-cert.crt") + require.NoError(t, err) + + var enc Encoder + + err = enc.SetIntegrityPassword("store-password") + require.NoError(t, err) + + pkcs1 := x509.MarshalPKCS1PrivateKey(rsaKey) + cipher, err := PBEWithMD5AndDES3CBC([]byte("key-password"), rnd, 20) + require.NoError(t, err) + err = enc.AddPrivateKeyPKCS1("test-private-key", now, pkcs1, [][]byte{leafCert.Raw, caCert.Raw}, cipher) + require.NoError(t, err) + + err = enc.AddTrustedCertificate("test-trusted-cert", now, cert.Raw) + require.NoError(t, err) + + f, err := os.Create(jceksFilename) + require.NoError(t, err) + defer func() { + _ = f.Close() + }() + _, err = enc.WriteTo(f) + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + + // Now convert the JCEKS file to PKCS#12 with keytool, and then use openssl to extract the certs and keys into PEM + + err = exec.Command("keytool", "-importkeystore", + "-srckeystore", jceksFilename, + "-srcstoretype", "JCEKS", + "-srcstorepass", "store-password", + "-destkeystore", p12Filename, + "-storetype", "PKCS12", + "-deststorepass", "store-password", + "-alias", "test-private-key", + "-srckeypass", "key-password", + "-destkeypass", "store-password", + ).Run() + require.NoError(t, err) + + err = exec.Command("keytool", "-importkeystore", + "-srckeystore", jceksFilename, + "-srcstoretype", "JCEKS", + "-srcstorepass", "store-password", + "-destkeystore", p12Filename, + "-storetype", "PKCS12", + "-deststorepass", "store-password", + "-alias", "test-trusted-cert", + ).Run() + require.NoError(t, err) + + err = exec.Command("openssl", "pkcs12", + "-in", p12Filename, + "-out", pemFilename, + "-nodes", + "-passin", "pass:store-password", + "-legacy", + ).Run() + require.NoError(t, err) + + // Finally, verify that the material that ended up in the PEM matches the source material + + pemData, err := os.ReadFile(pemFilename) + require.NoError(t, err) + + var certsDER [][]byte + var keysDER [][]byte + for { + var pemBlock *pem.Block + pemBlock, pemData = pem.Decode(pemData) + if pemBlock == nil { + break + } + switch pemBlock.Type { + case "CERTIFICATE": + certsDER = append(certsDER, pemBlock.Bytes) + case "PRIVATE KEY": + keysDER = append(keysDER, pemBlock.Bytes) + } + } + + require.ElementsMatch(t, certsDER, [][]byte{leafCert.Raw, caCert.Raw, cert.Raw}) + + pkcs8, err := x509.MarshalPKCS8PrivateKey(rsaKey) + require.NoError(t, err) + require.ElementsMatch(t, keysDER, [][]byte{pkcs8}) +}