Skip to content

Commit

Permalink
feat: add mTLS options (oras-project#1319)
Browse files Browse the repository at this point in the history
Signed-off-by: Andrew Block <[email protected]>
  • Loading branch information
sabre1041 authored and FeynmanZhou committed May 11, 2024
1 parent 5c6e7a2 commit d41f6f6
Show file tree
Hide file tree
Showing 6 changed files with 299 additions and 8 deletions.
20 changes: 19 additions & 1 deletion cmd/oras/internal/option/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import (
)

const (
caFileFlag = "ca-file"
certFileFlag = "cert-file"
keyFileFlag = "key-file"
usernameFlag = "username"
passwordFlag = "password"
passwordFromStdinFlag = "password-stdin"
Expand All @@ -58,6 +61,8 @@ const (
type Remote struct {
DistributionSpec
CACertFilePath string
CertFilePath string
KeyFilePath string
Insecure bool
Configs []string
Username string
Expand Down Expand Up @@ -120,7 +125,9 @@ func (opts *Remote) ApplyFlagsWithPrefix(fs *pflag.FlagSet, prefix, description
opts.plainHTTP = func() (bool, bool) {
return *plainHTTP, fs.Changed(plainHTTPFlagName)
}
fs.StringVar(&opts.CACertFilePath, opts.flagPrefix+"ca-file", "", "server certificate authority file for the remote "+notePrefix+"registry")
fs.StringVar(&opts.CACertFilePath, opts.flagPrefix+caFileFlag, "", "server certificate authority file for the remote "+notePrefix+"registry")
fs.StringVarP(&opts.CertFilePath, opts.flagPrefix+certFileFlag, "", "", "client certificate file for the remote "+notePrefix+"registry")
fs.StringVarP(&opts.KeyFilePath, opts.flagPrefix+keyFileFlag, "", "", "client private key file for the remote "+notePrefix+"registry")
fs.StringArrayVar(&opts.resolveFlag, opts.flagPrefix+"resolve", nil, "customized DNS for "+notePrefix+"registry, formatted in `host:port:address[:address_port]`")
fs.StringArrayVar(&opts.Configs, opts.flagPrefix+"registry-config", nil, "`path` of the authentication file for "+notePrefix+"registry")
fs.StringArrayVarP(&opts.headerFlags, opts.flagPrefix+"header", shortHeader, nil, "add custom headers to "+notePrefix+"requests")
Expand All @@ -142,6 +149,7 @@ func CheckStdinConflict(flags *pflag.FlagSet) error {
func (opts *Remote) Parse(cmd *cobra.Command) error {
usernameAndIdTokenFlags := []string{opts.flagPrefix + usernameFlag, opts.flagPrefix + identityTokenFlag}
passwordAndIdTokenFlags := []string{opts.flagPrefix + passwordFlag, opts.flagPrefix + identityTokenFlag}
certFileAndKeyFileFlags := []string{opts.flagPrefix + certFileFlag, opts.flagPrefix + keyFileFlag}
if cmd.Flags().Lookup(identityTokenFromStdinFlag) != nil {
usernameAndIdTokenFlags = append(usernameAndIdTokenFlags, identityTokenFromStdinFlag)
passwordAndIdTokenFlags = append(passwordAndIdTokenFlags, identityTokenFromStdinFlag)
Expand All @@ -155,6 +163,9 @@ func (opts *Remote) Parse(cmd *cobra.Command) error {
if err := opts.parseCustomHeaders(); err != nil {
return err
}

cmd.MarkFlagsRequiredTogether(certFileAndKeyFileFlags...)

return opts.readSecret(cmd)
}

Expand Down Expand Up @@ -228,6 +239,13 @@ func (opts *Remote) tlsConfig() (*tls.Config, error) {
return nil, err
}
}
if opts.CertFilePath != "" && opts.KeyFilePath != "" {
cert, err := tls.LoadX509KeyPair(opts.CertFilePath, opts.KeyFilePath)
if err != nil {
return nil, err
}
config.Certificates = []tls.Certificate{cert}
}
return config, nil
}

Expand Down
123 changes: 116 additions & 7 deletions cmd/oras/internal/option/remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ limitations under the License.
package option

import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
_ "embed"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
Expand All @@ -43,9 +46,63 @@ var testTagList = struct {
Tags: []string{"tag"},
}

// localhostServerCert is a PEM-encoded TLS cert with SAN IPs
// "127.0.0.1" and "[::1]", expiring at Jan 29 16:00:00 2084 GMT.
// adapted from golang crypto/tls:
// go run generate_cert.go --rsa-bits 4096 --host 127.0.0.1,::1,oras.land --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h
//
//go:embed testdata/localhostServer.crt
var localhostServerCert []byte

// localhostServerKey is the private key for localhostServerCert.
//
//go:embed testdata/localhostServer.key
var localhostServerKey []byte

// localhostClientCert is a PEM-encoded TLS cert with SAN IPs
// "127.0.0.1" and "[::1]", expiring at Jan 29 16:00:00 2084 GMT.
// adapted from golang crypto/tls (added Client Auth usage):
// go run generate_cert.go --rsa-bits 4096 --host 127.0.0.1,::1,oras.land --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h
//
//go:embed testdata/localhostClient.crt
var localhostClientCert []byte

// localhostClientKey is the private key for localhostClientCert.
//
//go:embed testdata/localhostClient.key
var localhostClientKey []byte

func testingKey(s []byte) []byte {
return bytes.ReplaceAll(s, []byte("TESTING KEY"), []byte("PRIVATE KEY"))
}

func loadTestingTLSConfig() *tls.Config {

clientCertPool := x509.NewCertPool()
clientCertPool.AppendCertsFromPEM(localhostClientCert)

tlsConfig := &tls.Config{
Certificates: []tls.Certificate{loadTestingCert(localhostServerCert, testingKey(localhostServerKey))},
ClientAuth: tls.VerifyClientCertIfGiven,
ClientCAs: clientCertPool,
}

return tlsConfig
}

func loadTestingCert(certificate, key []byte) tls.Certificate {
cert, err := tls.X509KeyPair(certificate, key)
if err != nil {
panic(fmt.Sprintf("Unable to load testing certificate: %v", err))
}

return cert

}

func TestMain(m *testing.M) {
// Test server
ts = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ts = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
m := r.Method
switch {
Expand All @@ -57,6 +114,8 @@ func TestMain(m *testing.M) {
}
}
}))
ts.TLS = loadTestingTLSConfig()
ts.StartTLS()
defer ts.Close()
m.Run()
}
Expand Down Expand Up @@ -116,7 +175,7 @@ func TestRemote_authClient_skipTlsVerify(t *testing.T) {

func TestRemote_authClient_CARoots(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "oras-test.pem")
if err := os.WriteFile(caPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ts.Certificate().Raw}), 0644); err != nil {
if err := os.WriteFile(caPath, localhostServerCert, 0644); err != nil {
t.Fatalf("unexpected error: %v", err)
}

Expand Down Expand Up @@ -174,7 +233,7 @@ func plainHTTPNotSpecified() (plainHTTP bool, fromFlag bool) {

func TestRemote_NewRegistry(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "oras-test.pem")
if err := os.WriteFile(caPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ts.Certificate().Raw}), 0644); err != nil {
if err := os.WriteFile(caPath, localhostServerCert, 0644); err != nil {
t.Fatalf("unexpected error: %v", err)
}

Expand Down Expand Up @@ -203,15 +262,63 @@ func TestRemote_NewRegistry(t *testing.T) {

func TestRemote_NewRepository(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "oras-test.pem")
if err := os.WriteFile(caPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ts.Certificate().Raw}), 0644); err != nil {
if err := os.WriteFile(caPath, localhostServerCert, 0644); err != nil {
t.Fatalf("unexpected error: %v", err)
}
opts := struct {
Remote
Common
}{
Remote{
CACertFilePath: caPath,
plainHTTP: plainHTTPNotSpecified,
},
Common{},
}

uri, err := url.ParseRequestURI(ts.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
repo, err := opts.NewRepository(uri.Host+"/"+testRepo, opts.Common, logrus.New())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err = repo.Tags(context.Background(), "", func(got []string) error {
want := []string{"tag"}
if len(got) != len(testTagList.Tags) || !reflect.DeepEqual(got, want) {
return fmt.Errorf("expect: %v, got: %v", testTagList.Tags, got)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestRemote_NewRepositoryMTLS(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "oras-test.pem")
if err := os.WriteFile(caPath, localhostServerCert, 0644); err != nil {
t.Fatalf("unexpected error: %v", err)
}

clientCertPath := filepath.Join(t.TempDir(), "oras-test-client.pem")
if err := os.WriteFile(clientCertPath, localhostClientCert, 0644); err != nil {
t.Fatalf("unexpected error: %v", err)
}

clientKeyPath := filepath.Join(t.TempDir(), "oras-test-client.key")
if err := os.WriteFile(clientKeyPath, testingKey(localhostClientKey), 0644); err != nil {
t.Fatalf("unexpected error: %v", err)
}

opts := struct {
Remote
Common
}{
Remote{
CACertFilePath: caPath,
CertFilePath: clientCertPath,
KeyFilePath: clientKeyPath,
plainHTTP: plainHTTPNotSpecified,
},
Common{},
Expand All @@ -238,11 +345,11 @@ func TestRemote_NewRepository(t *testing.T) {

func TestRemote_NewRepository_Retry(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "oras-test.pem")
if err := os.WriteFile(caPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ts.Certificate().Raw}), 0644); err != nil {
if err := os.WriteFile(caPath, localhostServerCert, 0644); err != nil {
t.Fatalf("unexpected error: %v", err)
}
retries, count := 3, 0
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
if count < retries {
http.Error(w, "error", http.StatusTooManyRequests)
Expand All @@ -253,6 +360,8 @@ func TestRemote_NewRepository_Retry(t *testing.T) {
http.Error(w, "error encoding", http.StatusBadRequest)
}
}))
ts.TLS = loadTestingTLSConfig()
ts.StartTLS()
defer ts.Close()
opts := struct {
Remote
Expand Down
30 changes: 30 additions & 0 deletions cmd/oras/internal/option/testdata/localhostClient.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFMjCCAxqgAwIBAgIRAMow8iHaN1G+R2GjMyOC5LQwDQYJKoZIhvcNAQELBQAw
DzENMAsGA1UEChMET1JBUzAgFw03MDAxMDEwMDAwMDBaGA8yMDg0MDEyOTE2MDAw
MFowDzENMAsGA1UEChMET1JBUzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
ggIBAM01pjqP4xZmX8zAT5p7k90FpbZ1W5S8qLIkajvzzoGeksSH2FgG+rXK+yJ6
TZo74a5ZKOUHtdjurRTvMgCjCvFIWv0wqnkeVFmXDLXQZiEamatPInGF2vJnv9BW
O4sLdopyXxAd4BQiC7ot8JO2PRiLubPRHDp2xExsDbqiAPU49qvwVFEfdnU+nHdt
jmmcZKzeD4iiu9DeZZJhv9h+OEZNXyHNDFV9/CRNrwO9Xfx8OvTZNklSYOyF0YER
nr1mmdQBZ4SCYYGBBdWvjkII7hZNkT9/qOBB2p/JXquF+6GYGruVAfAYRfADXCg1
+T+M8X1XZuja5wIxvwbH8JV5RXN/vk9mt36EXGSuesAOlVYKnv3Ux1bxPSuWT3RU
47GM/8vmelopHTIUtcLPnNFk3xlWyjNx7Sv67CxWniJFuOXrqrI0DLj/EQzOxoMa
uD0WO0BXHHNSFF7dhrd6FTNu3EppYFWQt9wBsZ8jgDRsHh2GeuyEo1YlSSB+rvs4
20w9cCSYGduJ4BKqcGRDY6c9QCRgZ5VWs+feS3awBK6Gx4mWh4bjM1ICGbiPeiw9
2DeHjjAqunlMN0z7Pa28lE0GE3rZpcwxtS3Blnm6Q/lDbCBmOWrGH4ngQ02HeaY4
t7xUayaiz3lkZjcTrdZa9OxHLZwmGmqY7JB3X/TReePr9jT5AgMBAAGjgYYwgYMw
DgYDVR0PAQH/BAQDAgKkMBMGA1UdJQQMMAoGCCsGAQUFBwMCMA8GA1UdEwEB/wQF
MAMBAf8wHQYDVR0OBBYEFDpedkP2AJKzCgJkZf9PpIOIXRHbMCwGA1UdEQQlMCOC
CW9yYXMubGFuZIcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsF
AAOCAgEAGtRCFG27HZqu2kz0IW6UuYUKv7gqtN4/47At3CHFAd3UAAErKrammwFH
fWB4zy4MU9fHMgJhc8ctZg0QR8f1fnpN8MlnmV3fqEbUVwbZov2x46wgwLD7PRZM
NPh+Z2reFRE2VNi+rZsqcTAPy48FQ0WdCpzQheeonincLuY6GYV1DjhHn3HNFqkn
lslS5FRRkkLxXLgr0p0fNAEUMqce16VjVcM2BbEuCRvlepZZF7uVvEOQvtEMJSdF
APmUUsS7x6jvIxmZpxzanBmu2u4kQwTMW4UDpkNVwdCPGEM/SAdQUkqOYIQU807n
93QrXXY3XIlDp7U6xA2AfZQ9fiV7cDul06IzsXcisTeK8uSfhHRROqbt9MotiIve
xEVTyh4tfue7kFowq7wd3H2UTSLgByfJBkEAN+BTDUpEwT7gZOVcxbzpkL+vyPfr
CVdIbE9ZlBzCc5i0RWaMrEJfFy93a7ZptlXytfQM/MFOlKaIDXFjoylmkW9IhrFd
Ger/gr4AFzD7AQRF9+c0Cu4je3A+ob9R0CvvwEthjQCrOWjVgbUxR+qMVNCtCRwk
Bt4BEd7qH0bM8EAY7BwPEP7vfvp7iblp0FNNLYQCbxd0eK69Tb2olzQRKKyNpqls
/cqUr8lpYbGAdorLJSeCj8EZ7+o9ZJXbxilkcMl3R8wV8mn5Olw=
-----END CERTIFICATE-----
52 changes: 52 additions & 0 deletions cmd/oras/internal/option/testdata/localhostClient.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-----BEGIN RSA TESTING KEY-----
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
tFAnaOX7ziz6wrV8TmuU9ZdGX+Aw+g==
-----END RSA TESTING KEY-----
30 changes: 30 additions & 0 deletions cmd/oras/internal/option/testdata/localhostServer.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFMjCCAxqgAwIBAgIRAPzEFsDt4E8GxCNLsF76j18wDQYJKoZIhvcNAQELBQAw
DzENMAsGA1UEChMET1JBUzAgFw03MDAxMDEwMDAwMDBaGA8yMDg0MDEyOTE2MDAw
MFowDzENMAsGA1UEChMET1JBUzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
ggIBAKcbdhkxvx0CXeDZuEz3kKgVsQpyNRXP0AWKS8FqxbVe+NLDZl9jzDtQBvCt
BW3UjgIxnUMG5tGrtTHvzLJNfkX4DvTkWUOiLu8VxcAT6vG5v87xyrq86taMLrCm
o42wHpJNETPlCtJquGEADPHs4D+EOAMWfaCvy5rEYt6JD/oy6/VehPInE3Q634NK
98OD25xykOtq1sIvoZibIgq8T5GlmBrxAb+axfhX8tp7NEJ4wvOUorEnww3cZJb6
7Q6ijvzEWT2RD58prR7l4yvsb+HmSEWoJcd5JZd0CUiVIM44L3E4qllXJ2vwT9Fv
dXwfwx29zQ9VVLlNibPzJ4a0ZEIhi8ZMGtoLR7gEhsM+PraZz+2TL5APJWI2TRZk
A2m7v2DMv4ZbUOOy1OivOIwJqA6k9lsm2oMOPrTIZ0pZIXu1bmF0/Z8js4LNx5HU
4SsIHYpj1efH27YgjfBY9ROX3iITQsKyUpCpF9z845tzbdw66tRTazLvumNVrdlz
fKceFDbmgi6uz/lILSppy/kzKkoaY6+NTogW6Kg/2icPH8rg6LpxHpQlAHqABB8t
25w39DQoD5sPGt/ChEX5Pb0bLUWlnl+6vKa4vfctiZ2DevGePLrK1lW+L4K05yCF
CpqPWKvm75aVs9HZGFbIsJTF2B4IQnYmm9IhqFuSh1sZBf2dAgMBAAGjgYYwgYMw
DgYDVR0PAQH/BAQDAgKkMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQF
MAMBAf8wHQYDVR0OBBYEFHSvVlBTGCMOip+SGZ7QO+0isE8tMCwGA1UdEQQlMCOC
CW9yYXMubGFuZIcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsF
AAOCAgEANrLbIqIe+7hd51BZDJn1tSE/Ld/kgwJ8mBZdB6XF2WLoCgVOx9VGmJH3
Z09b0KqJpp3s2HgYJNc5AHNAyH/u1E4HUAyKajuI420Z/GdV3CK2uwcp5mkkF4qK
ew6PEYUQRjZf95k+6T8VR8P9O0kiigr4Srgqal2EolSf8e27EO+JERueVn3NgRms
FrJv2+LILKFfBt5o9S1D5xNa9qu6NfW3x6F0VwaBxgjnUpqePUQaFzSSZ+VXmKmh
rx7I8PaNrac5DslDtmpVj/z9RwuExPZAB+/utocUWmSU0epTyqXiKQy8sgSVhsVe
yyClPguXtuD3IV/i5uAcSVf1xrCSSifkYnmhfMA5lUgjOmDuY/Am4f2DJcjBETb2
hsac+8C6IppGrZOuOinTp5ZIocFKjNxcjGnmqxk0hWW6GuF4truKSEicIiBvoqZp
rearfvDbM6g4CobowU1S6vNkUc2ziCE23AmoY65V3Vnmj72p5Mi7P932jzf2qEA1
vCGB9hrxO3Yr5wkOXUGQI/nU+KQTCdf/4j4kUh+N1pByGMPG05GrOpmy1Tems9sp
BddDm85WsThpxTf+Rp5xp5/FQh72eigkXa/ezeldEGI/rhKRJJtujU6Kq6SoKX29
YRSTpM+MeFPJDVffVUFXQrxnGIXRzxhx49wMSoKFYR/225exX1c=
-----END CERTIFICATE-----
Loading

0 comments on commit d41f6f6

Please sign in to comment.