diff --git a/internal/templates/registry.go b/internal/templates/registry.go new file mode 100644 index 00000000..72c21086 --- /dev/null +++ b/internal/templates/registry.go @@ -0,0 +1,109 @@ +package templates + +import ( + "fmt" + "reflect" + "sync" + "text/template" +) + +// Registry holds template functions added by the application, for the +// functions a template needs that this library does not provide. +type Registry struct { + // reserved is a function so a package can name its own built-ins without + // this file knowing them. It is resolved once, on first use. + reserved func() map[string]struct{} + once sync.Once + names map[string]struct{} + + mu sync.RWMutex + funcs map[string]any +} + +// NewRegistry returns a Registry that refuses any name returned by reserved. +func NewRegistry(reserved func() map[string]struct{}) *Registry { + return &Registry{reserved: reserved, funcs: map[string]any{}} +} + +// Register adds fn to the registry. It returns an error if name is already +// registered or reserved. +func (r *Registry) Register(name string, fn any) error { + if err := validate(name, fn); err != nil { + return err + } + + r.once.Do(func() { r.names = r.reserved() }) + if _, ok := r.names[name]; ok { + return fmt.Errorf("template function %q is built in and cannot be replaced", name) + } + + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.funcs[name]; ok { + return fmt.Errorf("template function %q is already registered", name) + } + r.funcs[name] = fn + return nil +} + +// Replace adds fn to the registry, replacing any reserved or previously +// registered function with the same name. +func (r *Registry) Replace(name string, fn any) error { + if err := validate(name, fn); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.funcs[name] = fn + return nil +} + +func validate(name string, fn any) error { + switch { + case name == "": + return fmt.Errorf("template function name is required") + case fn == nil: + return fmt.Errorf("template function %q is nil", name) + case reflect.TypeOf(fn).Kind() != reflect.Func: + return fmt.Errorf("template function %q is a %s, not a function", name, reflect.TypeOf(fn).Kind()) + case !validName(name): + return fmt.Errorf("template function name %q is not a valid identifier", name) + } + return nil +} + +// Unregister removes a function from the registry. It returns true if a +// function was removed. +func (r *Registry) Unregister(name string) bool { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.funcs[name]; !ok { + return false + } + delete(r.funcs, name) + return true +} + +// Apply adds the registered functions to funcMap. +func (r *Registry) Apply(funcMap template.FuncMap) { + r.mu.RLock() + defer r.mu.RUnlock() + for name, fn := range r.funcs { + funcMap[name] = fn + } +} + +// validName reports whether name is a valid Go identifier, as required by +// "text/template". +func validName(name string) bool { + for i, c := range name { + switch { + case c == '_': + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9' && i > 0: + default: + return false + } + } + return true +} diff --git a/sshutil/funcs.go b/sshutil/funcs.go new file mode 100644 index 00000000..f534f889 --- /dev/null +++ b/sshutil/funcs.go @@ -0,0 +1,46 @@ +package sshutil + +import ( + "text/template" + + "go.step.sm/crypto/internal/templates" +) + +// templateFuncs holds the functions registered by the application. It is +// separate from the X.509 registry, so registering for one kind of certificate +// does not affect the other. +var templateFuncs = templates.NewRegistry(func() map[string]struct{} { + names := map[string]struct{}{} + for name := range builtinFuncMap(new(TemplateError)) { + names[name] = struct{}{} + } + return names +}) + +// RegisterTemplateFunc adds fn to the functions available to SSH certificate +// templates. It returns an error if name is already registered or built in. +// +// It behaves as [go.step.sm/crypto/x509util.RegisterTemplateFunc] does, over a +// separate registry; an application that wants a function in both calls both. +func RegisterTemplateFunc(name string, fn any) error { + return templateFuncs.Register(name, fn) +} + +// ReplaceTemplateFunc adds fn to the functions available to SSH certificate +// templates, replacing a built-in or previously registered function with the +// same name. Use [RegisterTemplateFunc] unless the replacement is intended. +func ReplaceTemplateFunc(name string, fn any) error { + return templateFuncs.Replace(name, fn) +} + +// UnregisterTemplateFunc removes a registered function. It returns true if a +// function was removed. +func UnregisterTemplateFunc(name string) bool { + return templateFuncs.Unregister(name) +} + +// builtinFuncMap returns the functions provided by this package, excluding +// those registered by the application. +func builtinFuncMap(err *TemplateError) template.FuncMap { + return templates.GetFuncMap(&err.Message) +} diff --git a/sshutil/funcs_test.go b/sshutil/funcs_test.go new file mode 100644 index 00000000..46452519 --- /dev/null +++ b/sshutil/funcs_test.go @@ -0,0 +1,69 @@ +package sshutil + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/x509util" +) + +func TestRegisterTemplateFunc(t *testing.T) { + cr := CertificateRequest{Key: mustGeneratePublicKey(t), Type: UserCert.String()} + data := CreateTemplateData(UserCert, "jane@example.com", []string{"jane"}) + + require.NoError(t, RegisterTemplateFunc("testPrincipals", func(data any) (any, error) { + m, _ := data.(TemplateData) + return m[PrincipalsKey], nil + })) + t.Cleanup(func() { UnregisterTemplateFunc("testPrincipals") }) + + var o Options + require.NoError(t, WithTemplate(`{{ testPrincipals $ | toJson }}`, data)(cr, &o)) + assert.Equal(t, `["jane"]`, o.CertBuffer.String()) +} + +func TestRegisterTemplateFuncErrors(t *testing.T) { + require.Error(t, RegisterTemplateFunc("", func() string { return "" })) + require.Error(t, RegisterTemplateFunc("notfn", "a string")) + + err := RegisterTemplateFunc("toJson", func() string { return "" }) + require.Error(t, err) + assert.Contains(t, err.Error(), "built in and cannot be replaced") +} + +// TestRegistriesAreSeparate checks that a function registered for one kind of +// certificate is not available to the other. +func TestRegistriesAreSeparate(t *testing.T) { + require.NoError(t, RegisterTemplateFunc("testSSHOnly", func() string { return "ssh" })) + t.Cleanup(func() { UnregisterTemplateFunc("testSSHOnly") }) + + cr := CertificateRequest{Key: mustGeneratePublicKey(t), Type: UserCert.String()} + data := CreateTemplateData(UserCert, "jane@example.com", []string{"jane"}) + + var o Options + require.NoError(t, WithTemplate(`{{ testSSHOnly }}`, data)(cr, &o)) + assert.Equal(t, "ssh", o.CertBuffer.String()) + + // The same name is undefined for X.509 templates. + signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + xcr, err := x509util.CreateCertificateRequest("foo", []string{"foo.com"}, signer) + require.NoError(t, err) + var xo x509util.Options + err = x509util.WithTemplate(`{{ testSSHOnly }}`, x509util.TemplateData{})(xcr, &xo) + require.Error(t, err) + assert.Contains(t, err.Error(), `function "testSSHOnly" not defined`) + + // An application that wants it in both registers with both. + require.NoError(t, x509util.RegisterTemplateFunc("testSSHOnly", func() string { return "x509" })) + t.Cleanup(func() { x509util.UnregisterTemplateFunc("testSSHOnly") }) + + var xo2 x509util.Options + require.NoError(t, x509util.WithTemplate(`{{ testSSHOnly }}`, x509util.TemplateData{})(xcr, &xo2)) + assert.Equal(t, "x509", xo2.CertBuffer.String()) +} diff --git a/sshutil/options.go b/sshutil/options.go index 8b937bdd..8d664304 100644 --- a/sshutil/options.go +++ b/sshutil/options.go @@ -7,8 +7,6 @@ import ( "text/template" "github.com/pkg/errors" - - "go.step.sm/crypto/internal/templates" ) // Options are the options that can be passed to NewCertificate. @@ -36,7 +34,9 @@ func GetFuncMap() template.FuncMap { } func getFuncMap(err *TemplateError) template.FuncMap { - return templates.GetFuncMap(&err.Message) + funcMap := builtinFuncMap(err) + templateFuncs.Apply(funcMap) + return funcMap } // WithTemplate is an options that executes the given template text with the diff --git a/x509util/funcs.go b/x509util/funcs.go new file mode 100644 index 00000000..73b374c6 --- /dev/null +++ b/x509util/funcs.go @@ -0,0 +1,57 @@ +package x509util + +import ( + "text/template" + + "go.step.sm/crypto/internal/templates" +) + +// templateFuncs holds the functions registered by the application. The reserved +// set is this package's own function map, so a registration cannot shadow one. +var templateFuncs = templates.NewRegistry(func() map[string]struct{} { + names := map[string]struct{}{} + for name := range builtinFuncMap(new(TemplateError)) { + names[name] = struct{}{} + } + return names +}) + +// RegisterTemplateFunc adds fn to the functions available to X.509 certificate +// templates. It returns an error if name is already registered or built in. +// +// Register during start-up. "text/template" resolves function names when it +// parses, so a template rendered before the call will fail to parse. +// +// A function receives only its own arguments. One that needs the template data +// takes it as a parameter, which the template passes as "$" rather than ".", +// as the dot is rebound inside a range block: +// +// {{ cel "device.serial" $ | toJson }} +func RegisterTemplateFunc(name string, fn any) error { + return templateFuncs.Register(name, fn) +} + +// ReplaceTemplateFunc adds fn to the functions available to X.509 certificate +// templates, replacing a built-in or previously registered function with the +// same name. Use [RegisterTemplateFunc] unless the replacement is intended. +func ReplaceTemplateFunc(name string, fn any) error { + return templateFuncs.Replace(name, fn) +} + +// UnregisterTemplateFunc removes a registered function. It returns true if a +// function was removed. +func UnregisterTemplateFunc(name string) bool { + return templateFuncs.Unregister(name) +} + +// builtinFuncMap returns the functions provided by this package, excluding +// those registered by the application. +func builtinFuncMap(err *TemplateError) template.FuncMap { + funcMap := templates.GetFuncMap(&err.Message) + // asn1 methods + funcMap["asn1Enc"] = asn1Encode + funcMap["asn1Marshal"] = asn1Marshal + funcMap["asn1Seq"] = asn1Sequence + funcMap["asn1Set"] = asn1Set + return funcMap +} diff --git a/x509util/funcs_replace_test.go b/x509util/funcs_replace_test.go new file mode 100644 index 00000000..d96bf119 --- /dev/null +++ b/x509util/funcs_replace_test.go @@ -0,0 +1,48 @@ +package x509util + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReplaceTemplateFunc checks that a built-in can be replaced deliberately, +// but not by [RegisterTemplateFunc]. +func TestReplaceTemplateFunc(t *testing.T) { + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + + require.Error(t, RegisterTemplateFunc("toJson", func(any) string { return "replaced" })) + + require.NoError(t, ReplaceTemplateFunc("toJson", func(any) string { return "replaced" })) + t.Cleanup(func() { UnregisterTemplateFunc("toJson") }) + + var o Options + require.NoError(t, WithTemplate(`{{ toJson .Subject }}`, TemplateData{})(cr, &o)) + assert.Equal(t, "replaced", o.CertBuffer.String()) + + // Removing it restores the built-in. + UnregisterTemplateFunc("toJson") + var o2 Options + require.NoError(t, WithTemplate(`{{ toJson "x" }}`, TemplateData{})(cr, &o2)) + assert.Equal(t, `"x"`, o2.CertBuffer.String()) +} + +func TestReplaceTemplateFuncOverridesARegistration(t *testing.T) { + require.NoError(t, RegisterTemplateFunc("testReplaceMe", func() string { return "first" })) + t.Cleanup(func() { UnregisterTemplateFunc("testReplaceMe") }) + + require.NoError(t, ReplaceTemplateFunc("testReplaceMe", func() string { return "second" })) + + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + var o Options + require.NoError(t, WithTemplate(`{{ testReplaceMe }}`, TemplateData{})(cr, &o)) + assert.Equal(t, "second", o.CertBuffer.String()) +} + +func TestReplaceTemplateFuncStillValidates(t *testing.T) { + require.Error(t, ReplaceTemplateFunc("", func() string { return "" })) + require.Error(t, ReplaceTemplateFunc("bad-name", func() string { return "" })) + require.Error(t, ReplaceTemplateFunc("notfn", "a string")) + require.Error(t, ReplaceTemplateFunc("nilfn", nil)) +} diff --git a/x509util/funcs_test.go b/x509util/funcs_test.go new file mode 100644 index 00000000..67bb7097 --- /dev/null +++ b/x509util/funcs_test.go @@ -0,0 +1,118 @@ +package x509util + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegisterTemplateFunc(t *testing.T) { + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + + require.NoError(t, RegisterTemplateFunc("testShout", func(s string) string { + return s + "!" + })) + t.Cleanup(func() { UnregisterTemplateFunc("testShout") }) + + var o Options + require.NoError(t, WithTemplate(`{{ testShout "hello" }}`, TemplateData{})(cr, &o)) + assert.Equal(t, "hello!", o.CertBuffer.String()) +} + +// A function receives only its own arguments, so a template that needs the +// template data passes it with $. +func TestRegisterTemplateFuncReachesTheData(t *testing.T) { + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + + require.NoError(t, RegisterTemplateFunc("testPick", func(key string, data any) (any, error) { + m, _ := data.(TemplateData) + return m[key], nil + })) + t.Cleanup(func() { UnregisterTemplateFunc("testPick") }) + + data := TemplateData{ + SubjectKey: Subject{CommonName: "example"}, + "custom": []string{"a", "b"}, + } + + t.Run("at the top level", func(t *testing.T) { + var o Options + require.NoError(t, WithTemplate(`{{ testPick "custom" $ | toJson }}`, data)(cr, &o)) + assert.Equal(t, `["a","b"]`, o.CertBuffer.String()) + }) + + // $ is the value the template was executed with wherever it appears; the + // dot is not. + t.Run("inside a range block", func(t *testing.T) { + var o Options + text := `{{ range $i, $v := (testPick "custom" $) }}{{ testPick "custom" $ | toJson }}{{ end }}` + require.NoError(t, WithTemplate(text, data)(cr, &o)) + assert.Equal(t, `["a","b"]["a","b"]`, o.CertBuffer.String()) + }) +} + +func TestRegisterTemplateFuncErrors(t *testing.T) { + tests := []struct { + name string + fnName string + fn any + wantErr string + }{ + {"empty name", "", func() string { return "" }, "name is required"}, + {"nil function", "nilfn", nil, "is nil"}, + {"not a function", "notfn", "a string", "not a function"}, + {"invalid identifier", "not-an-identifier", func() string { return "" }, "not a valid identifier"}, + {"leading digit", "1abc", func() string { return "" }, "not a valid identifier"}, + {"shadows sprig", "toJson", func() string { return "" }, "built in and cannot be replaced"}, + {"shadows fail", "fail", func() string { return "" }, "built in and cannot be replaced"}, + {"shadows asn1", "asn1Enc", func() string { return "" }, "built in and cannot be replaced"}, + {"shadows time helper", "toTime", func() string { return "" }, "built in and cannot be replaced"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := RegisterTemplateFunc(tt.fnName, tt.fn) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestRegisterTemplateFuncRejectsDuplicates(t *testing.T) { + require.NoError(t, RegisterTemplateFunc("dup", func() string { return "first" })) + t.Cleanup(func() { UnregisterTemplateFunc("dup") }) + + err := RegisterTemplateFunc("dup", func() string { return "second" }) + require.Error(t, err) + assert.Contains(t, err.Error(), "already registered") +} + +func TestUnregisterTemplateFunc(t *testing.T) { + assert.False(t, UnregisterTemplateFunc("never-registered")) + + require.NoError(t, RegisterTemplateFunc("temp", func() string { return "" })) + assert.True(t, UnregisterTemplateFunc("temp")) + + // A template calling a function that is not registered fails to parse. + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + var o Options + err := WithTemplate(`{{ temp }}`, TemplateData{})(cr, &o) + require.Error(t, err) + assert.Contains(t, err.Error(), `function "temp" not defined`) +} + +// TestBuiltinsAreUnaffected checks that registering leaves the built-in +// functions in place. +func TestBuiltinsAreUnaffected(t *testing.T) { + require.NoError(t, RegisterTemplateFunc("extra", func() string { return "x" })) + t.Cleanup(func() { UnregisterTemplateFunc("extra") }) + + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + var o Options + text := `{{ toJson .Subject.CommonName }}{{ "a" | upper }}{{ extra }}` + require.NoError(t, WithTemplate(text, TemplateData{ + SubjectKey: Subject{CommonName: "example"}, + })(cr, &o)) + assert.Equal(t, `"example"Ax`, o.CertBuffer.String()) +} diff --git a/x509util/options.go b/x509util/options.go index c56bba66..84a65705 100644 --- a/x509util/options.go +++ b/x509util/options.go @@ -12,8 +12,6 @@ import ( "github.com/pkg/errors" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/cryptobyte/asn1" - - "go.step.sm/crypto/internal/templates" ) // Options are the options that can be passed to NewCertificate. @@ -51,12 +49,8 @@ func GetFuncMap() template.FuncMap { } func getFuncMap(err *TemplateError) template.FuncMap { - funcMap := templates.GetFuncMap(&err.Message) - // asn1 methods - funcMap["asn1Enc"] = asn1Encode - funcMap["asn1Marshal"] = asn1Marshal - funcMap["asn1Seq"] = asn1Sequence - funcMap["asn1Set"] = asn1Set + funcMap := builtinFuncMap(err) + templateFuncs.Apply(funcMap) return funcMap }