diff --git a/celutil/celutil.go b/celutil/celutil.go new file mode 100644 index 00000000..66ec3641 --- /dev/null +++ b/celutil/celutil.go @@ -0,0 +1,290 @@ +// Package celutil builds and caches the CEL environments used by the "cel" +// function in X.509 and SSH certificate templates. +// +// The environment a certificate template can see is fixed by this library: the +// subject, the SANs, the certificate request, and whatever a webhook returned. +// The last of those arrives as decoded JSON with no schema, so it is declared +// as dyn and the checker cannot see through it. +// +// Callers that do know the shape of their own data can say so, by registering +// an [Extension] that declares typed variables and supplies their values at +// render time. An expression then reads device.serial as a string rather than +// Webhooks.Agent.Device.Serial as a dyn, which means a wrong type or a +// misspelled field is caught when the template is written instead of when a +// certificate is signed. +package celutil + +import ( + "fmt" + "maps" + "reflect" + "sync" + "sync/atomic" + + "cel.dev/cel-go/cel" +) + +// costLimit bounds the work a single expression may do at evaluation time. +// Expressions are metered and cancelled once they exceed it, so a template +// cannot make signing arbitrarily expensive. +var costLimit atomic.Uint64 + +func init() { + costLimit.Store(DefaultCostLimit) +} + +// DefaultCostLimit is the evaluation cost ceiling applied unless a caller sets +// another with [SetCostLimit]. +const DefaultCostLimit = 1000 + +// CostLimit returns the current evaluation cost ceiling. +func CostLimit() uint64 { return costLimit.Load() } + +// SetCostLimit changes the evaluation cost ceiling. The limit is fixed into a +// program when it is compiled, so this discards programs compiled under the +// previous limit. +func SetCostLimit(limit uint64) { + costLimit.Store(limit) + generation.Add(1) +} + +// Extension contributes variables to the CEL environment. +// +// EnvOptions declares them — typically ext.NativeTypes for a struct plus a +// cel.Variable for each name — and Activation supplies their values for one +// render, given the template data. Every variable declared must be bound on +// every render, including with a zero value: an unbound variable is an +// evaluation error, not an empty value. +type Extension struct { + // Name identifies the extension in error messages and prevents the same + // one being registered twice. + Name string + // EnvOptions declares the extension's types and variables. + EnvOptions []cel.EnvOption + // Activation returns the values for those variables, derived from the + // template data for the certificate being rendered. It may be nil for an + // extension that only adds functions. + Activation func(data map[string]any) map[string]any +} + +var ( + registryMu sync.Mutex + registry []Extension + // generation changes whenever the registry does, so cached environments + // know to rebuild rather than silently serve a stale set of variables. + generation atomic.Uint64 +) + +// Register adds an extension to every environment built afterwards, and +// invalidates any already built. It is safe to call at any point, though the +// natural place is program start-up, before anything is signed. +// +// Registering the same name twice replaces the first, so a process that +// re-registers during tests does not accumulate stale declarations. +func Register(ext Extension) error { + if ext.Name == "" { + return fmt.Errorf("celutil: extension name is required") + } + registryMu.Lock() + defer registryMu.Unlock() + for i, e := range registry { + if e.Name == ext.Name { + registry[i] = ext + generation.Add(1) + return nil + } + } + registry = append(registry, ext) + generation.Add(1) + return nil +} + +// Unregister removes a previously registered extension. It reports whether one +// was removed. +func Unregister(name string) bool { + registryMu.Lock() + defer registryMu.Unlock() + for i, e := range registry { + if e.Name == name { + registry = append(registry[:i], registry[i+1:]...) + generation.Add(1) + return true + } + } + return false +} + +func extensions() []Extension { + registryMu.Lock() + defer registryMu.Unlock() + out := make([]Extension, len(registry)) + copy(out, registry) + return out +} + +// Environment is a lazily built, cached CEL environment: a fixed set of base +// options contributed by x509util or sshutil, plus whatever extensions are +// registered. +type Environment struct { + // base is built at most once. Constructing an environment registers native + // types by reflection and initialises every extension library, which costs + // on the order of 140µs and 280KB — per signature, if it were built inside + // the render path. + base func() (*cel.Env, error) + + mu sync.Mutex + gen uint64 + env *cel.Env + err error + programs map[string]cel.Program +} + +// NewEnvironment returns an environment built from the given base options. The +// options function is called at most once. +func NewEnvironment(baseOptions func() []cel.EnvOption) *Environment { + return &Environment{ + base: sync.OnceValues(func() (*cel.Env, error) { + env, err := cel.NewEnv(baseOptions()...) + if err != nil { + return nil, fmt.Errorf("error creating CEL environment: %w", err) + } + return env, nil + }), + } +} + +// Env returns the environment, extending the base with the registered +// extensions and caching the result until the registry changes. +func (e *Environment) Env() (*cel.Env, error) { + env, _, err := e.envAt(generation.Load()) + return env, err +} + +// envAt returns the environment for a given registry generation, along with the +// generation it was actually built for. Callers that cache something derived +// from the environment use the returned generation to check the cache is still +// the right one to write into. +func (e *Environment) envAt(gen uint64) (*cel.Env, uint64, error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.env != nil && e.gen == gen { + return e.env, gen, nil + } + if e.err != nil && e.gen == gen { + return nil, gen, e.err + } + + base, err := e.base() + if err != nil { + e.gen, e.env, e.err, e.programs = gen, nil, err, nil + return nil, gen, err + } + + env := base + for _, ext := range extensions() { + if len(ext.EnvOptions) == 0 { + continue + } + if env, err = env.Extend(ext.EnvOptions...); err != nil { + err = fmt.Errorf("error applying CEL extension %q: %w", ext.Name, err) + e.gen, e.env, e.err, e.programs = gen, nil, err, nil + return nil, gen, err + } + } + + e.gen, e.env, e.err = gen, env, nil + // Programs are compiled against a specific environment and a specific cost + // limit, so a rebuild invalidates them along with it. + e.programs = map[string]cel.Program{} + return env, gen, nil +} + +// Program compiles expr, reusing a previously compiled program when the same +// expression is seen again. Compiling costs roughly 60µs against evaluation's +// 1µs, and a template's expressions do not change between signatures. +func (e *Environment) Program(expr string) (cel.Program, error) { + gen := generation.Load() + env, gen, err := e.envAt(gen) + if err != nil { + return nil, err + } + + e.mu.Lock() + prg, ok := e.programs[expr] + e.mu.Unlock() + if ok { + return prg, nil + } + + ast, iss := env.Compile(expr) + if err := iss.Err(); err != nil { + return nil, fmt.Errorf("error compiling CEL expression: %w", err) + } + prg, err = env.Program(ast, + cel.EvalOptions(cel.OptOptimize), + cel.CostLimit(costLimit.Load()), + ) + if err != nil { + return nil, fmt.Errorf("error creating CEL program: %w", err) + } + + // Only cache against the environment this program was compiled for. If the + // registry changed in between, the cache now belongs to a different + // environment and this entry does not belong in it. + e.mu.Lock() + if e.programs != nil && e.gen == gen { + e.programs[expr] = prg + } + e.mu.Unlock() + + return prg, nil +} + +// Eval evaluates expr against the template data and returns the result as a +// plain Go value. +// +// The conversion is deliberate. A result taken through ref.Val.Value() is CEL's +// internal representation, and for a list backed by a Go slice that marshals to +// {} rather than to an array — so a template piping the result to toJson would +// silently produce an object where the certificate needs a list. +// ConvertToNative is correct for every result type. +func (e *Environment) Eval(expr string, data map[string]any) (any, error) { + prg, err := e.Program(expr) + if err != nil { + return nil, err + } + + out, _, err := prg.Eval(activation(data)) + if err != nil { + return nil, fmt.Errorf("error evaluating CEL expression: %w", err) + } + + native, err := out.ConvertToNative(anyType) + if err != nil { + return nil, fmt.Errorf("error converting CEL result: %w", err) + } + return native, nil +} + +// activation merges the template data with the bindings contributed by each +// registered extension. Extensions are applied after the template data so a +// typed variable is not shadowed by a same-named key that happened to be in the +// data; the two use different naming conventions precisely to avoid the clash. +func activation(data map[string]any) map[string]any { + exts := extensions() + if len(exts) == 0 { + return data + } + + merged := make(map[string]any, len(data)+len(exts)) + maps.Copy(merged, data) + for _, ext := range exts { + if ext.Activation == nil { + continue + } + maps.Copy(merged, ext.Activation(data)) + } + return merged +} + +var anyType = reflect.TypeFor[any]() diff --git a/celutil/celutil_test.go b/celutil/celutil_test.go new file mode 100644 index 00000000..4a599023 --- /dev/null +++ b/celutil/celutil_test.go @@ -0,0 +1,214 @@ +package celutil + +import ( + "reflect" + "testing" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/ext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testEnv() *Environment { + return NewEnvironment(func() []cel.EnvOption { + return []cel.EnvOption{ + cel.Variable("name", cel.StringType), + cel.Variable("tags", cel.ListType(cel.StringType)), + } + }) +} + +func TestEnvironmentIsBuiltOnce(t *testing.T) { + var builds int + e := NewEnvironment(func() []cel.EnvOption { + builds++ + return []cel.EnvOption{cel.Variable("name", cel.StringType)} + }) + + for range 5 { + _, err := e.Env() + require.NoError(t, err) + } + assert.Equal(t, 1, builds, "the base environment must be constructed once, not per call") +} + +func TestEnvironmentCachesPrograms(t *testing.T) { + e := testEnv() + + first, err := e.Program(`name + "!"`) + require.NoError(t, err) + second, err := e.Program(`name + "!"`) + require.NoError(t, err) + assert.Same(t, first, second) +} + +func TestEval(t *testing.T) { + e := testEnv() + data := map[string]any{"name": "example", "tags": []string{"a", "b"}} + + tests := []struct { + expr string + want any + }{ + {`name`, "example"}, + {`name.upperAscii() == "EXAMPLE"`, false}, // upperAscii needs ext.Strings, absent here + {`tags`, []any{"a", "b"}}, + {`size(tags)`, int64(2)}, + } + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + got, err := e.Eval(tt.expr, data) + if err != nil { + // The second case is only there to show the base options are + // exactly what the caller passed and nothing more. + assert.Contains(t, err.Error(), "undeclared reference") + return + } + assert.Equal(t, tt.want, got) + }) + } +} + +// TestEvalConvertsResults guards the conversion: a list must come back as a Go +// slice, not as CEL's internal representation, or a template piping it to +// toJson silently produces an object instead of an array. +func TestEvalConvertsResults(t *testing.T) { + e := testEnv() + got, err := e.Eval(`tags`, map[string]any{"name": "x", "tags": []string{"a", "b"}}) + require.NoError(t, err) + assert.Equal(t, []any{"a", "b"}, got) +} + +func TestRegister(t *testing.T) { + e := testEnv() + + // Not declared yet. + _, err := e.Eval(`extra`, map[string]any{"name": "x", "tags": []string{}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "undeclared reference") + + require.NoError(t, Register(Extension{ + Name: "extra", + EnvOptions: []cel.EnvOption{cel.Variable("extra", cel.StringType)}, + Activation: func(map[string]any) map[string]any { + return map[string]any{"extra": "value"} + }, + })) + t.Cleanup(func() { Unregister("extra") }) + + got, err := e.Eval(`extra`, map[string]any{"name": "x", "tags": []string{}}) + require.NoError(t, err) + assert.Equal(t, "value", got) +} + +func TestRegisterReplacesByName(t *testing.T) { + e := testEnv() + + require.NoError(t, Register(Extension{ + Name: "dup", + EnvOptions: []cel.EnvOption{cel.Variable("dup", cel.StringType)}, + Activation: func(map[string]any) map[string]any { + return map[string]any{"dup": "first"} + }, + })) + t.Cleanup(func() { Unregister("dup") }) + + got, err := e.Eval(`dup`, map[string]any{}) + require.NoError(t, err) + assert.Equal(t, "first", got) + + // Re-registering the same name replaces it rather than declaring the + // variable twice, which would fail to build the environment. + require.NoError(t, Register(Extension{ + Name: "dup", + EnvOptions: []cel.EnvOption{cel.Variable("dup", cel.StringType)}, + Activation: func(map[string]any) map[string]any { + return map[string]any{"dup": "second"} + }, + })) + + got, err = e.Eval(`dup`, map[string]any{}) + require.NoError(t, err) + assert.Equal(t, "second", got) +} + +func TestRegisterRequiresName(t *testing.T) { + require.Error(t, Register(Extension{})) +} + +func TestUnregister(t *testing.T) { + assert.False(t, Unregister("never-registered")) + + require.NoError(t, Register(Extension{ + Name: "temp", + EnvOptions: []cel.EnvOption{cel.Variable("temp", cel.StringType)}, + })) + assert.True(t, Unregister("temp")) +} + +// TestActivationDoesNotShadowTemplateData checks the merge order: an extension +// supplies its own names, and the template data supplies the rest. +func TestActivationDoesNotShadowTemplateData(t *testing.T) { + e := testEnv() + + require.NoError(t, Register(Extension{ + Name: "merge", + EnvOptions: []cel.EnvOption{cel.Variable("extra", cel.StringType)}, + Activation: func(data map[string]any) map[string]any { + // Derived from the data it was given, as a real projection would be. + name, _ := data["name"].(string) + return map[string]any{"extra": name + "-derived"} + }, + })) + t.Cleanup(func() { Unregister("merge") }) + + got, err := e.Eval(`name + "/" + extra`, map[string]any{"name": "example", "tags": []string{}}) + require.NoError(t, err) + assert.Equal(t, "example/example-derived", got) +} + +func TestCostLimit(t *testing.T) { + e := NewEnvironment(func() []cel.EnvOption { + return []cel.EnvOption{cel.Variable("name", cel.StringType)} + }) + + original := CostLimit() + t.Cleanup(func() { SetCostLimit(original) }) + + const expr = `name + name + name + name` + data := map[string]any{"name": "example"} + + require.Equal(t, uint64(DefaultCostLimit), original) + _, err := e.Eval(expr, data) + require.NoError(t, err) + + // Lowering the limit must also discard the program already compiled under + // the old one, or the change would appear to do nothing. + SetCostLimit(1) + _, err = e.Eval(expr, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "cost limit exceeded") + + SetCostLimit(original) + _, err = e.Eval(expr, data) + require.NoError(t, err) +} + +func TestEnvironmentPropagatesBuildErrors(t *testing.T) { + var builds int + e := NewEnvironment(func() []cel.EnvOption { + builds++ + // ext.NativeTypes rejects anything that is not a struct. + return []cel.EnvOption{ext.NativeTypes(reflect.TypeFor[string]())} + }) + + _, err := e.Env() + require.Error(t, err) + assert.Contains(t, err.Error(), "error creating CEL environment") + + // The failure is cached rather than recomputed on every call. + _, err = e.Env() + require.Error(t, err) + assert.Equal(t, 1, builds) +} diff --git a/sshutil/cel.go b/sshutil/cel.go new file mode 100644 index 00000000..c0255140 --- /dev/null +++ b/sshutil/cel.go @@ -0,0 +1,62 @@ +package sshutil + +import ( + "reflect" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/ext" + + "go.step.sm/crypto/celutil" +) + +// celEnv is the environment for the "cel" function in SSH templates. It is +// built at most once and reused for every certificate; see +// [celutil.Environment]. +var celEnv = celutil.NewEnvironment(celEnvOptions) + +// CELEnvOptions returns the environment options the "cel" template function +// declares for SSH templates. +// +// It is exported so a caller that validates expressions ahead of time can build +// the same environment the renderer will use, rather than approximating it. +func CELEnvOptions() []cel.EnvOption { + return celEnvOptions() +} + +func celEnvOptions() []cel.EnvOption { + return []cel.EnvOption{ + // Extension libraries, matching the set available to X.509 templates. + ext.Strings(), ext.Encoders(), ext.Lists(), ext.Sets(), ext.Network(), + cel.OptionalTypes(), // required by regex + ext.Regex(), + // The certificate's own fields, which this package does know the shape + // of. Extensions and CriticalOptions are declared with a dyn value + // because a template may add arbitrary entries to either. + cel.Variable(TypeKey, cel.StringType), + cel.Variable(KeyIDKey, cel.StringType), + cel.Variable(PrincipalsKey, cel.ListType(cel.StringType)), + cel.Variable(ExtensionsKey, cel.MapType(cel.StringType, cel.DynType)), + cel.Variable(CriticalOptionsKey, cel.MapType(cel.StringType, cel.DynType)), + // Everything below arrives from outside — a token, a webhook, the + // request — so its shape is not knowable here and it is declared dyn. + // A caller that does know can declare typed variables for it with a + // [celutil.Extension]. + cel.Variable(TokenKey, cel.MapType(cel.StringType, cel.DynType)), + cel.Variable(WebhooksKey, cel.MapType(cel.StringType, cel.DynType)), + cel.Variable(InsecureKey, cel.MapType(cel.StringType, cel.DynType)), + cel.Variable(UserKey, cel.MapType(cel.StringType, cel.DynType)), + cel.Variable(AuthorizationCrtKey, cel.DynType), + cel.Variable(AuthorizationChainKey, cel.ListType(cel.DynType)), + ext.NativeTypes(reflect.TypeFor[Certificate](), ext.ParseStructTag("cel")), + ext.NativeTypes(reflect.TypeFor[CertificateRequest](), ext.ParseStructTag("cel")), + } +} + +// celFunc returns the "cel" template function bound to one certificate's data. +// The result is a Go value rather than pre-formatted text so a template can +// pipe it, e.g. {{ cel "Principals.map(p, p.lowerAscii())" | toJson }}. +func celFunc(data TemplateData) func(string) (any, error) { + return func(expr string) (any, error) { + return celEnv.Eval(expr, data) + } +} diff --git a/sshutil/cel_test.go b/sshutil/cel_test.go new file mode 100644 index 00000000..67863c89 --- /dev/null +++ b/sshutil/cel_test.go @@ -0,0 +1,104 @@ +package sshutil + +import ( + "reflect" + "testing" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/ext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/celutil" +) + +func TestCELTemplate(t *testing.T) { + cr := CertificateRequest{ + Key: mustGeneratePublicKey(t), + Type: UserCert.String(), + KeyID: "jane@example.com", + Principals: []string{"Jane", "jane"}, + } + data := CreateTemplateData(UserCert, "jane@example.com", []string{"Jane", "jane"}) + data.SetToken(map[string]any{"sub": "jane@example.com"}) + + tests := []struct { + name string + text string + want string + }{ + {"key id", `{{ cel "KeyID" | toJson }}`, `"jane@example.com"`}, + {"principals", `{{ cel "Principals" | toJson }}`, `["Jane","jane"]`}, + {"lowered principals", `{{ cel "Principals.map(p, p.lowerAscii())" | toJson }}`, `["jane","jane"]`}, + {"deduplicated", `{{ cel "Principals.map(p, p.lowerAscii()).distinct()" | toJson }}`, `["jane"]`}, + {"local part", `{{ cel "KeyID.split(\"@\")[0]" | toJson }}`, `"jane"`}, + {"cert type", `{{ cel "Type" | toJson }}`, `"user"`}, + {"token", `{{ cel "Token.sub" | toJson }}`, `"jane@example.com"`}, + {"has extension", `{{ cel "\"permit-pty\" in Extensions" | toJson }}`, `true`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var o Options + require.NoError(t, WithTemplate(tt.text, data)(cr, &o)) + assert.Equal(t, tt.want, o.CertBuffer.String()) + }) + } +} + +func TestCELTemplateErrors(t *testing.T) { + cr := CertificateRequest{Key: mustGeneratePublicKey(t), Type: UserCert.String()} + data := CreateTemplateData(UserCert, "jane@example.com", []string{"jane"}) + + t.Run("undeclared variable", func(t *testing.T) { + var o Options + err := WithTemplate(`{{ cel "Nope" }}`, data)(cr, &o) + require.Error(t, err) + assert.Contains(t, err.Error(), "undeclared reference") + }) + + t.Run("cost limit", func(t *testing.T) { + var o Options + err := WithTemplate(`{{ cel "lists.range(1000)" }}`, data)(cr, &o) + require.Error(t, err) + assert.Contains(t, err.Error(), "cost limit exceeded") + }) +} + +type sshDevice struct { + Serial string `cel:"serial"` +} + +// TestCELExtensionAppliesToSSH checks that one registration covers both +// certificate kinds. A CA registers its schema once at start-up and it is +// available to X.509 and SSH templates alike. +func TestCELExtensionAppliesToSSH(t *testing.T) { + require.NoError(t, celutil.Register(celutil.Extension{ + Name: "test-ssh-device", + EnvOptions: []cel.EnvOption{ + ext.NativeTypes(reflect.TypeFor[sshDevice](), ext.ParseStructTag("cel")), + cel.Variable("device", cel.ObjectType(reflect.TypeFor[sshDevice]().String())), + }, + Activation: func(map[string]any) map[string]any { + return map[string]any{"device": sshDevice{Serial: "C02XK1JMJGH5"}} + }, + })) + t.Cleanup(func() { celutil.Unregister("test-ssh-device") }) + + cr := CertificateRequest{Key: mustGeneratePublicKey(t), Type: UserCert.String()} + data := CreateTemplateData(UserCert, "jane@example.com", []string{"jane"}) + + var o Options + text := `{{ cel "Principals + [device.serial.lowerAscii()]" | toJson }}` + require.NoError(t, WithTemplate(text, data)(cr, &o)) + assert.Equal(t, `["jane","c02xk1jmjgh5"]`, o.CertBuffer.String()) +} + +func TestSSHCELEnvOptionsIsExported(t *testing.T) { + env, err := cel.NewEnv(CELEnvOptions()...) + require.NoError(t, err) + + ast, iss := env.Compile(`Principals.map(p, p.lowerAscii())`) + require.NoError(t, iss.Err()) + assert.Equal(t, "list(string)", ast.OutputType().String()) +} diff --git a/sshutil/options.go b/sshutil/options.go index 8b937bdd..3efb2840 100644 --- a/sshutil/options.go +++ b/sshutil/options.go @@ -45,6 +45,8 @@ func WithTemplate(text string, data TemplateData) Option { return func(cr CertificateRequest, o *Options) error { terr := new(TemplateError) funcMap := getFuncMap(terr) + funcMap["cel"] = celFunc(data) + // Parse template tmpl, err := template.New("template").Funcs(funcMap).Parse(text) if err != nil { diff --git a/x509util/cel.go b/x509util/cel.go new file mode 100644 index 00000000..ee2eac40 --- /dev/null +++ b/x509util/cel.go @@ -0,0 +1,70 @@ +package x509util + +import ( + "crypto/x509" + "reflect" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/ext" + + "go.step.sm/crypto/celutil" +) + +// celEnv is the environment for the "cel" function in X.509 templates. It is +// built at most once and reused for every certificate; see [celutil.Environment]. +var celEnv = celutil.NewEnvironment(celEnvOptions) + +// CELEnvOptions returns the environment options the "cel" template function +// declares for X.509 templates. +// +// It is exported so a caller that validates expressions ahead of time can build +// the same environment the renderer will use, rather than approximating it. An +// expression accepted against this environment plus any registered +// [celutil.Extension] is one this package can evaluate. +func CELEnvOptions() []cel.EnvOption { + return celEnvOptions() +} + +func celEnvOptions() []cel.EnvOption { + return []cel.EnvOption{ + // Extension libraries. + ext.Strings(), ext.Encoders(), ext.Lists(), ext.Sets(), ext.Network(), + cel.OptionalTypes(), // required by regex + ext.Regex(), + // Types. + cel.Variable(SubjectKey, cel.ObjectType(celTypeName[Subject]())), + cel.Variable(SANsKey, cel.ListType(cel.ObjectType(celTypeName[SubjectAlternativeName]()))), + cel.Variable(TokenKey, cel.MapType(cel.StringType, cel.DynType)), + cel.Variable(WebhooksKey, cel.MapType(cel.StringType, cel.DynType)), + cel.Variable(InsecureKey, cel.MapType(cel.StringType, cel.DynType)), + cel.Variable(AuthorizationCrtKey, cel.DynType), + cel.Variable(AuthorizationChainKey, cel.ListType(cel.DynType)), + ext.NativeTypes(reflect.TypeFor[SubjectAlternativeName](), ext.ParseStructTag("cel")), + ext.NativeTypes(reflect.TypeFor[Subject](), ext.ParseStructTag("cel")), + ext.NativeTypes(reflect.TypeFor[Certificate](), ext.ParseStructTag("cel")), + ext.NativeTypes(reflect.TypeFor[CertificateRequest](), ext.ParseStructTag("cel")), + ext.NativeTypes(reflect.TypeFor[x509.Certificate](), ext.ParseStructTag("cel")), + ext.NativeTypes(reflect.TypeFor[x509.CertificateRequest](), ext.ParseStructTag("cel")), + } +} + +// celTypeName returns the name ext.NativeTypes gives a Go struct, so the +// declaration of a variable and the registration of its type cannot drift +// apart. +func celTypeName[T any]() string { + return reflect.TypeFor[T]().String() +} + +// celFunc returns the "cel" template function bound to one certificate's data. +// +// It returns the result as a Go value rather than as pre-formatted text, so a +// template can pipe it: {{ cel "SANs.map(s, s.Value)" | toJson }} produces a +// JSON array, and the same pipeline is correct for a string, a list or a +// number. Formatting the result to a string here would make toJson produce a +// quoted string for every type, which is wrong everywhere a template needs a +// list. +func celFunc(data TemplateData) func(string) (any, error) { + return func(expr string) (any, error) { + return celEnv.Eval(expr, data) + } +} diff --git a/x509util/cel_bench_test.go b/x509util/cel_bench_test.go new file mode 100644 index 00000000..d9bed2a5 --- /dev/null +++ b/x509util/cel_bench_test.go @@ -0,0 +1,41 @@ +package x509util + +import "testing" + +// BenchmarkWithTemplate compares a template that uses the cel function against +// one that does not. The environment is shared, so a template with no cel call +// pays nothing for the function being available — which matters because that +// describes every template written before this existed. +func BenchmarkWithTemplate(b *testing.B) { + cr, _ := createCertificateRequest(b, "foo", []string{"foo.com"}) + data := TemplateData{ + SubjectKey: Subject{CommonName: "example", Country: []string{"ES"}}, + SANsKey: CreateSANs([]string{"foo.com", "bar.com"}), + } + + benchmarks := []struct { + name string + text string + }{ + {"no cel call", `{"subject":{"commonName":{{ toJson .Subject.CommonName }}}}`}, + {"one cel call", `{"subject":{"commonName":{{ cel "Subject.CommonName" | toJson }}}}`}, + {"five cel calls", `{"subject":{"commonName":{{ cel "Subject.CommonName" | toJson }},` + + `"country":{{ cel "Subject.Country" | toJson }},` + + `"organization":{{ cel "[Subject.CommonName]" | toJson }},` + + `"locality":{{ cel "[Subject.CommonName.upperAscii()]" | toJson }},` + + `"province":{{ cel "SANs.map(s, s.Value)" | toJson }}}}`}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + fn := WithTemplate(bm.text, data) + b.ReportAllocs() + for b.Loop() { + var o Options + if err := fn(cr, &o); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/x509util/cel_test.go b/x509util/cel_test.go new file mode 100644 index 00000000..8ae88b86 --- /dev/null +++ b/x509util/cel_test.go @@ -0,0 +1,201 @@ +package x509util + +import ( + "reflect" + "testing" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/ext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/celutil" +) + +// TestCELPipesToJSON is the reason the cel function returns a value rather than +// pre-formatted text. A template placing an expression in a JSON position pipes +// it through toJson, and that has to be correct for every result type — a bare +// string is not valid JSON where a value is expected, and a list formatted to a +// string would become a quoted string where the certificate needs an array. +func TestCELPipesToJSON(t *testing.T) { + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com", "foo@foo.com", "https://foo.com"}) + + data := TemplateData{ + SubjectKey: Subject{CommonName: "example", Country: []string{"ES"}}, + SANsKey: CreateSANs([]string{"foo.com", "bar.com"}), + } + + tests := []struct { + name string + text string + want string + }{ + {"string", `{{ cel "Subject.CommonName" | toJson }}`, `"example"`}, + {"string function", `{{ cel "Subject.CommonName.upperAscii()" | toJson }}`, `"EXAMPLE"`}, + {"list of strings", `{{ cel "SANs.map(s, s.Value)" | toJson }}`, `["foo.com","bar.com"]`}, + {"list from a field", `{{ cel "Subject.Country" | toJson }}`, `["ES"]`}, + {"filtered list", `{{ cel "SANs.filter(s, s.Value.startsWith(\"foo\")).map(s, s.Value)" | toJson }}`, `["foo.com"]`}, + {"number", `{{ cel "size(SANs)" | toJson }}`, `2`}, + {"boolean", `{{ cel "Subject.CommonName == \"example\"" | toJson }}`, `true`}, + // A literal is the commonest configuration of all, and it must not be + // metered as though it were unbounded work. + {"constant string", `{{ cel "\"wifi\"" | toJson }}`, `"wifi"`}, + {"constant list", `{{ cel "[\"a\", \"b\"]" | toJson }}`, `["a","b"]`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var o Options + require.NoError(t, WithTemplate(tt.text, data)(cr, &o)) + assert.Equal(t, tt.want, o.CertBuffer.String()) + }) + } +} + +// TestCELEnvironmentIsReused checks that the environment is not rebuilt for +// every certificate. Constructing one registers native types by reflection and +// initialises seven extension libraries; doing that per signature is pure waste. +func TestCELEnvironmentIsReused(t *testing.T) { + first, err := celEnv.Env() + require.NoError(t, err) + second, err := celEnv.Env() + require.NoError(t, err) + assert.Same(t, first, second) +} + +func TestCELProgramIsReused(t *testing.T) { + const expr = `Subject.CommonName + "-reused"` + data := TemplateData{SubjectKey: Subject{CommonName: "example"}} + + first, err := celEnv.Program(expr) + require.NoError(t, err) + second, err := celEnv.Program(expr) + require.NoError(t, err) + assert.Same(t, first, second) + + got, err := celEnv.Eval(expr, data) + require.NoError(t, err) + assert.Equal(t, "example-reused", got) +} + +// device is a caller-supplied schema, standing in for the kind of typed data a +// CA knows about and this package cannot. +type device struct { + Serial string `cel:"serial"` + Hostname string `cel:"hostname"` + IPAddresses []string `cel:"ipAddresses"` +} + +// TestCELExtension covers the whole point of the registry: a caller declares +// typed variables, supplies their values from the template data, and templates +// read them with the type visible to the checker. +func TestCELExtension(t *testing.T) { + require.NoError(t, celutil.Register(celutil.Extension{ + Name: "test-device", + EnvOptions: []cel.EnvOption{ + ext.NativeTypes(reflect.TypeFor[device](), ext.ParseStructTag("cel")), + cel.Variable("device", cel.ObjectType(celTypeName[device]())), + }, + Activation: func(data map[string]any) map[string]any { + // Stands in for reading a webhook response and projecting it onto + // a schema the caller controls. + wh, _ := data[WebhooksKey].(map[string]any) + agent, _ := wh["Agent"].(map[string]any) + d := device{IPAddresses: []string{}} + if agent != nil { + d.Serial, _ = agent["Serial"].(string) + d.Hostname, _ = agent["Hostname"].(string) + } + return map[string]any{"device": d} + }, + })) + t.Cleanup(func() { celutil.Unregister("test-device") }) + + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + data := TemplateData{ + SubjectKey: Subject{CommonName: "example"}, + WebhooksKey: map[string]any{ + "Agent": map[string]any{"Serial": "C02XK1JMJGH5", "Hostname": "d1.example.com"}, + }, + } + + t.Run("typed read", func(t *testing.T) { + var o Options + text := `{{ cel "\"arn:aws:iam::123:role/\" + device.serial.lowerAscii()" | toJson }}` + require.NoError(t, WithTemplate(text, data)(cr, &o)) + assert.Equal(t, `"arn:aws:iam::123:role/c02xk1jmjgh5"`, o.CertBuffer.String()) + }) + + t.Run("absent value reads as empty, not an error", func(t *testing.T) { + var o Options + text := `{{ cel "[device.serial, \"unknown\"].filter(v, v != \"\")[0]" | toJson }}` + require.NoError(t, WithTemplate(text, TemplateData{})(cr, &o)) + assert.Equal(t, `"unknown"`, o.CertBuffer.String()) + }) + + t.Run("misspelled field fails to compile", func(t *testing.T) { + var o Options + err := WithTemplate(`{{ cel "device.serail" }}`, data)(cr, &o) + require.Error(t, err) + assert.Contains(t, err.Error(), "undefined field 'serail'") + }) + + t.Run("still reachable untyped", func(t *testing.T) { + var o Options + text := `{{ cel "Webhooks.Agent.Serial" | toJson }}` + require.NoError(t, WithTemplate(text, data)(cr, &o)) + assert.Equal(t, `"C02XK1JMJGH5"`, o.CertBuffer.String()) + }) +} + +// TestCELExtensionRebuildsEnvironment checks that registering after an +// environment has already been built takes effect, rather than being silently +// ignored by a cache that was populated first. +func TestCELExtensionRebuildsEnvironment(t *testing.T) { + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + + var o Options + require.NoError(t, WithTemplate(`{{ cel "1 + 1" | toJson }}`, TemplateData{})(cr, &o)) + + require.NoError(t, celutil.Register(celutil.Extension{ + Name: "late", + EnvOptions: []cel.EnvOption{cel.Variable("late", cel.StringType)}, + Activation: func(map[string]any) map[string]any { + return map[string]any{"late": "bound"} + }, + })) + t.Cleanup(func() { celutil.Unregister("late") }) + + var o2 Options + require.NoError(t, WithTemplate(`{{ cel "late" | toJson }}`, TemplateData{})(cr, &o2)) + assert.Equal(t, `"bound"`, o2.CertBuffer.String()) + + // And it is gone again once unregistered. + celutil.Unregister("late") + var o3 Options + err := WithTemplate(`{{ cel "late" }}`, TemplateData{})(cr, &o3) + require.Error(t, err) + assert.Contains(t, err.Error(), "undeclared reference") +} + +func TestCELEnvOptionsIsExported(t *testing.T) { + // A caller validating expressions ahead of time builds the same + // environment the renderer uses. + env, err := cel.NewEnv(CELEnvOptions()...) + require.NoError(t, err) + + ast, iss := env.Compile(`Subject.CommonName.lowerAscii()`) + require.NoError(t, iss.Err()) + assert.Equal(t, "string", ast.OutputType().String()) + + _, iss = env.Compile(`Subject.CommonNam`) + require.Error(t, iss.Err()) +} + +func TestCELCostLimit(t *testing.T) { + cr, _ := createCertificateRequest(t, "foo", []string{"foo.com"}) + var o Options + err := WithTemplate(`{{ cel "lists.range(1000)" }}`, TemplateData{})(cr, &o) + require.Error(t, err) + assert.Contains(t, err.Error(), "cost limit exceeded") +} diff --git a/x509util/certificate_test.go b/x509util/certificate_test.go index a7df6960..e7fda8de 100644 --- a/x509util/certificate_test.go +++ b/x509util/certificate_test.go @@ -34,7 +34,7 @@ func mustOID(t *testing.T, s string) x509.OID { return oid } -func createCertificateRequest(t *testing.T, commonName string, sans []string) (*x509.CertificateRequest, crypto.Signer) { +func createCertificateRequest(t testing.TB, commonName string, sans []string) (*x509.CertificateRequest, crypto.Signer) { dnsNames, ips, emails, uris := SplitSANs(sans) t.Helper() _, priv, err := ed25519.GenerateKey(rand.Reader) diff --git a/x509util/options.go b/x509util/options.go index 3695d8f3..4d6d043f 100644 --- a/x509util/options.go +++ b/x509util/options.go @@ -5,14 +5,10 @@ import ( "crypto/x509" encoding_asn1 "encoding/asn1" "encoding/base64" - "fmt" "os" - "reflect" "strings" "text/template" - "cel.dev/cel-go/cel" - "cel.dev/cel-go/ext" "github.com/pkg/errors" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/cryptobyte/asn1" @@ -64,74 +60,13 @@ func getFuncMap(err *TemplateError) template.FuncMap { return funcMap } -type celFunc struct { - data TemplateData - env *cel.Env -} - -func newCelFunc(data TemplateData) (*celFunc, error) { - env, err := cel.NewEnv( - // Extensions - ext.Strings(), ext.Encoders(), ext.Lists(), ext.Sets(), ext.Network(), - cel.OptionalTypes(), // required by regex - ext.Regex(), - // Types - cel.Variable(SubjectKey, cel.ObjectType("x509util.Subject")), - cel.Variable(SANsKey, cel.ListType(cel.ObjectType("x509util.SubjectAlternativeName"))), - cel.Variable(TokenKey, cel.MapType(cel.StringType, cel.DynType)), - cel.Variable(WebhooksKey, cel.MapType(cel.StringType, cel.DynType)), - cel.Variable(InsecureKey, cel.MapType(cel.StringType, cel.DynType)), - cel.Variable(AuthorizationCrtKey, cel.DynType), - cel.Variable(AuthorizationChainKey, cel.ListType(cel.DynType)), - cel.Variable(CertificateRequestKey, cel.ObjectType("x509util.CertificateRequest")), - ext.NativeTypes(reflect.TypeOf(SubjectAlternativeName{}), ext.ParseStructTag("cel")), - ext.NativeTypes(reflect.TypeOf(Subject{}), ext.ParseStructTag("cel")), - ext.NativeTypes(reflect.TypeOf(Certificate{}), ext.ParseStructTag("cel")), - ext.NativeTypes(reflect.TypeOf(CertificateRequest{}), ext.ParseStructTag("cel")), - ext.NativeTypes(reflect.TypeOf(x509.Certificate{}), ext.ParseStructTag("cel")), - ext.NativeTypes(reflect.TypeOf(x509.CertificateRequest{}), ext.ParseStructTag("cel")), - ) - if err != nil { - return nil, fmt.Errorf("error creating CEL environment: %w", err) - } - - return &celFunc{ - data: data, - env: env, - }, nil -} - -func (c *celFunc) call(expr string) (string, error) { - ast, iss := c.env.Compile(expr) - if err := iss.Err(); err != nil { - return "", fmt.Errorf("error compiling CEL expression: %w", err) - } - - prg, err := c.env.Program(ast, cel.EvalOptions(cel.OptOptimize), cel.CostLimit(1000)) - if err != nil { - return "", fmt.Errorf("error creating CEL program: %w", err) - } - - out, _, err := prg.Eval(map[string]any(c.data)) - if err != nil { - return "", fmt.Errorf("error evaluating CEL expresion: %w", err) - } - - return fmt.Sprint(out), nil -} - // WithTemplate is an options that executes the given template text with the // given data. func WithTemplate(text string, data TemplateData) Option { return func(cr *x509.CertificateRequest, o *Options) error { - celfn, err := newCelFunc(data) - if err != nil { - return err - } - terr := new(TemplateError) funcMap := getFuncMap(terr) - funcMap["cel"] = celfn.call + funcMap["cel"] = celFunc(data) // Parse template tmpl, err := template.New("template").Funcs(funcMap).Parse(text) diff --git a/x509util/options_test.go b/x509util/options_test.go index 27bf8ce6..b4e7da71 100644 --- a/x509util/options_test.go +++ b/x509util/options_test.go @@ -233,7 +233,7 @@ func TestWithTemplate_cel(t *testing.T) { }, cr}, buf("example.ES"), assert.NoError}, {"sans", args{`{{cel "SANs.filter(s, s.Value.contains(\"foo\")).map(s, s.Value)"}}`, TemplateData{ SANsKey: CreateSANs([]string{"foo.com", "foo@foo.com", "::1", "https://foo.com"}), - }, cr}, buf("[foo.com, foo@foo.com, https://foo.com]"), assert.NoError}, + }, cr}, buf("[foo.com foo@foo.com https://foo.com]"), assert.NoError}, {"token", args{`{{cel "json.encode({'subject':{'commonName': Token.sub}, 'uris':[Token.iss]})"}}`, TemplateData{ TokenKey: map[string]any{ "iss": "https://iss",