diff --git a/go.mod b/go.mod index c12fab7e1a..c1a5cd1550 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ tool ( ) require ( - github.com/Microsoft/cosesign1go v1.4.0 + github.com/Microsoft/cosesign1go v1.6.0-alpha1 github.com/Microsoft/didx509go v0.0.3 github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 github.com/blang/semver/v4 v4.0.0 diff --git a/go.sum b/go.sum index ce144cee6c..56b378b958 100644 --- a/go.sum +++ b/go.sum @@ -364,6 +364,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Microsoft/cosesign1go v1.4.0 h1:VdiqzsilEE6t1GQi98I/h0WpVFM7AyMEeyP8ud7V/BY= github.com/Microsoft/cosesign1go v1.4.0/go.mod h1:1La/HcGw19rRLhPW0S6u55K6LKfti+GQSgGCtrfhVe8= +github.com/Microsoft/cosesign1go v1.5.0 h1:YmQCF8z7dGp50Rp/+rLTLFOFgIfZ1GSUHXPgLLlOlNk= +github.com/Microsoft/cosesign1go v1.5.0/go.mod h1:s7E3nBWxb//ZLhuLAU5u9EZ1qMGBdgZzrKIUW1H/OIY= +github.com/Microsoft/cosesign1go v1.6.0-alpha1 h1:hFyLTdNp9JCBbeRCx7DGwS6secReX4t9JzlIGHTiz6w= +github.com/Microsoft/cosesign1go v1.6.0-alpha1/go.mod h1:7x+fdYtZ4ureEgfVtl2K+nY4MMfujMsCIb5kRuncpmg= github.com/Microsoft/didx509go v0.0.3 h1:n/owuFOXVzCEzSyzivMEolKEouBm9G0NrEDgoTekM8A= github.com/Microsoft/didx509go v0.0.3/go.mod h1:wWt+iQsLzn3011+VfESzznLIp/Owhuj7rLF7yLglYbk= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= diff --git a/internal/protocol/guestresource/resources.go b/internal/protocol/guestresource/resources.go index 7d5988d930..c7eecfe140 100644 --- a/internal/protocol/guestresource/resources.go +++ b/internal/protocol/guestresource/resources.go @@ -240,4 +240,8 @@ type ConfidentialOptions struct { type SecurityPolicyFragment struct { Fragment string `json:"Fragment,omitempty"` + // MediaType is the media type of the blob carried in Fragment. An empty + // value is treated by the guest as the default "application/cose-x509+rego" + // for backward compatibility with older hosts that do not set this field. + MediaType string `json:"MediaType,omitempty"` } diff --git a/internal/regopolicyinterpreter/regopolicyinterpreter.go b/internal/regopolicyinterpreter/regopolicyinterpreter.go index 6e316f9b41..4872c298eb 100644 --- a/internal/regopolicyinterpreter/regopolicyinterpreter.go +++ b/internal/regopolicyinterpreter/regopolicyinterpreter.go @@ -513,6 +513,19 @@ func (r RegoQueryResult) Object(key string) (map[string]interface{}, error) { } } +// Array attempts to interpret the result value as an array. +func (r RegoQueryResult) Array(key string) ([]interface{}, error) { + if value, ok := r[key]; ok { + if arr, ok := value.([]interface{}); ok { + return arr, nil + } else { + return nil, fmt.Errorf("value for '%s' is not an array", key) + } + } else { + return nil, fmt.Errorf("unable to find value for key '%s'", key) + } +} + // Bool attempts to interpret a result value as a boolean. func (r RegoQueryResult) Bool(key string) (bool, error) { if value, ok := r[key]; ok { diff --git a/internal/uvm/security_policy.go b/internal/uvm/security_policy.go index 5778919b9d..652c813398 100644 --- a/internal/uvm/security_policy.go +++ b/internal/uvm/security_policy.go @@ -130,7 +130,8 @@ func (uvm *UtilityVM) InjectPolicyFragment(ctx context.Context, fragment *ctrdta ResourceType: guestresource.ResourceTypePolicyFragment, RequestType: guestrequest.RequestTypeAdd, Settings: guestresource.SecurityPolicyFragment{ - Fragment: fragment.Fragment, + Fragment: fragment.Fragment, + MediaType: fragment.MediaType, }, }, } diff --git a/pkg/ctrdtaskapi/update.go b/pkg/ctrdtaskapi/update.go index f49e204f25..de795a9d5e 100644 --- a/pkg/ctrdtaskapi/update.go +++ b/pkg/ctrdtaskapi/update.go @@ -15,6 +15,12 @@ type PolicyFragment struct { // The value is a base64 encoded COSE_Sign1 document that contains the // fragment and any additional information required for validation. Fragment string `json:"fragment,omitempty"` + // MediaType is the media type of the blob carried in Fragment. It allows + // the same delivery mechanism to carry payloads other than Rego policy + // fragments (e.g. a Transparency Trust List). An empty value is treated by + // the guest as the default "application/cose-x509+rego" for backward + // compatibility with older hosts that do not set this field. + MediaType string `json:"mediaType,omitempty"` } type ContainerMount struct { diff --git a/pkg/securitypolicy/api.rego b/pkg/securitypolicy/api.rego index 88c3d64d14..f7a639c2ac 100644 --- a/pkg/securitypolicy/api.rego +++ b/pkg/securitypolicy/api.rego @@ -24,4 +24,5 @@ enforcement_points := { "load_fragment": {"introducedVersion": "0.9.0", "default_results": {"allowed": false, "add_module": false}, "use_framework": false}, "scratch_mount": {"introducedVersion": "0.10.0", "default_results": {"allowed": true}, "use_framework": false}, "scratch_unmount": {"introducedVersion": "0.10.0", "default_results": {"allowed": true}, "use_framework": false}, + "load_transparency_trust_list": {"introducedVersion": "0.12.0", "default_results": {"allowed": false}, "use_framework": false}, } diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 76e5b048a0..96573f1376 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1141,6 +1141,9 @@ default fragment_external_processes := [] fragment_external_processes := data[input.namespace].external_processes +default fragment_transparency_roots := [] +fragment_transparency_roots := data[input.namespace].transparency_roots + apply_defaults(name, raw_values, framework_version) := values { semver.compare(framework_version, version) == 0 values := raw_values @@ -1170,6 +1173,20 @@ apply_defaults("fragment", raw_values, framework_version) := values { ] } +# transparency_roots is introduced in framework version 0.5.0. If an old policy +# has it, silently ignore as it might be using the name for something else. + +apply_defaults("transparency_roots", raw_values, framework_version) := values { + semver.compare(framework_version, version) < 0 + semver.compare(framework_version, "0.5.0") >= 0 + values := raw_values +} + +apply_defaults("transparency_roots", raw_values, framework_version) := values { + semver.compare(framework_version, "0.5.0") < 0 + values := [] +} + default fragment_framework_version := null fragment_framework_version := data[input.namespace].framework_version @@ -1178,7 +1195,8 @@ extract_fragment_includes(includes) := fragment { objects := { "containers": apply_defaults("container", fragment_containers, framework_version), "fragments": apply_defaults("fragment", fragment_fragments, framework_version), - "external_processes": apply_defaults("external_process", fragment_external_processes, framework_version) + "external_processes": apply_defaults("external_process", fragment_external_processes, framework_version), + "transparency_roots": apply_defaults("transparency_roots", fragment_transparency_roots, framework_version), } fragment := { @@ -1260,6 +1278,47 @@ fragment_issuer_feed_ok(fragment) { input.feed == fragment.feed } +header_svn_ok(fragment) { + not input.has_header_svn +} + +header_svn_ok(fragment) { + input.has_header_svn + svn_ok(input.header_svn, fragment.minimum_svn) +} + +svn_ok_if_defined(minimum_svn) { + data[input.namespace].svn # This also works if the svn is 0 + not input.has_header_svn + svn_ok(data[input.namespace].svn, minimum_svn) +} + +svn_ok_if_defined(minimum_svn) { + data[input.namespace].svn + input.has_header_svn + # Use to_number as fragment may define svn as a string + to_number(input.header_svn) == to_number(data[input.namespace].svn) + svn_ok(data[input.namespace].svn, minimum_svn) +} + +# If not defined in fragment, require SVN to present in the header +svn_ok_if_defined(minimum_svn) { + not data[input.namespace].svn + input.has_header_svn + svn_ok(input.header_svn, minimum_svn) +} + +# A fragment rule may require transparency receipts from one or more ledgers +# (the receipt issuers). input.receipt_issuers is the set of ledgers for which +# the enforcer successfully validated a receipt attached to this fragment. If +# not set, no receipts are required. +fragment_receipts_ok(fragment) { + required := object.get(fragment, "required_receipt_issuers", []) + every required_issuer in required { + required_issuer in input.receipt_issuers + } +} + default load_fragment := {"allowed": false} # load_fragment gets called twice - first before loading the fragment as a Rego @@ -1267,20 +1326,26 @@ default load_fragment := {"allowed": false} # have access to anything under data[fragment.namespace] yet, and so we only # check that the fragment issuer and feed is valid, but does not actually load # the fragment into metadata. It will then be called a second time, at which -# point we can check the SVN defined in the fragment is valid, and if -# successful, add the fragment to the metadata. +# point we can check the SVN defined in the fragment is valid (if the SVN is not +# in the header, and thus we could not have checked earlier), and if successful, +# add the fragment to the metadata. load_fragment := {"allowed": true} { not input.fragment_loaded some fragment in candidate_fragments fragment_issuer_feed_ok(fragment) + # If SVN provided in header, validate it now. + header_svn_ok(fragment) + fragment_receipts_ok(fragment) } load_fragment := {"metadata": [updateIssuer], "add_module": add_module, "allowed": true} { input.fragment_loaded some fragment in candidate_fragments fragment_issuer_feed_ok(fragment) - svn_ok(data[input.namespace].svn, fragment.minimum_svn) + # If SVN is defined in the fragment's Rego module, also validate it. + # If header SVN was present, it must match that. + svn_ok_if_defined(fragment.minimum_svn) issuer := update_issuer(fragment.includes) updateIssuer := { @@ -1293,6 +1358,54 @@ load_fragment := {"metadata": [updateIssuer], "add_module": add_module, "allowed add_module := "namespace" in fragment.includes } +# transparency_roots declares which signed Transparency Trust Lists (TTLs) the +# policy is willing to accept, and which ledgers each such TTL may contribute +# keys for. Like candidate_fragments, the candidate set is the union of the +# top-level policy's transparency_roots and any contributed by already-loaded +# fragments that included "transparency_roots". +default policy_transparency_roots := [] +policy_transparency_roots := data.policy.transparency_roots + +candidate_transparency_roots := roots { + fragment_roots := [r | + feed := data.metadata.issuers[_].feeds[_] + fragment := feed[_] + r := fragment.transparency_roots[_] + ] + + roots := array.concat(policy_transparency_roots, fragment_roots) +} + +# The set of ledger names a matching transparency root authorizes for the given +# (issuer, subject, svn). "*" is a wildcard meaning "any ledger". +ttl_allowed_ledgers_for_issuer_subject_svn(issuer, subject, svn) := allowed_ledgers { + allowed_ledgers := {l | + ttl := candidate_transparency_roots[_] + ttl.issuer == issuer + ttl.subject == subject + svn_ok(svn, ttl.minimum_svn) + l := ttl.allowed_ledgers[_] + } +} + +intersect_or_allow_all_if_wildcard(allowed_ledgers, input_ledgers) := result { + not "*" in allowed_ledgers + result := {l | l := input_ledgers[_]; l in allowed_ledgers} +} + +intersect_or_allow_all_if_wildcard(allowed_ledgers, input_ledgers) := result { + "*" in allowed_ledgers + result := {l | l := input_ledgers[_]} +} + +default load_transparency_trust_list := {"allowed": false} + +load_transparency_trust_list := {"allowed": true, "allowed_ledgers": allowed_ledgers} { + root_ledgers := ttl_allowed_ledgers_for_issuer_subject_svn(input.issuer, input.subject, input.svn) + allowed_ledgers := intersect_or_allow_all_if_wildcard(root_ledgers, input.ledgers) + count(allowed_ledgers) > 0 +} + default scratch_mount := {"allowed": false} scratch_mounted(target) { @@ -1810,6 +1923,14 @@ fragment_version_is_valid { svn_ok(data[input.namespace].svn, fragment.minimum_svn) } +fragment_version_is_valid { + some fragment in candidate_fragments + fragment.issuer == input.issuer + fragment.feed == input.feed + input.has_header_svn + svn_ok(input.header_svn, fragment.minimum_svn) +} + default svn_mismatch := false svn_mismatch { @@ -1830,6 +1951,39 @@ svn_mismatch { to_number(fragment.minimum_svn) } +# Header SVN is always a number, not semver +svn_mismatch { + some fragment in candidate_fragments + fragment.issuer == input.issuer + fragment.feed == input.feed + input.fragment_loaded + semver.is_valid(fragment.minimum_svn) + input.has_header_svn +} + +default header_svn_not_match_fragment := false + +header_svn_not_match_fragment { + input.has_header_svn + some fragment in candidate_fragments + fragment.issuer == input.issuer + fragment.feed == input.feed + input.fragment_loaded + data[input.namespace].svn + to_number(data[input.namespace].svn) != to_number(input.header_svn) +} + +default missing_svn := false + +missing_svn { + not input.has_header_svn + some fragment in candidate_fragments + fragment.issuer == input.issuer + fragment.feed == input.feed + input.fragment_loaded + not data[input.namespace].svn +} + errors["fragment svn is below the specified minimum"] { input.rule == "load_fragment" fragment_feed_matches @@ -1845,6 +1999,52 @@ errors["fragment svn and the specified minimum are different types"] { svn_mismatch } +errors[svnMismatchError] { + input.rule == "load_fragment" + fragment_feed_matches + input.fragment_loaded + header_svn_not_match_fragment + + svnMismatchError := sprintf("svn in header %v does not match svn in fragment rego %v", [input.header_svn, data[input.namespace].svn]) +} + +errors["missing fragment svn in either header or rego payload"] { + input.rule == "load_fragment" + fragment_feed_matches + input.fragment_loaded + missing_svn +} + +errors[receipt_error] { + input.rule == "load_fragment" + not input.fragment_loaded + some fragment in candidate_fragments + fragment_issuer_feed_ok(fragment) + required := object.get(fragment, "required_receipt_issuers", []) + some required_issuer in required + not required_issuer in input.receipt_issuers + receipt_error := sprintf("missing receipt from %s", [required_issuer]) +} + +default transparency_root_matches := false + +transparency_root_matches { + some ttl in candidate_transparency_roots + ttl.issuer == input.issuer + ttl.subject == input.subject + svn_ok(input.svn, ttl.minimum_svn) +} + +errors["no transparency root matches the trust list issuer, subject and svn"] { + input.rule == "load_transparency_trust_list" + not transparency_root_matches +} + +errors["transparency trust list carries no ledgers authorized by any transparency root"] { + input.rule == "load_transparency_trust_list" + transparency_root_matches +} + errors["scratch already mounted at path"] { input.rule == "scratch_mount" scratch_mounted(input.target) @@ -2331,6 +2531,13 @@ check_fragment(raw_fragment, framework_version) := fragment { "feed": raw_fragment.feed, "minimum_svn": raw_fragment.minimum_svn, "includes": raw_fragment.includes, + + # required_receipt_issuers was added in 0.5.0. Older policies default to + # [], i.e. no transparency receipts are required, but if any is + # specified, even when the policy has an older framework_version, we + # respect it since it is restrictive. + "required_receipt_issuers": object.get(raw_fragment, "required_receipt_issuers", []), + # Additional fields need to have default logic applied } } diff --git a/pkg/securitypolicy/open_door.rego b/pkg/securitypolicy/open_door.rego index 02da3fa9b6..f18fc7db0a 100644 --- a/pkg/securitypolicy/open_door.rego +++ b/pkg/securitypolicy/open_door.rego @@ -23,3 +23,4 @@ runtime_logging := {"allowed": true} load_fragment := {"allowed": true} scratch_mount := {"allowed": true} scratch_unmount := {"allowed": true} +load_transparency_trust_list := {"allowed": true} diff --git a/pkg/securitypolicy/policy.rego b/pkg/securitypolicy/policy.rego index 195d462931..d98abd7d7a 100644 --- a/pkg/securitypolicy/policy.rego +++ b/pkg/securitypolicy/policy.rego @@ -26,4 +26,5 @@ runtime_logging := data.framework.runtime_logging load_fragment := data.framework.load_fragment scratch_mount := data.framework.scratch_mount scratch_unmount := data.framework.scratch_unmount +load_transparency_trust_list := data.framework.load_transparency_trust_list reason := data.framework.reason diff --git a/pkg/securitypolicy/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index 51da87a18c..91325ff684 100644 --- a/pkg/securitypolicy/regopolicy_linux_test.go +++ b/pkg/securitypolicy/regopolicy_linux_test.go @@ -5,6 +5,10 @@ package securitypolicy import ( "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + cryptorand "crypto/rand" "encoding/json" "errors" "fmt" @@ -23,6 +27,8 @@ import ( "github.com/Microsoft/hcsshim/internal/guestpath" rpi "github.com/Microsoft/hcsshim/internal/regopolicyinterpreter" oci "github.com/opencontainers/runtime-spec/specs-go" + + "github.com/Microsoft/cosesign1go/pkg/cosesign1" ) const testOSType = "linux" @@ -4139,7 +4145,7 @@ func Test_Rego_LoadFragment_Container(t *testing.T) { fragment := tc.fragments[0] container := tc.containers[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -4203,7 +4209,7 @@ func Test_Rego_LoadFragment_Container_Compat_0_10_0(t *testing.T) { } tc.policy = policy - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -4267,7 +4273,7 @@ func Test_Rego_LoadFragment_Container_Compat_0_10_0_allow_all(t *testing.T) { fragment := tc.fragments[0] container := tc.containers[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -4324,13 +4330,13 @@ func Test_Rego_LoadFragment_Fragment(t *testing.T) { fragment := tc.fragments[0] subFragment := tc.subFragments[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false } - err = tc.policy.LoadFragment(p.ctx, subFragment.info.issuer, subFragment.info.feed, subFragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: subFragment.info.issuer, Feed: subFragment.info.feed, Rego: subFragment.code}) if err != nil { t.Error("unable to load sub-fragment from fragment: %w", err) return false @@ -4367,7 +4373,7 @@ func Test_Rego_LoadFragment_ExternalProcess(t *testing.T) { fragment := tc.fragments[0] process := tc.externalProcesses[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -4403,7 +4409,7 @@ func Test_Rego_LoadFragment_BadIssuer(t *testing.T) { fragment := tc.fragments[0] issuer := testDataGenerator.uniqueFragmentIssuer() - err = tc.policy.LoadFragment(p.ctx, issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err == nil { t.Error("expected to be unable to load fragment due to bad issuer") return false @@ -4437,7 +4443,7 @@ func Test_Rego_LoadFragment_BadFeed(t *testing.T) { fragment := tc.fragments[0] feed := testDataGenerator.uniqueFragmentFeed() - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: feed, Rego: fragment.code}) if err == nil { t.Error("expected to be unable to load fragment due to bad feed") return false @@ -4562,7 +4568,7 @@ enforcement_point_info := { } `, fragment.info.minimumSVN, frameworkVersion) - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: code}) if err == nil { t.Error("expected to be unable to load fragment due to bad namespace") @@ -4604,7 +4610,7 @@ framework_version := "%s" load_fragment := {"allowed": true, "add_module": true} `, fragment.info.minimumSVN, frameworkVersion) - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: code}) if err == nil { t.Error("expected to be unable to load fragment due to invalid namespace") @@ -4637,7 +4643,7 @@ func Test_Rego_LoadFragment_InvalidSVN(t *testing.T) { } fragment := tc.fragments[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err == nil { t.Error("expected to be unable to load fragment due to invalid svn") return false @@ -4670,14 +4676,14 @@ func Test_Rego_LoadFragment_Fragment_InvalidSVN(t *testing.T) { } fragment := tc.fragments[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false } subFragment := tc.subFragments[0] - err = tc.policy.LoadFragment(p.ctx, subFragment.info.issuer, subFragment.info.feed, subFragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: subFragment.info.issuer, Feed: subFragment.info.feed, Rego: subFragment.code}) if err == nil { t.Error("expected to be unable to load subfragment due to invalid svn") return false @@ -4722,7 +4728,7 @@ func Test_Rego_LoadFragment_SemverVersion(t *testing.T) { fragmentConstraints.svn = mustIncrementSVN(p.fragments[0].minimumSVN) code := fragmentConstraints.toFragment().marshalRego() - err = policy.LoadFragment(p.ctx, issuer, feed, code) + err = policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: issuer, Feed: feed, Rego: code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -4750,7 +4756,7 @@ func Test_Rego_LoadFragment_SVNMismatch(t *testing.T) { } fragment := tc.fragments[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err == nil { t.Error("expected to be unable to load fragment due to invalid version") return false @@ -4774,6 +4780,322 @@ func Test_Rego_LoadFragment_SVNMismatch(t *testing.T) { } } +// removeRegoSVN returns the fragment Rego code with its `svn := ""` +// declaration removed, simulating a "SCITT-style" fragment whose SVN is carried +// in the COSE header instead of the Rego payload. +func removeRegoSVN(code string, svn string) string { + return strings.Replace(code, fmt.Sprintf("svn := %q", svn), "", 1) +} + +// A fragment whose SVN is provided in the COSE header (and not in its Rego +// payload) loads successfully when the header SVN meets the minimum. +func Test_Rego_LoadFragment_HeaderSVN(t *testing.T) { + f := func(p *generatedConstraints) bool { + tc, err := setupRegoFragmentTestConfigWithIncludes(p, []string{"containers"}) + if err != nil { + t.Error(err) + return false + } + + fragment := tc.fragments[0] + container := tc.containers[0] + + minSVN, err := strconv.Atoi(fragment.info.minimumSVN) + if err != nil { + t.Errorf("unable to parse minimum SVN %q: %v", fragment.info.minimumSVN, err) + return false + } + + // SCITT-style fragment: the SVN comes from the COSE header and the + // fragment's Rego module does not declare one. + code := removeRegoSVN(fragment.code, fragment.info.minimumSVN) + headerSVN := int64(minSVN) + + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, HeaderSVN: &headerSVN, Rego: code}) + if err != nil { + t.Errorf("unable to load fragment with header SVN: %v", err) + return false + } + + containerID, err := mountImageForContainer(tc.policy, container.container) + if err != nil { + t.Errorf("unable to mount image for fragment container: %v", err) + return false + } + + _, _, _, err = tc.policy.EnforceCreateContainerPolicy(p.ctx, + container.sandboxID, + containerID, + copyStrings(container.container.Command), + copyStrings(container.envList), + container.container.WorkingDir, + copyMounts(container.mounts), + false, + container.container.NoNewPrivileges, + container.user, + container.groups, + container.container.User.Umask, + container.capabilities, + container.seccomp, + ) + if err != nil { + t.Errorf("unable to create container from fragment loaded via header SVN: %v", err) + return false + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 25, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_LoadFragment_HeaderSVN: %v", err) + } +} + +// A SCITT-style fragment is rejected (before its module is loaded) when the +// header SVN is below the policy's minimum. +func Test_Rego_LoadFragment_HeaderSVN_BelowMinimum(t *testing.T) { + f := func(p *generatedConstraints) bool { + tc, err := setupRegoFragmentTestConfigWithIncludes(p, []string{"containers"}) + if err != nil { + t.Error(err) + return false + } + + fragment := tc.fragments[0] + + minSVN, err := strconv.Atoi(fragment.info.minimumSVN) + if err != nil { + t.Errorf("unable to parse minimum SVN %q: %v", fragment.info.minimumSVN, err) + return false + } + + code := removeRegoSVN(fragment.code, fragment.info.minimumSVN) + headerSVN := int64(minSVN - 1) + + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, HeaderSVN: &headerSVN, Rego: code}) + if err == nil { + t.Error("expected to be unable to load fragment due to header SVN below minimum") + return false + } + + if !expectFragmentNotLoaded(t, tc.policy, fragment.info.issuer, fragment.info.feed) { + t.Error("module not removed upon failure") + return false + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 25, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_LoadFragment_HeaderSVN_BelowMinimum: %v", err) + } +} + +// When both a header SVN and the SVN in the fragment's Rego module are present +// and they agree (and meet the minimum), the fragment loads. The Rego SVN is a +// string (as the tooling generates it) while the header SVN is a number, so the +// framework must compare them numerically. +func Test_Rego_LoadFragment_HeaderSVN_MatchesRegoSVN(t *testing.T) { + f := func(p *generatedConstraints) bool { + tc, err := setupRegoFragmentTestConfigWithIncludes(p, []string{"containers"}) + if err != nil { + t.Error(err) + return false + } + + fragment := tc.fragments[0] + + minSVN, err := strconv.Atoi(fragment.info.minimumSVN) + if err != nil { + t.Errorf("unable to parse minimum SVN %q: %v", fragment.info.minimumSVN, err) + return false + } + + code := fragment.code + // it just happens now that the minSVN is always used as the fragment + // SVN. To fix. + headerSVN := int64(minSVN) + + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, HeaderSVN: &headerSVN, Rego: code}) + if err != nil { + t.Errorf("unable to load fragment when header SVN matches Rego SVN: %v", err) + return false + } + + if tc.policy.rego.IsModuleActive(rpi.ModuleID(fragment.info.issuer, fragment.info.feed)) { + t.Error("module not removed after load") + return false + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 25, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_LoadFragment_HeaderSVN_MatchesRegoSVN: %v", err) + } +} + +// When both a header SVN and a numeric SVN in the fragment's Rego module are +// present but disagree, the fragment is rejected even though both values meet +// the minimum. +func Test_Rego_LoadFragment_HeaderSVN_MismatchRegoSVN(t *testing.T) { + f := func(p *generatedConstraints) bool { + tc, err := setupRegoFragmentTestConfigWithIncludes(p, []string{"containers"}) + if err != nil { + t.Error(err) + return false + } + + fragment := tc.fragments[0] + + minSVN, err := strconv.Atoi(fragment.info.minimumSVN) + if err != nil { + t.Errorf("unable to parse minimum SVN %q: %v", fragment.info.minimumSVN, err) + return false + } + + // The Rego SVN equals the minimum, but the header SVN is higher, so + // although both are at/above the minimum they do not match. + code := fragment.code + // It just happens now that the minSVN is always used as the fragment + // SVN. To fix. + headerSVN := int64(minSVN + 1) + + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, HeaderSVN: &headerSVN, Rego: code}) + if err == nil { + t.Error("expected to be unable to load fragment due to header/Rego SVN mismatch") + return false + } + + expectedString := fmt.Sprintf("svn in header %v does not match svn in fragment rego %v", headerSVN, minSVN) + if !assertDecisionJSONContains(t, err, expectedString) { + t.Errorf("expected error string to contain '%s'", expectedString) + return false + } + + if !expectFragmentNotLoaded(t, tc.policy, fragment.info.issuer, fragment.info.feed) { + t.Error("module not removed upon failure") + return false + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 25, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_LoadFragment_HeaderSVN_MismatchRegoSVN: %v", err) + } +} + +// A fragment with no SVN in either the header or its Rego payload is rejected. +func Test_Rego_LoadFragment_MissingSVN(t *testing.T) { + f := func(p *generatedConstraints) bool { + tc, err := setupRegoFragmentTestConfigWithIncludes(p, []string{"containers"}) + if err != nil { + t.Error(err) + return false + } + + fragment := tc.fragments[0] + + // It just happens now that the minSVN is always used as the fragment + // SVN. To fix. + code := removeRegoSVN(fragment.code, fragment.info.minimumSVN) + + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: code}) + if err == nil { + t.Error("expected to be unable to load fragment with no SVN in header or Rego") + return false + } + + if !assertDecisionJSONContains(t, err, "missing fragment svn in either header or rego payload") { + t.Error("expected error string to contain 'missing fragment svn in either header or rego payload'") + return false + } + + if !expectFragmentNotLoaded(t, tc.policy, fragment.info.issuer, fragment.info.feed) { + t.Error("module not removed upon failure") + return false + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 25, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_LoadFragment_MissingSVN: %v", err) + } +} + +// A fragment with an SVN of 0 (the lowest valid value) loads successfully +// whether the SVN is carried in the COSE header or declared in the fragment's +// Rego body. This guards against a regression where a 0 SVN could be mistaken +// for "no SVN defined" due to Rego truthiness semantics. +func Test_Rego_LoadFragment_ZeroSVN(t *testing.T) { + f := func(p *generatedConstraints) bool { + p.fragments = generateFragments(testRand, 1) + p.fragments[0].minimumSVN = "0" + securityPolicy := p.toPolicy() + + defaultMounts := toOCIMounts(generateMounts(testRand)) + privilegedMounts := toOCIMounts(generateMounts(testRand)) + + issuer := p.fragments[0].issuer + feed := p.fragments[0].feed + + // Scenario 1: SVN 0 carried in the COSE header, no SVN in the Rego body. + { + policy, err := newRegoPolicy(securityPolicy.marshalRego(), defaultMounts, privilegedMounts, testOSType) + if err != nil { + t.Fatalf("error compiling policy: %v", err) + } + + fragmentConstraints := generateConstraints(testRand, 1) + fragmentConstraints.svn = "0" + code := removeRegoSVN(fragmentConstraints.toFragment().marshalRego(), "0") + headerSVN := int64(0) + + err = policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: issuer, Feed: feed, HeaderSVN: &headerSVN, Rego: code}) + if err != nil { + t.Errorf("unable to load fragment with SVN 0 in header: %v", err) + return false + } + + if policy.rego.IsModuleActive(rpi.ModuleID(issuer, feed)) { + t.Error("module not removed after load (header SVN 0)") + return false + } + } + + // Scenario 2: SVN 0 declared in the Rego body, no header SVN. + { + policy, err := newRegoPolicy(securityPolicy.marshalRego(), defaultMounts, privilegedMounts, testOSType) + if err != nil { + t.Fatalf("error compiling policy: %v", err) + } + + fragmentConstraints := generateConstraints(testRand, 1) + fragmentConstraints.svn = "0" + code := fragmentConstraints.toFragment().marshalRego() + + err = policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: issuer, Feed: feed, Rego: code}) + if err != nil { + t.Errorf("unable to load fragment with SVN 0 in Rego body: %v", err) + return false + } + + if policy.rego.IsModuleActive(rpi.ModuleID(issuer, feed)) { + t.Error("module not removed after load (Rego body SVN 0)") + return false + } + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 25, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_LoadFragment_ZeroSVN: %v", err) + } +} + func Test_Rego_LoadFragment_SameIssuerTwoFeeds(t *testing.T) { f := func(p *generatedConstraints) bool { tc, err := setupRegoFragmentTwoFeedTestConfig(p, true, false) @@ -4783,7 +5105,7 @@ func Test_Rego_LoadFragment_SameIssuerTwoFeeds(t *testing.T) { } for _, fragment := range tc.fragments { - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -4836,7 +5158,7 @@ func Test_Rego_LoadFragment_TwoFeeds(t *testing.T) { } for _, fragment := range tc.fragments { - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -4888,13 +5210,13 @@ func Test_Rego_LoadFragment_SameFeedTwice(t *testing.T) { return false } - err = tc.policy.LoadFragment(p.ctx, tc.fragments[0].info.issuer, tc.fragments[0].info.feed, tc.fragments[0].code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: tc.fragments[0].info.issuer, Feed: tc.fragments[0].info.feed, Rego: tc.fragments[0].code}) if err != nil { t.Error("unable to load fragment the first time: %w", err) return false } - err = tc.policy.LoadFragment(p.ctx, tc.fragments[1].info.issuer, tc.fragments[1].info.feed, tc.fragments[1].code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: tc.fragments[1].info.issuer, Feed: tc.fragments[1].info.feed, Rego: tc.fragments[1].code}) if err != nil { t.Error("expected to be able to load the same issuer/feed twice: %w", err) return false @@ -4948,7 +5270,7 @@ func Test_Rego_LoadFragment_ExcludedContainer(t *testing.T) { fragment := tc.fragments[0] container := tc.containers[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -4979,13 +5301,13 @@ func Test_Rego_LoadFragment_ExcludedFragment(t *testing.T) { fragment := tc.fragments[0] subFragment := tc.subFragments[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false } - err = tc.policy.LoadFragment(p.ctx, subFragment.info.issuer, subFragment.info.feed, subFragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: subFragment.info.issuer, Feed: subFragment.info.feed, Rego: subFragment.code}) if err == nil { t.Error("expected to be unable to load a sub-fragment from a fragment") return false @@ -5010,7 +5332,7 @@ func Test_Rego_LoadFragment_ExcludedExternalProcess(t *testing.T) { fragment := tc.fragments[0] process := tc.externalProcesses[0] - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err != nil { t.Error("unable to load fragment: %w", err) return false @@ -5084,7 +5406,7 @@ mount_device := data.fragment.mount_device t.Fatalf("unable to create Rego policy: %v", err) } - err = policy.LoadFragment(ctx, issuer, feed, fragmentCode) + err = policy.LoadFragment(ctx, LoadFragmentOptions{Issuer: issuer, Feed: feed, Rego: fragmentCode}) if err != nil { t.Fatalf("unable to load fragment: %v", err) } @@ -5103,6 +5425,449 @@ mount_device := data.fragment.mount_device } } +func generateTestECDSAKey(t *testing.T) crypto.PublicKey { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader) + if err != nil { + t.Fatalf("unable to generate test key: %v", err) + } + return priv.Public() +} + +func ttlPolicyCode(issuer, subject string, minimumSVN int, allowedLedgers []string) string { + quoted := make([]string, len(allowedLedgers)) + for i, l := range allowedLedgers { + quoted[i] = fmt.Sprintf("%q", l) + } + return fmt.Sprintf(`package policy + +api_version := "%s" +framework_version := "%s" + +transparency_roots := [ + { + "issuer": "%s", + "subject": "%s", + "minimum_svn": %d, + "allowed_ledgers": [%s], + }, +] + +load_transparency_trust_list := data.framework.load_transparency_trust_list +reason := data.framework.reason +`, apiVersion, frameworkVersion, issuer, subject, minimumSVN, strings.Join(quoted, ", ")) +} + +func Test_Rego_LoadTransparencyTrustList(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + ledger := "esrp-cts-dev.confidential-ledger.azure.com" + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{ledger}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + "unauthorized.ledger.example": {"kid2": key}, + } + + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 2, parsedTTL); err != nil { + t.Fatalf("expected TTL to load: %v", err) + } + + if _, ok := policy.ttlKeys[ledger]["kid1"]; !ok { + t.Errorf("expected key for authorized ledger to be stored") + } + if _, ok := policy.ttlKeys["unauthorized.ledger.example"]; ok { + t.Errorf("keys for an unauthorized ledger must not be stored") + } +} + +func Test_Rego_LoadTransparencyTrustList_Wildcard(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{"*"}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + "ledger.one.example": {"kid1": key}, + "ledger.two.example": {"kid2": key}, + } + + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, parsedTTL); err != nil { + t.Fatalf("expected TTL to load: %v", err) + } + + for _, ledger := range []string{"ledger.one.example", "ledger.two.example"} { + if _, ok := policy.ttlKeys[ledger]; !ok { + t.Errorf("expected wildcard root to authorize ledger %s", ledger) + } + } +} + +func Test_Rego_LoadTransparencyTrustList_NoAuthorizedLedgers(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{"only.this.ledger.example"}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + "some.other.ledger.example": {"kid1": key}, + } + + err = policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, parsedTTL) + if err == nil { + t.Fatalf("expected TTL load to be denied when no ledgers are authorized") + } + if len(policy.ttlKeys) != 0 { + t.Errorf("no keys should be stored when the TTL is denied") + } +} + +func Test_Rego_LoadTransparencyTrustList_SVNBelowMinimum(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 5, []string{ledger}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + } + + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 4, parsedTTL); err == nil { + t.Fatalf("expected TTL load to be denied when svn is below the minimum") + } +} + +func Test_Rego_LoadTransparencyTrustList_MergeOverride(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{ledger}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + firstKey := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": firstKey}, + }); err != nil { + t.Fatalf("expected first TTL to load: %v", err) + } + + // A second TTL for the same ledger that adds a new kid and overrides the + // existing one with a different key. + secondKey := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": secondKey, "kid2": firstKey}, + }); err != nil { + t.Fatalf("expected second TTL to load: %v", err) + } + + eq := policy.ttlKeys[ledger]["kid1"].(interface{ Equal(crypto.PublicKey) bool }) + if !eq.Equal(secondKey) { + t.Errorf("expected kid1 to be overridden with the newer key") + } + if _, ok := policy.ttlKeys[ledger]["kid2"]; !ok { + t.Errorf("expected kid2 to be merged in") + } +} + +func Test_Rego_LoadFragment_MissingRequiredReceipt(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + feed := testDataGenerator.uniqueFragmentFeed() + ledger := "esrp-cts-dev.confidential-ledger.azure.com" + + fragmentCode := fmt.Sprintf(`package fragment + +svn := 1 +framework_version := "%s" +`, frameworkVersion) + + policyCode := fmt.Sprintf(`package policy + +api_version := "%s" +framework_version := "%s" + +fragments := [ + { + "issuer": "%s", + "feed": "%s", + "minimum_svn": 1, + "includes": [], + "required_receipt_issuers": ["%s"], + }, +] + +load_fragment := data.framework.load_fragment +reason := data.framework.reason +`, apiVersion, frameworkVersion, issuer, feed, ledger) + + policy, err := newRegoPolicy(policyCode, []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + // No TTL has been loaded, so the enforcer has no keys to validate any + // receipt, and the fragment requires one. The load must be denied. + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{Issuer: issuer, Feed: feed, HeaderSVN: &svn, Rego: fragmentCode}) + if err == nil { + t.Fatalf("expected fragment load to be denied for missing required receipt") + } + if !assertDecisionJSONContains(t, err, fmt.Sprintf("missing receipt from %s", ledger)) { + t.Fatalf("expected denial reason to mention the missing receipt, got: %v", err) + } + + if !expectFragmentNotLoaded(t, policy, issuer, feed) { + return + } +} + +func Test_Rego_LoadFragment_NoReceiptRequired(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + feed := testDataGenerator.uniqueFragmentFeed() + + fragmentCode := fmt.Sprintf(`package fragment + +svn := 1 +framework_version := "%s" +`, frameworkVersion) + + // A fragment object with no required_receipt_issuers must load without any + // receipts, exactly as before this feature existed. + policyCode := fmt.Sprintf(`package policy + +api_version := "%s" +framework_version := "%s" + +fragments := [ + { + "issuer": "%s", + "feed": "%s", + "minimum_svn": 1, + "includes": [], + }, +] + +load_fragment := data.framework.load_fragment +reason := data.framework.reason +`, apiVersion, frameworkVersion, issuer, feed) + + policy, err := newRegoPolicy(policyCode, []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + svn := int64(1) + if err := policy.LoadFragment(ctx, LoadFragmentOptions{Issuer: issuer, Feed: feed, HeaderSVN: &svn, Rego: fragmentCode}); err != nil { + t.Fatalf("expected fragment with no required receipts to load: %v", err) + } +} + +// receiptFragmentPolicy builds a policy that both trusts a TTL signed by +// ttlIssuer/ttlSubject (authorizing the given ledger) and allows a fragment +// from fragIssuer/fragFeed that requires a receipt from requiredLedger. +func receiptFragmentPolicy(ttlIssuer, ttlSubject, allowedLedger, fragIssuer, fragFeed, requiredLedger string) string { + return fmt.Sprintf(`package policy + +api_version := "%s" +framework_version := "%s" + +transparency_roots := [ + { + "issuer": "%s", + "subject": "%s", + "minimum_svn": 1, + "allowed_ledgers": ["%s"], + }, +] + +fragments := [ + { + "issuer": "%s", + "feed": "%s", + "minimum_svn": 1, + "includes": [], + "required_receipt_issuers": ["%s"], + }, +] + +load_fragment := data.framework.load_fragment +load_transparency_trust_list := data.framework.load_transparency_trust_list +reason := data.framework.reason +`, apiVersion, frameworkVersion, ttlIssuer, ttlSubject, allowedLedger, fragIssuer, fragFeed, requiredLedger) +} + +func Test_Rego_LoadFragment_ValidReceipt(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + ledger := "esrp-cts-dev.confidential-ledger.azure.com" + + policy, err := newRegoPolicy( + receiptFragmentPolicy(ttlIssuer, ttlSubject, ledger, fragIssuer, fragFeed, ledger), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // Mock receipt validation: assert the enforcer only ever offers us the keys + // belonging to the receipt's own claimed issuer, then accept. + validateCalled := false + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + validateCalled = true + if receipt.Issuer != ledger { + t.Errorf("validate called with unexpected issuer %q", receipt.Issuer) + } + if _, ok := keys["kid1"]; !ok || len(keys) != 1 { + t.Errorf("validate offered the wrong key set: %v", keys) + } + return nil + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: ledger, Kid: "kid1"}}, + }) + if err != nil { + t.Fatalf("expected fragment with a valid receipt to load: %v", err) + } + if !validateCalled { + t.Errorf("expected receipt validation to be invoked") + } +} + +func Test_Rego_LoadFragment_ReceiptWrongIssuer(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + requiredLedger := "required.ledger.example" + otherLedger := "other.ledger.example" + + // The TTL only authorizes otherLedger, and the fragment requires a receipt + // from requiredLedger. The attached receipt claims to be from otherLedger. + policy, err := newRegoPolicy( + receiptFragmentPolicy(ttlIssuer, ttlSubject, otherLedger, fragIssuer, fragFeed, requiredLedger), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + otherLedger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // Even though the mock would accept the receipt, its issuer is otherLedger, + // not the requiredLedger, so the requirement is not satisfied. + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return nil + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: otherLedger, Kid: "kid1"}}, + }) + if err == nil { + t.Fatalf("expected fragment load to be denied: receipt issuer does not match the requirement") + } + if !assertDecisionJSONContains(t, err, fmt.Sprintf("missing receipt from %s", requiredLedger)) { + t.Fatalf("expected denial reason to mention the missing receipt, got: %v", err) + } +} + +func Test_Rego_LoadFragment_ReceiptValidationFails(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy( + receiptFragmentPolicy(ttlIssuer, ttlSubject, ledger, fragIssuer, fragFeed, ledger), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // A receipt whose cryptographic validation fails must not count. + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return errors.New("bad signature") + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: ledger, Kid: "kid1"}}, + }) + if err == nil { + t.Fatalf("expected fragment load to be denied: receipt failed validation") + } + if !assertDecisionJSONContains(t, err, fmt.Sprintf("missing receipt from %s", ledger)) { + t.Fatalf("expected denial reason to mention the missing receipt, got: %v", err) + } +} + func Test_Rego_LoadFragment_BadIssuer_AttemptOverrideFrameworkItems(t *testing.T) { f := func(p *generatedConstraints) bool { tc, err := setupSimpleRegoFragmentTestConfig(p) @@ -5125,7 +5890,7 @@ input.issuer := "%s" data.framework.input.issuer := "%s" `, fragment.info.minimumSVN, frameworkVersion, expectedIssuer, expectedIssuer) - err = tc.policy.LoadFragment(p.ctx, actualIssuer, fragment.info.feed, code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: actualIssuer, Feed: fragment.info.feed, Rego: code}) if !assertDecisionJSONContains(t, err, "invalid fragment issuer") { return false @@ -5175,7 +5940,7 @@ enforcement_point_info := { data.framework.load_fragment := load_fragment `, fragment.constraints.svn, frameworkVersion) - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, fragment.info.feed, code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: code}) if !assertDecisionJSONContains(t, err, "fragment svn is below the specified minimum") { return false @@ -5205,7 +5970,7 @@ func Test_Rego_LoadFragment_BadIssuer_MustNotTryToLoadRego(t *testing.T) { actualIssuer := testDataGenerator.uniqueFragmentIssuer() code := "package fragment\n!invalid!rego" - err = tc.policy.LoadFragment(p.ctx, actualIssuer, fragment.info.feed, code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: actualIssuer, Feed: fragment.info.feed, Rego: code}) if strings.Contains(err.Error(), "error when compiling module") || !assertDecisionJSONDoesNotContain(t, err, "error when compiling module") { @@ -5241,7 +6006,7 @@ func Test_Rego_LoadFragment_BadFeed_MustNotTryToLoadRego(t *testing.T) { actualFeed := testDataGenerator.uniqueFragmentFeed() code := "package fragment\n!invalid!rego" - err = tc.policy.LoadFragment(p.ctx, fragment.info.issuer, actualFeed, code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: actualFeed, Rego: code}) if strings.Contains(err.Error(), "error when compiling module") || !assertDecisionJSONDoesNotContain(t, err, "error when compiling module") { @@ -5283,7 +6048,7 @@ func Test_Rego_LoadFragment_BadIssuer_MustNotTryToLoadRego_Compat_0_10_0(t *test actualIssuer := testDataGenerator.uniqueFragmentIssuer() code := "package fragment\n!invalid!rego" - err = tc.policy.LoadFragment(p.ctx, actualIssuer, fragment.info.feed, code) + err = tc.policy.LoadFragment(p.ctx, LoadFragmentOptions{Issuer: actualIssuer, Feed: fragment.info.feed, Rego: code}) if strings.Contains(err.Error(), "error when compiling module") || !assertDecisionJSONDoesNotContain(t, err, "error when compiling module") { @@ -5949,7 +6714,7 @@ func Test_Fragment_FrameworkVersion_Missing(t *testing.T) { } fragment := tc.fragments[0] - err = tc.policy.LoadFragment(gc.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(gc.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err == nil { t.Error("unexpected success. Missing framework_version should trigger an error.") } @@ -5986,7 +6751,7 @@ func Test_Fragment_FrameworkVersion_In_Future(t *testing.T) { } fragment := tc.fragments[0] - err = tc.policy.LoadFragment(gc.ctx, fragment.info.issuer, fragment.info.feed, fragment.code) + err = tc.policy.LoadFragment(gc.ctx, LoadFragmentOptions{Issuer: fragment.info.issuer, Feed: fragment.info.feed, Rego: fragment.code}) if err == nil { t.Error("unexpected success. Future framework_version should trigger an error.") } diff --git a/pkg/securitypolicy/securitypolicy_options.go b/pkg/securitypolicy/securitypolicy_options.go index cf993780cd..e43f84857a 100644 --- a/pkg/securitypolicy/securitypolicy_options.go +++ b/pkg/securitypolicy/securitypolicy_options.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "io" + "math" "os" "path/filepath" "sync" @@ -102,6 +103,41 @@ func (s *SecurityOptions) SetConfidentialOptions(ctx context.Context, enforcerTy return nil } +// Media types carried by the fragment-injection delivery mechanism. The host +// may deliver blobs of different types through the same path; the guest decides +// how to treat each one based on its media type. +const ( + // mediaTypeFragment is a Rego security policy fragment. This is the default + // when the host does not specify a media type (older hosts). + mediaTypeFragment = "application/cose-x509+rego" + // mediaTypeTransparencyTrustList is a signed Transparency Trust List (TTL). + mediaTypeTransparencyTrustList = "application/vnd.transparency-trust-list.v1+cose" +) + +// asInt64 coerces a CBOR-decoded integer value (which may be returned as +// int64, uint64 or int by different decoders) to an int64. +func asInt64(v interface{}) (int64, error) { + switch n := v.(type) { + case int64: + return n, nil + case int: + return int64(n), nil + case uint64: + if n > math.MaxInt64 { + return 0, errors.New("unable to convert uint64 to int64 due to overflow") + } + return int64(n), nil + case uint: + // uint is 64bit on 64bit platforms, so can overflow int64 + if n > math.MaxInt64 { + return 0, errors.New("unable to convert uint to int64 due to overflow") + } + return int64(n), nil + default: + return 0, errors.Errorf("expected integer type, got %T", v) + } +} + // Fragment extends current security policy with additional constraints // from the incoming fragment. Note that it is base64 encoded over the bridge/ // @@ -115,6 +151,22 @@ func (s *SecurityOptions) SetConfidentialOptions(ctx context.Context, enforcerTy func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestresource.SecurityPolicyFragment) (err error) { log.G(ctx).WithField("fragment", fmt.Sprintf("%+v", fragment)).Debug("VerifyAndExtractFragment") + // An empty media type defaults to a Rego policy fragment, for backward + // compatibility with older hosts that do not set the field. + mediaType := fragment.MediaType + if mediaType == "" { + mediaType = mediaTypeFragment + } + switch mediaType { + case mediaTypeFragment, mediaTypeTransparencyTrustList: + default: + // The host (azcri) only ever injects blobs whose media type it knows + // we handle, so receiving an unrecognized one means either a host bug + // or a newer host paired with an older guest. Fail loudly rather than + // silently ignoring it; a failed injection is non-fatal to the host. + return fmt.Errorf("cannot inject fragment blob with unsupported media type %q", mediaType) + } + raw, err := base64.StdEncoding.DecodeString(fragment.Fragment) if err != nil { return fmt.Errorf("failed to decode fragment: %w", err) @@ -134,16 +186,27 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres return fmt.Errorf("InjectFragment failed COSE validation: %w", err) } + cwtClaimsRaw, hasCwtClaims := unpacked.Protected[cosesign1.COSE_Header_CWTClaims] + var cwtClaims map[any]any + if hasCwtClaims { + var ok bool + cwtClaims, ok = cwtClaimsRaw.(map[any]any) + if !ok { + return fmt.Errorf("CWT claims header present, expected it to be a map[any]any, but got %T", cwtClaimsRaw) + } + } + payloadString := string(unpacked.Payload[:]) issuer := unpacked.Issuer feed := unpacked.Feed chainPem := unpacked.ChainPem log.G(ctx).WithFields(logrus.Fields{ - "issuer": issuer, // eg the DID:x509:blah.... - "feed": feed, - "cty": unpacked.ContentType, - "chainPem": chainPem, + "issuer": issuer, // eg the DID:x509:blah.... + "feed": feed, + "cty": unpacked.ContentType, + "chainPem": chainPem, + "cwtClaims": cwtClaims, }).Debugf("unpacked COSE1 cert chain") log.G(ctx).WithFields(logrus.Fields{ @@ -162,10 +225,47 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres return fmt.Errorf("failed to resolve DID: %w", err) } + var svnFromCwt *int64 = nil + if hasCwtClaims { + svnFromCwtRaw, hasSvn := cwtClaims["svn"] + if hasSvn { + svn, err := asInt64(svnFromCwtRaw) + if err != nil { + return errors.Wrap(err, "SVN present in CWT claims, but failed to convert it to int64") + } + svnFromCwt = &svn + } + } + + if mediaType == mediaTypeTransparencyTrustList { + // A TTL must carry its SVN in the COSE header; there is nowhere else for + // it to go. + if svnFromCwt == nil { + return fmt.Errorf("transparency trust list is missing an SVN in its CWT claims") + } + + parsedTTL, err := cosesign1.ParseTTLPayload(unpacked.Payload) + if err != nil { + return errors.Wrap(err, "failed to parse transparency trust list payload") + } + + // feed is the "subject" in the new-style envelope terminology. + if err := s.PolicyEnforcer.LoadTransparencyTrustList(ctx, issuer, feed, *svnFromCwt, parsedTTL); err != nil { + return errors.Wrap(err, "error loading transparency trust list") + } + return nil + } + // now offer the payload fragment to the policy - err = s.PolicyEnforcer.LoadFragment(ctx, issuer, feed, payloadString) + err = s.PolicyEnforcer.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: issuer, + Feed: feed, + HeaderSVN: svnFromCwt, + Rego: payloadString, + Receipts: unpacked.Receipts, + }) if err != nil { - return fmt.Errorf("error loading security policy fragment: %w", err) + return errors.Wrap(err, "error loading security policy fragment") } return nil } diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index 0c2a98e998..ffc547a75d 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -2,9 +2,11 @@ package securitypolicy import ( "context" + "crypto" "fmt" "syscall" + "github.com/Microsoft/cosesign1go/pkg/cosesign1" "github.com/Microsoft/hcsshim/internal/protocol/guestrequest" oci "github.com/opencontainers/runtime-spec/specs-go" "github.com/pkg/errors" @@ -33,6 +35,7 @@ type CreateContainerOptions struct { // pod sandbox container (usually it is the "pause" image). IsSandboxContainer bool } + type SignalContainerOptions struct { IsInitProcess bool // One of these will be set depending on platform @@ -43,6 +46,20 @@ type SignalContainerOptions struct { WindowsCommand []string } +type LoadFragmentOptions struct { + Issuer string + Feed string + // If the fragment's COSE envelope contains a CWT Claims with a SVN, pass it + // in HeaderSVN. + HeaderSVN *int64 + // Rego is the fragment's Rego payload. + Rego string + // Receipts are the COSE transparency receipts attached to the fragment's + // COSE envelope, if any. Validation is handled by the enforcer, caller + // does not have to validate them. + Receipts []cosesign1.ParsedCOSEReceipt +} + const ( openDoorEnforcerName = "open_door" ) @@ -125,7 +142,8 @@ type SecurityPolicyEnforcer interface { EnforceGetPropertiesPolicy(ctx context.Context) error EnforceDumpStacksPolicy(ctx context.Context) error EnforceRuntimeLoggingPolicy(ctx context.Context) (err error) - LoadFragment(ctx context.Context, issuer string, feed string, rego string) error + LoadFragment(ctx context.Context, opts LoadFragmentOptions) error + LoadTransparencyTrustList(ctx context.Context, issuer string, subject string, svn int64, parsedTTL map[string]map[string]crypto.PublicKey) error EnforceScratchMountPolicy(ctx context.Context, scratchPath string, encrypted bool) (err error) EnforceScratchUnmountPolicy(ctx context.Context, scratchPath string) (err error) GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) @@ -292,7 +310,11 @@ func (OpenDoorSecurityPolicyEnforcer) EnforceDumpStacksPolicy(context.Context) e return nil } -func (OpenDoorSecurityPolicyEnforcer) LoadFragment(context.Context, string, string, string) error { +func (OpenDoorSecurityPolicyEnforcer) LoadFragment(context.Context, LoadFragmentOptions) error { + return nil +} + +func (OpenDoorSecurityPolicyEnforcer) LoadTransparencyTrustList(context.Context, string, string, int64, map[string]map[string]crypto.PublicKey) error { return nil } @@ -425,10 +447,14 @@ func (ClosedDoorSecurityPolicyEnforcer) EnforceDumpStacksPolicy(context.Context) return errors.New("getting stack dumps is denied by policy") } -func (ClosedDoorSecurityPolicyEnforcer) LoadFragment(context.Context, string, string, string) error { +func (ClosedDoorSecurityPolicyEnforcer) LoadFragment(context.Context, LoadFragmentOptions) error { return errors.New("loading fragments is denied by policy") } +func (ClosedDoorSecurityPolicyEnforcer) LoadTransparencyTrustList(context.Context, string, string, int64, map[string]map[string]crypto.PublicKey) error { + return errors.New("loading transparency trust lists is denied by policy") +} + func (ClosedDoorSecurityPolicyEnforcer) ExtendDefaultMounts(_ []oci.Mount) error { return nil } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index 5e196ebd9a..b84498367f 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -5,6 +5,7 @@ package securitypolicy import ( "context" + "crypto" _ "embed" "encoding/base64" "encoding/json" @@ -15,6 +16,7 @@ import ( "sync" "syscall" + "github.com/Microsoft/cosesign1go/pkg/cosesign1" "github.com/Microsoft/hcsshim/internal/guestpath" hcsschema "github.com/Microsoft/hcsshim/internal/hcs/schema2" "github.com/Microsoft/hcsshim/internal/log" @@ -61,6 +63,15 @@ type regoEnforcer struct { osType string // Mutex to ensure only one transaction is active transactionLock sync.Mutex + // ttlKeysLock guards access to ttlKeys. + ttlKeysLock sync.Mutex + // ttlKeys holds the receipt-signing keys learned from loaded Transparency + // Trust Lists, keyed by ledger name (receipt issuer) and then by key id. + ttlKeys map[string]map[string]crypto.PublicKey + // validateReceipt validates a single transparency receipt against the given + // keys. It defaults to (cosesign1.ParsedCOSEReceipt).Validate and exists as + // a field so tests can substitute a mock. + validateReceipt func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error } var _ SecurityPolicyEnforcer = (*regoEnforcer)(nil) @@ -159,6 +170,10 @@ func newRegoPolicy(code string, defaultMounts []oci.Mount, privilegedMounts []oc return nil, err } policy.stdio = map[string]bool{} + policy.ttlKeys = map[string]map[string]crypto.PublicKey{} + policy.validateReceipt = func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return receipt.Validate(keys) + } policy.base64policy = "" policy.rego.AddModule("framework.rego", &rpi.RegoModule{Namespace: "framework", Code: FrameworkCode}) @@ -1088,12 +1103,51 @@ func parseNamespace(rego string) (string, error) { return namespace, nil } -func (policy *regoEnforcer) LoadFragment(ctx context.Context, issuer string, feed string, rego string) error { +// Evaluates a fragment, and if the policy allows, load it into the policy. +// opts.HeaderSvn can be nil, in which case the SVN is read from the fragment's +// Rego module after loading it (and unloaded if the fragment's SVN is too low), +// or a SVN read from the COSE envelope, for a "SCITT-style" fragment. This +// allows determining if the SVN should be allowed without loading any Rego from +// the fragment. +func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentOptions) error { + issuer := opts.Issuer + feed := opts.Feed + headerSvn := opts.HeaderSVN + rego := opts.Rego + namespace, err := parseNamespace(rego) if err != nil { return fmt.Errorf("unable to load fragment: %w", err) } + // Validate each attached transparency receipt against the keys we have for + // its claimed issuer (ledger), learned from previously loaded TTLs. We only + // ever offer Validate the keys belonging to the receipt's own claimed + // issuer, so a ledger cannot sign a receipt while pretending to be a + // different ledger. The set of issuers for which we successfully validated a + // receipt is then passed to the policy as input.receipt_issuers. + receiptIssuersSet := make(map[string]struct{}) + policy.ttlKeysLock.Lock() + for _, receipt := range opts.Receipts { + keys, ok := policy.ttlKeys[receipt.Issuer] + if !ok { + // We have no TTL keys for this issuer, so we cannot validate the + // receipt. Ignore it. + log.G(ctx).WithField("issuer", receipt.Issuer).Debug("skipping fragment receipt: no TTL keys for claimed issuer") + continue + } + if err := policy.validateReceipt(receipt, keys); err != nil { + log.G(ctx).WithError(err).WithField("issuer", receipt.Issuer).Error("fragment receipt failed validation") + continue + } + receiptIssuersSet[receipt.Issuer] = struct{}{} + } + policy.ttlKeysLock.Unlock() + receiptIssuers := make([]string, 0, len(receiptIssuersSet)) + for issuer := range receiptIssuersSet { + receiptIssuers = append(receiptIssuers, issuer) + } + fragment := &rpi.RegoModule{ Issuer: issuer, Feed: feed, @@ -1106,6 +1160,9 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, issuer string, fee "feed": feed, "namespace": namespace, "fragment_loaded": false, + "has_header_svn": headerSvn != nil, + "header_svn": headerSvn, + "receipt_issuers": receiptIssuers, } // Check that the fragment is signed by the expected issuer before loading @@ -1116,7 +1173,8 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, issuer string, fee } // At this point we need to add the fragment code as a new Rego module in - // order for the framework (or any user defined policies) to check the SVN, + // order for the framework (or any user defined policies) to check the SVN + // (if it's not already available in the CWT, passed in here as headerSvn), // and potentially other information defined by its Rego code. We've already // checked that the fragment is signed correctly, and the namespace is safe // to load (won't override framework or other built-in modules). Once we @@ -1139,6 +1197,83 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, issuer string, fee return nil } +// SetReceiptValidationFunction overrides how transparency receipts are +// validated. It exists only for tests, since a real CCF receipt cannot be +// constructed in a unit test. +func (policy *regoEnforcer) SetReceiptValidationFunction(fn func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error) { + policy.validateReceipt = fn +} + +// LoadTransparencyTrustList enforces and ingests a signed Transparency Trust +// List (TTL). parsedTTL maps each ledger name (receipt issuer) to that ledger's +// kid -> public key map. The Rego enforcement point only receives the list of +// ledger names; it decides which of them this TTL is authorized to contribute +// keys for, based on the policy's transparency_roots. The keys for the allowed +// ledgers are then merged into the enforcer's TTL key store for use when +// validating fragment receipts. +func (policy *regoEnforcer) LoadTransparencyTrustList(ctx context.Context, issuer string, subject string, svn int64, parsedTTL map[string]map[string]crypto.PublicKey) error { + ledgers := make([]string, 0, len(parsedTTL)) + for ledger := range parsedTTL { + ledgers = append(ledgers, ledger) + } + + input := inputData{ + "issuer": issuer, + "subject": subject, + "svn": svn, + "ledgers": ledgers, + } + + results, err := policy.enforce(ctx, "load_transparency_trust_list", input) + if err != nil { + return err + } + + allowedLedgersRaw, err := results.Array("allowed_ledgers") + if err != nil { + return errors.Wrap(err, "unable to get allowed_ledgers from load_transparency_trust_list result") + } + + if len(allowedLedgersRaw) == 0 { + return errors.New("transparency trust list carries no ledgers authorized by the policy") + } + + allowedLedgers := make([]string, 0, len(allowedLedgersRaw)) + for _, l := range allowedLedgersRaw { + ledger, ok := l.(string) + if !ok { + return fmt.Errorf("Elements of result.allowed_ledgers must be strings, got %T", l) + } + allowedLedgers = append(allowedLedgers, ledger) + } + + policy.ttlKeysLock.Lock() + defer policy.ttlKeysLock.Unlock() + for _, ledger := range allowedLedgers { + newKeys := parsedTTL[ledger] + existingKeys, ok := policy.ttlKeys[ledger] + if !ok { + existingKeys = make(map[string]crypto.PublicKey, len(newKeys)) + policy.ttlKeys[ledger] = existingKeys + } + for kid, pk := range newKeys { + if existingKey, exists := existingKeys[kid]; exists { + // Equal is implemented for all crypto.PublicKey types in std. + eq, ok := existingKey.(interface{ Equal(crypto.PublicKey) bool }) + if !ok || !eq.Equal(pk) { + log.G(ctx).Warnf("TTL for ledger %s overrides existing key with id %s with a different key", ledger, kid) + existingKeys[kid] = pk + } + } else { + existingKeys[kid] = pk + } + } + } + + log.G(ctx).Infof("Loaded TTL with subject %s signed by %s with keys for ledgers: %v", subject, issuer, allowedLedgers) + return nil +} + func (policy *regoEnforcer) EnforceScratchMountPolicy(ctx context.Context, scratchPath string, encrypted bool) error { input := map[string]interface{}{ "target": scratchPath, @@ -1211,14 +1346,39 @@ func (policy *regoEnforcer) WithMetadataRollback(fn func() error) error { return errors.Wrap(err, "failed to snapshot policy metadata") } + // The TTL key store is Go-side enforcer state, not Rego metadata, so it is + // not covered by SaveMetadata/RestoreMetadata. Snapshot it here so it is + // rolled back alongside the metadata if fn fails. We only copy the per-ledger + // map references, not deep-copy the crypto.PublicKey values. + savedTTLKeys := policy.snapshotTTLKeys() + err = fn() if err != nil { if restoreErr := policy.rego.RestoreMetadata(saved); restoreErr != nil { panic(fmt.Sprintf("failed to rollback policy metadata: %v (caused by error: %v)", restoreErr, err)) } + policy.ttlKeysLock.Lock() + policy.ttlKeys = savedTTLKeys + policy.ttlKeysLock.Unlock() log.G(context.Background()).WithError(err).Warn("rolled back policy metadata due to error") return err } return nil } + +// snapshotTTLKeys returns a shallow copy of the TTL key store: the outer and +// inner maps are copied, but the crypto.PublicKey values are shared. +func (policy *regoEnforcer) snapshotTTLKeys() map[string]map[string]crypto.PublicKey { + policy.ttlKeysLock.Lock() + defer policy.ttlKeysLock.Unlock() + snapshot := make(map[string]map[string]crypto.PublicKey, len(policy.ttlKeys)) + for ledger, keys := range policy.ttlKeys { + keysCopy := make(map[string]crypto.PublicKey, len(keys)) + for kid, pk := range keys { + keysCopy[kid] = pk + } + snapshot[ledger] = keysCopy + } + return snapshot +} diff --git a/pkg/securitypolicy/version_api b/pkg/securitypolicy/version_api index d9df1bbc0c..ac454c6a1f 100644 --- a/pkg/securitypolicy/version_api +++ b/pkg/securitypolicy/version_api @@ -1 +1 @@ -0.11.0 +0.12.0 diff --git a/pkg/securitypolicy/version_framework b/pkg/securitypolicy/version_framework index 267577d47e..8f0916f768 100644 --- a/pkg/securitypolicy/version_framework +++ b/pkg/securitypolicy/version_framework @@ -1 +1 @@ -0.4.1 +0.5.0 diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile index 8c982132c2..6527ca105d 100644 --- a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile @@ -24,12 +24,11 @@ # -# note test-fail is expected to fail - -AUTOPARSE_CHAIN:=0 +# Opaque label written into the COSE `iss` header. It is NOT validated against +# the chain at create time; the Go unit test asserts the round-trip preserves +# this exact string, so don't change it without updating the test. ISSUER_DID:="TestIssuer" FEED:="TestFeed" -DID_FINGERPRINT:="" all: chain.pem cose test-fail test-pass @@ -38,11 +37,6 @@ cose: infra.rego.cose %.pem: $(MAKE) -f Makefile.certs chain.pem -ifeq "$(AUTOPARSE_CHAIN)" "1" -ISSUER_DID = $(shell ./sign1util did-x509 -chain chain.pem -policy cn) -DID_FINGERPRINT = $(shell ./sign1util did-x509 -chain chain.pem -policy cn | cut -d: -f5) -endif - # from these media types have to match containerd. The also need to change and the security policy one ought to be x-ms-ccepolicy-frag # fragment atrifact type = application/x-ms-ccepolicy-frag # fragment media type = application/cose-x509+rego @@ -74,13 +68,13 @@ show: sign1util didx509: chain.pem sign1util ./sign1util did-x509 -chain chain.pem -i 1 -policy "subject:CN:Test Leaf (DO NOT TRUST)" -verbose -info: chain.pem sign1util - @echo "ISSUER_DID: $(ISSUER_DID)" - @echo "DID_FINGERPRINT: $(DID_FINGERPRINT)" - -# for this to pass the did:x509 fingerprint (RgpNsHOK5hPlCAfTtiGY_BcDhFRxQbJnhlxNDhxps6U here) needs to be the one output from make print -did-check: chain.pem infra.rego.cose sign1util info - ./sign1util check -in infra.rego.cose -did $(ISSUER_DID) +# did-check derives the REAL did:x509 from chain.pem at run time and resolves +# it against the chain. Fails loudly if did-x509 returns empty. +did-check: chain.pem infra.rego.cose sign1util + @did="$$(./sign1util did-x509 -chain chain.pem -policy cn)"; \ + test -n "$$did" || { echo "did-x509 returned empty - check chain.pem"; exit 1; }; \ + echo "did-check: using did=$$did"; \ + ./sign1util check -in infra.rego.cose -did "$$did" # For normal workflow start from the chain.pem, here we'd take the chain from inside the cose sign1 doc, eg to manually confirm it is # as otherwise expected (ie that the issuer DID matches the chain) or to shortcut getting a DID from a cose document. @@ -92,12 +86,18 @@ did-from-cose: sign1util infra.rego.cose # note that since the infra.rego.cose is actually good the first part of the check will report a pass "checkCoseSign1 passed" # expect "DID resolvers failed: err: DID verification failed: unexpected certificate fingerprint" +# The recipe is expected to fail at the tool level; invert the exit code so the make target succeeds. did-fail-fingerprint: chain.pem sign1util infra.rego.cose - ./sign1util check -in infra.rego.cose -did did:x509:0:sha256:XXXi_nuWegx4NiLaeGabiz36bDUhDDiHEFl8HXMA_4o::subject:CN:Test+Leaf+%28DO+NOT+TRUST%29 + ! ./sign1util check -in infra.rego.cose -did did:x509:0:sha256:XXXi_nuWegx4NiLaeGabiz36bDUhDDiHEFl8HXMA_4o::subject:CN:Test+Leaf+%28DO+NOT+TRUST%29 # expect "DID resolvers failed: err: DID verification failed: invalid subject value: CN=Test XXXX (DO NOT TRUST)" +# Builds a DID with the REAL fingerprint but a WRONG subject; recipe must still +# fail at the tool level - `!` inverts that into a make-level success. did-fail-subject: chain.pem sign1util infra.rego.cose - ./sign1util check -in infra.rego.cose -did did:x509:0:sha256:$(DID_FINGERPRINT)::subject:CN:Test+XXXX+%28DO+NOT+TRUST%29 + @fp="$$(./sign1util did-x509 -chain chain.pem -policy cn | cut -d: -f5)"; \ + test -n "$$fp" || { echo "could not derive fingerprint - check chain.pem"; exit 1; }; \ + ! ./sign1util check -in infra.rego.cose \ + -did "did:x509:0:sha256:$$fp::subject:CN:Test+XXXX+%28DO+NOT+TRUST%29" did-fail: did-fail-subject did-fail-fingerprint diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile.certs b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile.certs index 9a0ce35257..e2452a7728 100644 --- a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile.certs +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile.certs @@ -8,12 +8,12 @@ all: chain.pem root.cert.pem: root.private.pem openssl req -new -key $< -out $@.tmp.csr -subj "/CN=Test Root CA (DO NOT TRUST)" -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=digitalSignature,keyCertSign' - openssl x509 -req -days 365 -in $@.tmp.csr -signkey $< -out $@ -CAcreateserial -extfile cert.extensions.cfg + openssl x509 -req -days 3650 -in $@.tmp.csr -signkey $< -out $@ -CAcreateserial -extfile cert.extensions.cfg rm -rf $@.tmp.csr intermediate.cert.pem: intermediate.private.pem | root.private.pem openssl req -new -key $< -out $@.tmp.csr -subj "/CN=Test Intermediate CA (DO NOT TRUST)" -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=digitalSignature,keyCertSign' - openssl x509 -req -days 365 -in $@.tmp.csr -CA ${subst private,cert,$|} -CAkey $| -out $@ -CAcreateserial -extfile cert.extensions.cfg + openssl x509 -req -days 1825 -in $@.tmp.csr -CA ${subst private,cert,$|} -CAkey $| -out $@ -CAcreateserial -extfile cert.extensions.cfg rm $@.tmp.csr leaf.cert.pem: leaf.private.pem | intermediate.private.pem diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go index 7366c37897..231771eb9f 100644 --- a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go @@ -1,11 +1,16 @@ package cosesign1 import ( + "bytes" + "crypto" + "crypto/sha256" "crypto/x509" "fmt" + "math" didx509resolver "github.com/Microsoft/didx509go/pkg/did-x509-resolver" + "github.com/fxamacker/cbor/v2" "github.com/sirupsen/logrus" "github.com/veraison/go-cose" @@ -63,6 +68,84 @@ type UnpackedCoseSign1 struct { ChainPem string Payload []byte CertChain []*x509.Certificate + Protected cose.ProtectedHeader + Unprotected cose.UnprotectedHeader + // Receipts contains the parsed COSE Receipts attached to the unprotected + // `receipts` header (label 394), if any. Receipts are parsed but not + // validated; use (ParsedCOSEReceipt).Validate to validate each. + Receipts []ParsedCOSEReceipt +} + +// ParsedCOSEReceipt is a parsed COSE Receipt attached to a COSE Sign1 +// envelope. It carries the original CBOR-encoded blob alongside the decoded +// COSE_Sign1 message and a few convenience fields extracted from its +// protected header. +type ParsedCOSEReceipt struct { + // Raw is the original CBOR-encoded COSE_Sign1 receipt blob. + Raw []byte + // Message is the decoded COSE_Sign1 receipt. + Message cose.Sign1Message + // Issuer is the value of CWT claim `iss` from the receipt's protected CWT + // Claims header + Issuer string + // The value of the receipt's protected `kid` header, interpreted + // as a string (CCF uses ASCII hex) + Kid string + // The expected hash of the Signed Statement this receipt is for. + ExpectedDataHash []byte +} + +// parseCOSEReceipts decodes the unprotected `receipts` header (label 394) +// into []ParsedCOSEReceipt. It does not validate the receipts. +func parseCOSEReceipts(unprotected cose.UnprotectedHeader) ([]ParsedCOSEReceipt, error) { + rcptsVal, ok := unprotected[COSE_Header_Receipts] + if !ok { + return nil, nil + } + rcptsArr, ok := rcptsVal.([]interface{}) + if !ok { + return nil, fmt.Errorf("receipts header is not an array (got %T)", rcptsVal) + } + out := make([]ParsedCOSEReceipt, 0, len(rcptsArr)) + for i, r := range rcptsArr { + rb, ok := r.([]byte) + if !ok { + return nil, fmt.Errorf("receipt %d is not a byte string (got %T)", i, r) + } + var msg cose.Sign1Message + if err := msg.UnmarshalCBOR(rb); err != nil { + return nil, fmt.Errorf("receipt %d: parsing COSE_Sign1: %w", i, err) + } + rcpt := ParsedCOSEReceipt{Raw: rb, Message: msg} + if kidVal, ok := msg.Headers.Protected[COSE_Header_kid]; ok { + if kidBytes, ok := kidVal.([]byte); ok { + rcpt.Kid = string(kidBytes) + } else { + return nil, fmt.Errorf("receipt %d: kid is not a byte string (got %T)", i, kidVal) + } + } else { + return nil, fmt.Errorf("receipt %d: kid header missing", i) + } + if cwtVal, ok := msg.Headers.Protected[COSE_Header_CWTClaims]; ok { + if cwt, ok := cwtVal.(map[interface{}]interface{}); ok { + issVal, issPresent := cwt[CWT_Issuer] + if !issPresent { + return nil, fmt.Errorf("receipt %d: issuer (iss) claim missing from CWT claims", i) + } + if iss, ok := issVal.(string); ok { + rcpt.Issuer = iss + } else { + return nil, fmt.Errorf("receipt %d: issuer is not a string (got %T)", i, issVal) + } + } else { + return nil, fmt.Errorf("receipt %d: CWT claims is not a map (got %T)", i, cwtVal) + } + } else { + return nil, fmt.Errorf("receipt %d: CWT claims missing", i) + } + out = append(out, rcpt) + } + return out, nil } // This function is rather unpleasant in that it both decodes the COSE Sign1 document and its various @@ -177,10 +260,36 @@ func UnpackAndValidateCOSE1CertChain(raw []byte) (*UnpackedCoseSign1, error) { return nil, err } - issuer := getStringValue(protected, "iss") - feed := getStringValue(protected, "feed") + cwt, hasCwt := protected[COSE_Header_CWTClaims] + var issuer, feed string + if hasCwt { + cwt, ok := cwt.(map[interface{}]interface{}) + if !ok { + return nil, fmt.Errorf("expected CWTClaims header to be a map[any]any, got %T", cwt) + } + issuer = getStringValue(cwt, CWT_Issuer) + feed = getStringValue(cwt, CWT_Subject) + } else { + issuer = getStringValue(protected, "iss") + feed = getStringValue(protected, "feed") + } + contenttype := getStringValue(protected, cose.HeaderLabelContentType) + receipts, err := parseCOSEReceipts(msg.Headers.Unprotected) + if err != nil { + return nil, fmt.Errorf("parsing receipts: %w", err) + } + if len(receipts) > 0 { + dataHash, err := computeSignedStatementDataHash(raw) + if err != nil { + return nil, fmt.Errorf("computing signed statement data hash: %w", err) + } + for i := range receipts { + receipts[i].ExpectedDataHash = dataHash + } + } + return &UnpackedCoseSign1{ Pubcert: leafCertBase64, Feed: feed, @@ -190,5 +299,291 @@ func UnpackAndValidateCOSE1CertChain(raw []byte) (*UnpackedCoseSign1, error) { ContentType: contenttype, Payload: msg.Payload, CertChain: chain, + Protected: protected, + Unprotected: msg.Headers.Unprotected, + Receipts: receipts, }, nil } + +// asInt64 coerces a CBOR-decoded integer value (which may be returned as +// int64, uint64 or int by different decoders) to an int64. +func asInt64(v interface{}) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case int: + return int64(n), true + case uint64: + if n > math.MaxInt64 { + logrus.Errorf("Unable to convert %v to int64 due to overflow", n) + return 0, false + } + return int64(n), true + case uint: + // uint is 64bit on 64bit platforms, so can overflow int64 + if n > math.MaxInt64 { + logrus.Errorf("Unable to convert %v to int64 due to overflow", n) + return 0, false + } + return int64(n), true + } + return 0, false +} + +// Validate validates the COSE Receipt's structure and signature. See +// https://www.ietf.org/archive/id/draft-ietf-cose-merkle-tree-proofs-18.html +// for details about COSE Receipts. +// +// It checks that: +// - the protected header carries a vds (label 395), +// - the payload is detached, +// - the unprotected `vdp` header (label 396) contains at least one +// inclusion proof (key -1) encoded as a byte string, +// - the Merkle root recomputed from each inclusion proof verifies the +// receipt's COSE_Sign1 signature, using the public key in `keys` indexed by +// r.Kid. +// - The data-hash in the receipt matches the expected hash of the signed +// statement it is for. +// +// keys is a map of key IDs to public keys for this ledger. The caller must +// acquire this via some other means, e.g. via a signed trusted key list, or via +// the JWKS endpoint of the ledger (see example code in +// cmd/sign1util/ccf_keyfetch.go) with additional attestation verification which +// is not implemented in this library. +func (r ParsedCOSEReceipt) Validate(keys map[string]crypto.PublicKey) error { + msg := r.Message + + vdsVal, ok := msg.Headers.Protected[COSE_Header_vds] + if !ok { + return fmt.Errorf("missing vds (label %d) in protected header", COSE_Header_vds) + } + vds, ok := asInt64(vdsVal) + if !ok { + return fmt.Errorf("vds has wrong type: %T", vdsVal) + } + + if msg.Payload != nil { + return fmt.Errorf("payload must be detached but has %d bytes", len(msg.Payload)) + } + + algoVal, ok := msg.Headers.Protected[cose.HeaderLabelAlgorithm] + if !ok { + return fmt.Errorf("missing algorithm in protected header") + } + algo, ok := algoVal.(cose.Algorithm) + if !ok { + return fmt.Errorf("algorithm has wrong type: %T", algoVal) + } + + pubKey, ok := keys[r.Kid] + if !ok { + return fmt.Errorf("no key for kid %s", r.Kid) + } + + vdpVal, ok := msg.Headers.Unprotected[COSE_Header_vdp] + if !ok { + return fmt.Errorf("missing vdp (label %d) in unprotected header", COSE_Header_vdp) + } + vdpMap, ok := vdpVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("vdp has wrong type: %T", vdpVal) + } + inclVal, ok := vdpMap[COSE_ProofInclusion] + if !ok { + return fmt.Errorf("no inclusion proofs (key %d) in vdp", COSE_ProofInclusion) + } + inclArr, ok := inclVal.([]interface{}) + if !ok { + return fmt.Errorf("inclusion proofs has wrong type: %T", inclVal) + } + if len(inclArr) == 0 { + return fmt.Errorf("inclusion proofs array is empty") + } + + verifier, err := cose.NewVerifier(algo, pubKey) + if err != nil { + return fmt.Errorf("cose.NewVerifier (algo %d): %w", algo, err) + } + + for i, p := range inclArr { + pb, ok := p.([]byte) + if !ok { + return fmt.Errorf("inclusion proof %d is not a byte string (got %T)", i, p) + } + var root, dataHash []byte + switch vds { + case COSE_vds_CCF_LEDGER_SHA256: + root, dataHash, err = CCF_ComputeRoot(pb) + default: + return fmt.Errorf("only receipts with CCF profile supported (got vds %d)", vds) + } + if err != nil { + return fmt.Errorf("inclusion proof %d: %w", i, err) + } + if !bytes.Equal(dataHash, r.ExpectedDataHash) { + return fmt.Errorf("inclusion proof %d: leaf data-hash %x does not match the expected value %x for the signed envelope", i, dataHash, r.ExpectedDataHash) + } + logrus.Debugf("receipt inclusion proof %d recomputed root: %x", i, root) + // Verify the receipt's COSE_Sign1 signature using the recomputed + // Merkle root as the detached payload. + msg.Payload = root + if err := msg.Verify(nil, verifier); err != nil { + return fmt.Errorf("inclusion proof %d: signature verification failed (recomputed root=%x, kid=%s, alg=%d): %w", i, root, r.Kid, algo, err) + } + msg.Payload = nil + } + return nil +} + +// Decodes a CCF inclusion proof (the bstr-wrapped CBOR `ccf-inclusion-proof` +// structure) and recomputes the Merkle root using the algorithm described in +// section 3.2 of +// https://datatracker.ietf.org/doc/html/draft-ietf-scitt-receipts-ccf-profile-02 +// Returns the recomputed Merkle root and the data-hash from the leaf (this +// needs to be verified by the caller against an expected value). +func CCF_ComputeRoot(proofBytes []byte) ([]byte, []byte, error) { + var proof map[int64]interface{} + if err := cbor.Unmarshal(proofBytes, &proof); err != nil { + return nil, nil, fmt.Errorf("decoding inclusion proof: %w", err) + } + // ccf-inclusion-proof = bstr .cbor { + // &(leaf: 1) => ccf-leaf + // &(path: 2) => [+ ccf-proof-element] + // } + leafVal, ok := proof[1] + if !ok { + return nil, nil, fmt.Errorf("missing leaf (key 1)") + } + pathVal, ok := proof[2] + if !ok { + return nil, nil, fmt.Errorf("missing path (key 2)") + } + + // ccf-leaf = [ + // ; Byte string of size HASH_SIZE(32) + // internal-transaction-hash: bstr .size 32 + // + // ; Text string of at most 1024 bytes + // internal-evidence: tstr .size (1..1024) + // + // ; Byte string of size HASH_SIZE(32) + // data-hash: bstr .size 32 + // ] + leafArr, ok := leafVal.([]interface{}) + if !ok || len(leafArr) != 3 { + return nil, nil, fmt.Errorf("leaf must be a 3-element array, got %T len %d", leafVal, lenOf(leafVal)) + } + internalTxHash, ok := leafArr[0].([]byte) + if !ok || len(internalTxHash) != 32 { + return nil, nil, fmt.Errorf("leaf.internal-transaction-hash must be a 32-byte bstr, got %T", leafArr[0]) + } + internalEvidenceStr, ok := leafArr[1].(string) + if !ok { + return nil, nil, fmt.Errorf("leaf.internal-evidence must be a text tstr, got %T", leafArr[1]) + } + internalEvidence := []byte(internalEvidenceStr) + if len(internalEvidence) < 1 || len(internalEvidence) > 1024 { + return nil, nil, fmt.Errorf("leaf.internal-evidence has invalid length %d", len(internalEvidence)) + } + dataHash, ok := leafArr[2].([]byte) + if !ok || len(dataHash) != 32 { + return nil, nil, fmt.Errorf("leaf.data-hash must be a 32-byte bstr, got %T", leafArr[2]) + } + + // Leaf hash: + // h := HASH(internal-transaction-hash || HASH(internal-evidence) || data-hash) + evidenceHash := sha256.Sum256(internalEvidence) + leafConcat := make([]byte, 0, 32+32+32) + leafConcat = append(leafConcat, internalTxHash...) + leafConcat = append(leafConcat, evidenceHash[:]...) + leafConcat = append(leafConcat, dataHash...) + leafHash := sha256.Sum256(leafConcat) + h := leafHash[:] + logrus.Debugf("CCF leaf: internal-tx-hash=%x evidence=%q (hash=%x) data-hash=%x -> leaf=%x", internalTxHash, internalEvidence, evidenceHash[:], dataHash, h) + + pathArr, ok := pathVal.([]interface{}) + if !ok { + return nil, nil, fmt.Errorf("path must be an array") + } + if len(pathArr) == 0 { + return nil, nil, fmt.Errorf("path must contain at least one element") + } + + for i, el := range pathArr { + // ccf-proof-element = [ + // ; Position of the element + // left: bool + // + // ; Hash of the proof element: byte string of size HASH_SIZE(32) + // hash: bstr .size 32 + // ] + elArr, ok := el.([]interface{}) + if !ok || len(elArr) != 2 { + return nil, nil, fmt.Errorf("path element %d must be a 2-element array", i) + } + left, ok := elArr[0].(bool) + if !ok { + return nil, nil, fmt.Errorf("path element %d left flag must be a bool", i) + } + hash, ok := elArr[1].([]byte) + if !ok { + return nil, nil, fmt.Errorf("path element %d hash must be a 32-byte bstr, got %T", i, elArr[1]) + } + if len(hash) != 32 { + return nil, nil, fmt.Errorf("path element %d hash must be 32 bytes, got %d bytes", i, len(hash)) + } + var concat []byte + if left { + concat = append(concat, hash...) + concat = append(concat, h...) + } else { + concat = append(concat, h...) + concat = append(concat, hash...) + } + sum := sha256.Sum256(concat) + h = sum[:] + logrus.Debugf("CCF path step %d: left=%v sibling=%x -> h=%x", i, left, hash, h) + } + return h, dataHash, nil +} + +// computeSignedStatementDataHash returns sha256 of the tagged COSE_Sign1 +// envelope with its unprotected header reset to an empty map. This should match +// the data-hash in the CCF receipt. +// +// This is the hash of the Signed Statement as defined by +// https://datatracker.ietf.org/doc/html/draft-ietf-scitt-architecture-22 +func computeSignedStatementDataHash(envelope []byte) ([]byte, error) { + var arr struct { + _ struct{} `cbor:",toarray"` + Protected cbor.RawMessage + Unprot map[interface{}]interface{} + Payload cbor.RawMessage + Signature cbor.RawMessage + } + if err := cbor.Unmarshal(envelope, &arr); err != nil { + return nil, fmt.Errorf("decoding COSE_Sign1: %w", err) + } + arr.Unprot = map[interface{}]interface{}{} + em, err := cbor.CanonicalEncOptions().EncMode() + if err != nil { + return nil, err + } + body, err := em.Marshal(arr) + if err != nil { + return nil, fmt.Errorf("encoding stripped COSE_Sign1: %w", err) + } + tagged, err := em.Marshal(cbor.Tag{Number: COSE_Sign1_Tag, Content: cbor.RawMessage(body)}) + if err != nil { + return nil, fmt.Errorf("tagging COSE_Sign1: %w", err) + } + digest := sha256.Sum256(tagged) + return digest[:], nil +} + +func lenOf(v interface{}) int { + if a, ok := v.([]interface{}); ok { + return len(a) + } + return -1 +} diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go new file mode 100644 index 0000000000..f2215256aa --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go @@ -0,0 +1,43 @@ +package cosesign1 + +const COSE_Sign1_Tag = 18 + +// COSE Header Parameters +// https://www.iana.org/assignments/cose/cose.xhtml +const ( + COSE_Header_kid = int64(4) + COSE_Header_CWTClaims = int64(15) + COSE_Header_x5chain = int64(33) + COSE_Header_x5t = int64(34) + COSE_Header_PayloadHashAlg = int64(258) + COSE_Header_PreimageContentType = int64(259) + COSE_Header_PayloadLocation = int64(260) + COSE_Header_Receipts = int64(394) + COSE_Header_vds = int64(395) + COSE_Header_vdp = int64(396) +) + +// COSE Verifiable Data Structure Algorithms +// (Values for COSE_HeaderLabelvds) +const ( + COSE_vds_RFC9162_SHA256 = int64(1) + + // TBD_1 in https://www.ietf.org/archive/id/draft-birkholz-cose-receipts-ccf-profile-05.html + COSE_vds_CCF_LEDGER_SHA256 = int64(2) +) + +// COSE Verifiable Data Structure Proofs +// (These are the map keys inside a COSE_HeaderLabelReceipts header). +const ( + COSE_ProofInclusion = int64(-1) + COSE_ProofConsistency = int64(-2) +) + +// CWT Claims +// https://www.iana.org/assignments/cwt/cwt.xhtml +const ( + CWT_Issuer = int64(1) + CWT_Subject = int64(2) +) + +const TTL_LedgerEntry_Keys = int64(1) diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_cp_ledger_pub_keys.json b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_cp_ledger_pub_keys.json new file mode 100644 index 0000000000..5f9507329e --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_cp_ledger_pub_keys.json @@ -0,0 +1,11 @@ +{ + "keys": [ + { + "crv": "P-384", + "kid": "a7ad3b7729516ca443fa472a0f2faa4a984ee3da7eafd17f98dcffbac4a6a10f", + "kty": "EC", + "x": "m0kQ1A_uqHWuP9fdGSKatSq2brcAJ6-q3aZ5P35wjbgtNnlm2u-NLF1qM-yC4I2n", + "y": "J9cJFrdWvUf6PCMkrWFTgB16uEq4mSMCI4NPVytnwYX6xNnuJ2GTrPtafKYg1VNi" + } + ] +} \ No newline at end of file diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_db_ledger_pub_keys.json b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_db_ledger_pub_keys.json new file mode 100644 index 0000000000..c1355ba425 --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_db_ledger_pub_keys.json @@ -0,0 +1,18 @@ +{ + "keys": [ + { + "crv": "P-384", + "kid": "23d48c280f71abf575c81e89f18a4dc9f3b33d8a3b149b16ad836c8553f95bc0", + "kty": "EC", + "x": "2GIJv9nAhste7hDWrpea1-hd_BAPXg4ZIxLy4C4hAX2eCpqT4siLqohA2KIVJti8", + "y": "aTT6XYHZPBgdI4RLFo2BaP1RVuOG2rFg5JBhYvt871HIwmtzNtwXl3_NBwfcqr8O" + }, + { + "crv": "P-384", + "kid": "da7694f16def5a056ca96afb21e89a9450e4cc875e2de351da76d99544a3e849", + "kty": "EC", + "x": "GeQ_qA3ZxYoaan3D0nA7xriMcmiMqQ0UNY1DLs7C5kIEaI_RL_2duRcG1Ii6g-8-", + "y": "uKiRr4UU8aXumcA8wu6LOatH0qL2AjFy3_8iBx3mbt1foS5xNHlXchMMLTSCvRLn" + } + ] +} diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/keyset.go b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/keyset.go new file mode 100644 index 0000000000..68b3cb7ebb --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/keyset.go @@ -0,0 +1,98 @@ +package cosesign1 + +import ( + "crypto" + + "github.com/fxamacker/cbor/v2" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + cose "github.com/veraison/go-cose" +) + +// Parses a COSE_KeySet, which is a CBOR array of raw COSE_Key objects, into a +// map from key IDs to public keys, to be used for receipt validation. +// +// Reference: https://www.rfc-editor.org/rfc/rfc9052.html#name-cose-keys +func ParseKeySetAsMap(data []byte) (map[string]crypto.PublicKey, error) { + var rawKeys []cbor.RawMessage + if err := cbor.Unmarshal(data, &rawKeys); err != nil { + return nil, errors.Wrap(err, "Failed to parse the COSE_KeySet") + } + if len(rawKeys) == 0 { + return nil, errors.New("empty COSE Key Set") + } + var lastKeyError error + keys := make(map[string]crypto.PublicKey) + for i, raw := range rawKeys { + // From RFC: Each element in a COSE Key Set MUST be processed + // independently. If one element in a COSE Key Set is either malformed + // or uses a key that is not understood by an application, that key is + // ignored, and the other keys are processed normally. + var k cose.Key + if err := k.UnmarshalCBOR(raw); err != nil { + logrus.Warnf("Failed to parse element %d of the COSE Key Set: %v", i, err) + lastKeyError = errors.Wrapf(err, "UnmarshalCBOR element %d", i) + continue + } + kid := string(k.ID) + if kid == "" { + logrus.Warnf("Failed to parse element %d of the COSE Key Set: missing key ID, ignoring this key", i) + lastKeyError = errors.Errorf("missing key ID in element %d", i) + continue + } + pk, err := k.PublicKey() + if err != nil { + logrus.Warnf("Failed to construct public key from element %d of the COSE Key Set (kid=%q): %v", i, kid, err) + lastKeyError = errors.Wrapf(err, "construct PublicKey from element %d", i) + continue + } + if existingKey, exists := keys[kid]; exists { + // Equal is implemented for all crypto.PublicKey types in std + eq, ok := existingKey.(interface{ Equal(crypto.PublicKey) bool }) + if !ok || !eq.Equal(pk) { + logrus.Warnf("Parsing element %d of the COSE Key Set: Key with ID %q already seen earlier but got another conflicting key with same ID, ignoring this one", i, kid) + continue + } + } + keys[kid] = pk + } + if len(keys) == 0 { + logrus.Errorf("Failed to parse any element of the provided COSE Key Set") + return nil, lastKeyError + } + return keys, nil +} + +// ParseTTLPayload parses an unsigned body of a Transparency Trust List (TTL), +// which is a CBOR map from issuer strings to LedgerEntry maps. Each LedgerEntry +// is a CBOR map keyed by integer attributes; the TTL_LedgerEntry_Keys (1) +// attribute holds that issuer's COSE_KeySet. The result is a map from issuer to +// that issuer's map of key IDs to public keys. +// +// Reference: https://github.com/achamayou/scitt-ccf-ledger/blob/ttl/docs/transparent_trust_lists.md +func ParseTTLPayload(data []byte) (map[string]map[string]crypto.PublicKey, error) { + var rawIssuers map[string]cbor.RawMessage + if err := cbor.Unmarshal(data, &rawIssuers); err != nil { + return nil, errors.Wrap(err, "Failed to parse the TTL payload") + } + if len(rawIssuers) == 0 { + return nil, errors.New("empty TTL payload") + } + out := make(map[string]map[string]crypto.PublicKey, len(rawIssuers)) + for issuer, rawEntry := range rawIssuers { + var entry map[int64]cbor.RawMessage + if err := cbor.Unmarshal(rawEntry, &entry); err != nil { + return nil, errors.Wrapf(err, "parsing LedgerEntry for issuer %q", issuer) + } + rawKeySet, ok := entry[TTL_LedgerEntry_Keys] + if !ok { + return nil, errors.Errorf("LedgerEntry for issuer %q is missing the keys attribute (%d)", issuer, TTL_LedgerEntry_Keys) + } + keys, err := ParseKeySetAsMap(rawKeySet) + if err != nil { + return nil, errors.Wrapf(err, "parsing COSE_KeySet for issuer %q", issuer) + } + out[issuer] = keys + } + return out, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index c9d971c854..94ecc0dc38 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -4,8 +4,8 @@ cyphar.com/go-pathrs cyphar.com/go-pathrs/internal/fdutils cyphar.com/go-pathrs/internal/libpathrs cyphar.com/go-pathrs/procfs -# github.com/Microsoft/cosesign1go v1.4.0 -## explicit; go 1.20 +# github.com/Microsoft/cosesign1go v1.6.0-alpha1 +## explicit; go 1.21 github.com/Microsoft/cosesign1go/pkg/cosesign1 # github.com/Microsoft/didx509go v0.0.3 ## explicit; go 1.20