diff --git a/authority/authority.go b/authority/authority.go index 8cf4cfc1..411787b6 100644 --- a/authority/authority.go +++ b/authority/authority.go @@ -85,6 +85,45 @@ func New(config *Config, opts ...Option) (*Authority, error) { return a, nil } +// NewEmbedded initializes an authority that can be embedded in a different +// project without the limitations of the config. +func NewEmbedded(opts ...Option) (*Authority, error) { + config := &Config{ + DNSNames: []string{"localhost", "127.0.0.1", "::1"}, + AuthorityConfig: defaultAuthConfig, + TLS: &DefaultTLSOptions, + } + a := &Authority{ + config: config, + certificates: new(sync.Map), + provisioners: provisioner.NewCollection(config.getAudiences()), + } + + // Apply options. + for _, fn := range opts { + if err := fn(a); err != nil { + return nil, err + } + } + + // Validate required options + switch { + case len(a.rootX509Certs) == 0 && a.config.Root.HasEmpties(): + return nil, errors.New("cannot create an authority without a root certificate") + case a.x509Issuer == nil && a.config.IntermediateCert == "": + return nil, errors.New("cannot create an authority without an issuer certificate") + case a.x509Signer == nil && a.config.IntermediateKey == "": + return nil, errors.New("cannot create an authority without an issuer signer") + } + + // Initialize authority from options or configuration. + if err := a.init(); err != nil { + return nil, err + } + + return a, nil +} + // init performs validation and initializes the fields of an Authority struct. func (a *Authority) init() error { // Check if handler has already been validated/initialized. diff --git a/authority/authority_test.go b/authority/authority_test.go index 058a4c25..b8cab30c 100644 --- a/authority/authority_test.go +++ b/authority/authority_test.go @@ -1,8 +1,13 @@ package authority import ( + "crypto" + "crypto/rand" "crypto/sha256" + "crypto/x509" "encoding/hex" + "io/ioutil" + "net" "reflect" "testing" @@ -10,6 +15,7 @@ import ( "github.com/smallstep/assert" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/db" + "github.com/smallstep/cli/crypto/pemutil" stepJOSE "github.com/smallstep/cli/jose" ) @@ -182,3 +188,121 @@ func TestAuthority_GetDatabase(t *testing.T) { }) } } + +func TestNewEmbedded(t *testing.T) { + caPEM, err := ioutil.ReadFile("testdata/certs/root_ca.crt") + assert.FatalError(t, err) + + crt, err := pemutil.ReadCertificate("testdata/certs/intermediate_ca.crt") + assert.FatalError(t, err) + key, err := pemutil.Read("testdata/secrets/intermediate_ca_key", pemutil.WithPassword([]byte("pass"))) + assert.FatalError(t, err) + + type args struct { + opts []Option + } + tests := []struct { + name string + args args + wantErr bool + }{ + {"ok", args{[]Option{WithX509RootBundle(caPEM), WithX509Signer(crt, key.(crypto.Signer))}}, false}, + {"ok config file", args{[]Option{WithConfigFile("../ca/testdata/ca.json")}}, false}, + {"ok config", args{[]Option{WithConfig(&Config{ + Root: []string{"testdata/certs/root_ca.crt"}, + IntermediateCert: "testdata/certs/intermediate_ca.crt", + IntermediateKey: "testdata/secrets/intermediate_ca_key", + Password: "pass", + AuthorityConfig: &AuthConfig{}, + })}}, false}, + {"fail options", args{[]Option{WithX509RootBundle([]byte("bad data"))}}, true}, + {"fail missing root", args{[]Option{WithX509Signer(crt, key.(crypto.Signer))}}, true}, + {"fail missing signer", args{[]Option{WithX509RootBundle(caPEM)}}, true}, + {"fail missing root file", args{[]Option{WithConfig(&Config{ + IntermediateCert: "testdata/certs/intermediate_ca.crt", + IntermediateKey: "testdata/secrets/intermediate_ca_key", + Password: "pass", + AuthorityConfig: &AuthConfig{}, + })}}, true}, + {"fail missing issuer", args{[]Option{WithConfig(&Config{ + Root: []string{"testdata/certs/root_ca.crt"}, + IntermediateKey: "testdata/secrets/intermediate_ca_key", + Password: "pass", + AuthorityConfig: &AuthConfig{}, + })}}, true}, + {"fail missing signer", args{[]Option{WithConfig(&Config{ + Root: []string{"testdata/certs/root_ca.crt"}, + IntermediateCert: "testdata/certs/intermediate_ca.crt", + Password: "pass", + AuthorityConfig: &AuthConfig{}, + })}}, true}, + {"fail bad password", args{[]Option{WithConfig(&Config{ + Root: []string{"testdata/certs/root_ca.crt"}, + IntermediateCert: "testdata/certs/intermediate_ca.crt", + IntermediateKey: "testdata/secrets/intermediate_ca_key", + Password: "bad", + AuthorityConfig: &AuthConfig{}, + })}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewEmbedded(tt.args.opts...) + if (err != nil) != tt.wantErr { + t.Errorf("NewEmbedded() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil { + assert.True(t, got.initOnce) + assert.NotNil(t, got.rootX509Certs) + assert.NotNil(t, got.x509Signer) + assert.NotNil(t, got.x509Issuer) + } + }) + } +} + +func TestNewEmbedded_Sign(t *testing.T) { + caPEM, err := ioutil.ReadFile("testdata/certs/root_ca.crt") + assert.FatalError(t, err) + + crt, err := pemutil.ReadCertificate("testdata/certs/intermediate_ca.crt") + assert.FatalError(t, err) + key, err := pemutil.Read("testdata/secrets/intermediate_ca_key", pemutil.WithPassword([]byte("pass"))) + assert.FatalError(t, err) + + a, err := NewEmbedded(WithX509RootBundle(caPEM), WithX509Signer(crt, key.(crypto.Signer))) + assert.FatalError(t, err) + + // Sign + cr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + DNSNames: []string{"foo.bar.zar"}, + }, key) + assert.FatalError(t, err) + csr, err := x509.ParseCertificateRequest(cr) + assert.FatalError(t, err) + + cert, err := a.Sign(csr, provisioner.Options{}) + assert.FatalError(t, err) + assert.Equals(t, []string{"foo.bar.zar"}, cert[0].DNSNames) + assert.Equals(t, crt, cert[1]) +} + +func TestNewEmbedded_GetTLSCertificate(t *testing.T) { + caPEM, err := ioutil.ReadFile("testdata/certs/root_ca.crt") + assert.FatalError(t, err) + + crt, err := pemutil.ReadCertificate("testdata/certs/intermediate_ca.crt") + assert.FatalError(t, err) + key, err := pemutil.Read("testdata/secrets/intermediate_ca_key", pemutil.WithPassword([]byte("pass"))) + assert.FatalError(t, err) + + a, err := NewEmbedded(WithX509RootBundle(caPEM), WithX509Signer(crt, key.(crypto.Signer))) + assert.FatalError(t, err) + + // GetTLSCertificate + cert, err := a.GetTLSCertificate() + assert.FatalError(t, err) + assert.Equals(t, []string{"localhost"}, cert.Leaf.DNSNames) + assert.True(t, cert.Leaf.IPAddresses[0].Equal(net.ParseIP("127.0.0.1"))) + assert.True(t, cert.Leaf.IPAddresses[1].Equal(net.ParseIP("::1"))) +} diff --git a/authority/config.go b/authority/config.go index ceb2ea89..5d951853 100644 --- a/authority/config.go +++ b/authority/config.go @@ -75,6 +75,15 @@ type AuthConfig struct { Backdate *provisioner.Duration `json:"backdate,omitempty"` } +// defaultAuthConfig used when skipping validation. +var defaultAuthConfig = &AuthConfig{ + Provisioners: provisioner.List{}, + Template: &x509util.ASN1DN{}, + Backdate: &provisioner.Duration{ + Duration: defaultBackdate, + }, +} + // Validate validates the authority configuration. func (c *AuthConfig) Validate(audiences provisioner.Audiences) error { if c == nil { @@ -93,7 +102,7 @@ func (c *AuthConfig) Validate(audiences provisioner.Audiences) error { } if c.Template == nil { - c.Template = &x509util.ASN1DN{} + c.Template = defaultAuthConfig.Template } if c.Backdate != nil { @@ -101,9 +110,7 @@ func (c *AuthConfig) Validate(audiences provisioner.Audiences) error { return errors.New("authority.backdate cannot be less than 0") } } else { - c.Backdate = &provisioner.Duration{ - Duration: defaultBackdate, - } + c.Backdate = defaultAuthConfig.Backdate } return nil diff --git a/authority/options.go b/authority/options.go index 04cd7bef..59566822 100644 --- a/authority/options.go +++ b/authority/options.go @@ -17,6 +17,24 @@ import ( // Option sets options to the Authority. type Option func(*Authority) error +// WithConfig replaces the current config with the given one. No validation is +// performed in the given value. +func WithConfig(config *Config) Option { + return func(a *Authority) error { + a.config = config + return nil + } +} + +// WithConfigFile reads the given filename as a configuration file and replaces +// the current one. No validation is performed in the given configuration. +func WithConfigFile(filename string) Option { + return func(a *Authority) (err error) { + a.config, err = LoadConfiguration(filename) + return + } +} + // WithDatabase sets an already initialized authority database to a new // authority. This option is intended to be use on graceful reloads. func WithDatabase(db db.AuthDB) Option {