diff --git a/go.mod b/go.mod index e0c32ca092..8c84551a16 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ tool ( ) require ( - github.com/Microsoft/cosesign1go v1.5.0 + github.com/Microsoft/cosesign1go v1.6.0 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 83d8303596..86871c93f6 100644 --- a/go.sum +++ b/go.sum @@ -362,8 +362,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= -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 h1:/dGDBxrrbqdkUDOgUDvFAKBou85XmSrB58G3sfYaAMk= +github.com/Microsoft/cosesign1go v1.6.0/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..da0627f724 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_trust_lists := [] +fragment_transparency_trust_lists := data[input.namespace].transparency_trust_lists + apply_defaults(name, raw_values, framework_version) := values { semver.compare(framework_version, version) == 0 values := raw_values @@ -1170,6 +1173,21 @@ apply_defaults("fragment", raw_values, framework_version) := values { ] } +# transparency_trust_lists 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_trust_lists", 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_trust_lists", 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 +1196,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_trust_lists": apply_defaults("transparency_trust_lists", fragment_transparency_trust_lists, framework_version), } fragment := { @@ -1260,6 +1279,84 @@ fragment_issuer_feed_ok(fragment) { input.feed == fragment.feed } +# header_svn_ok checks that the fragment's CWT-declared SVN is at least the +# minimum, if it is present. If it's not present, then we don't check it here, +# but later in svn_ok_if_defined we will ensure that the fragment itself +# declares an SVN and that it meets the minimum requirement. A case where +# neither the header nor the fragment Rego declares an SVN is tested in +# Test_Rego_LoadFragment_MissingSVN. + +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 sources +# (the receipt issuers). input.receipts is the set of receipts that the enforcer +# successfully validated against keys learned from accepted TTLs. Each receipt +# is {"issuer": , "ttl_feeds": [, ...]} where ttl_feeds +# lists the subjects of the TTLs that contributed the exact key that validated +# the receipt. If not set, no receipts are required. The list is an AND: every +# required entry must be satisfied. +fragment_receipts_ok(fragment) { + required := object.get(fragment, "receipt_issuers", []) + every required_issuer in required { + receipt_requirement_satisfied(required_issuer) + } +} + +# receipt_requirement_satisfied checks a single required receipt issuer against +# the validated receipts in input.receipts. A required entry may be: +# - "*": satisfied by any validated receipt. This still implies the receipt +# was signed by a key from a TTL we have accepted, it just doesn't constrain +# which one. +# - "TTL:": satisfied by a validated receipt that was signed by a key +# contributed by a TTL with the given subject. +# - a literal ledger name: satisfied by a validated receipt with that issuer. +receipt_requirement_satisfied(required_issuer) { + required_issuer == "*" + count(input.receipts) > 0 +} + +receipt_requirement_satisfied(required_issuer) { + startswith(required_issuer, "TTL:") + subject := substring(required_issuer, count("TTL:"), -1) + some receipt in input.receipts + subject in receipt.ttl_feeds +} + +receipt_requirement_satisfied(required_issuer) { + required_issuer != "*" + not startswith(required_issuer, "TTL:") + some receipt in input.receipts + receipt.issuer == required_issuer +} + default load_fragment := {"allowed": false} # load_fragment gets called twice - first before loading the fragment as a Rego @@ -1267,20 +1364,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 +1396,54 @@ load_fragment := {"metadata": [updateIssuer], "add_module": add_module, "allowed add_module := "namespace" in fragment.includes } +# transparency_trust_lists 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_trust_lists and any contributed by +# already-loaded fragments that included "transparency_trust_lists". +default policy_transparency_trust_lists := [] +policy_transparency_trust_lists := data.policy.transparency_trust_lists + +candidate_transparency_trust_lists := roots { + fragment_roots := [r | + feed := data.metadata.issuers[_].feeds[_] + fragment := feed[_] + r := fragment.transparency_trust_lists[_] + ] + + roots := array.concat(policy_transparency_trust_lists, 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_trust_lists[_] + 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 +1961,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 +1989,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 +2037,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, "receipt_issuers", []) + some required_issuer in required + not receipt_requirement_satisfied(required_issuer) + receipt_error := sprintf("missing receipt from %s", [required_issuer]) +} + +default transparency_root_matches := false + +transparency_root_matches { + some ttl in candidate_transparency_trust_lists + 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 +2569,13 @@ check_fragment(raw_fragment, framework_version) := fragment { "feed": raw_fragment.feed, "minimum_svn": raw_fragment.minimum_svn, "includes": raw_fragment.includes, + + # 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. + "receipt_issuers": object.get(raw_fragment, "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..ac7b2ccc50 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,787 @@ 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_trust_lists := [ + { + "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, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subject, + SVN: 2, + ParsedTTL: 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, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subject, + SVN: 1, + ParsedTTL: 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, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subject, + SVN: 1, + ParsedTTL: 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, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subject, + SVN: 4, + ParsedTTL: 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, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subject, + SVN: 1, + ParsedTTL: 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, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subject, + SVN: 1, + ParsedTTL: 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"].key.(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": [], + "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 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_trust_lists := [ + { + "issuer": "%s", + "subject": "%s", + "minimum_svn": 1, + "allowed_ledgers": ["%s"], + }, +] + +fragments := [ + { + "issuer": "%s", + "feed": "%s", + "minimum_svn": 1, + "includes": [], + "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, LoadTransparencyTrustListOptions{ + Issuer: ttlIssuer, + Subject: ttlSubject, + SVN: 1, + ParsedTTL: 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, LoadTransparencyTrustListOptions{ + Issuer: ttlIssuer, + Subject: ttlSubject, + SVN: 1, + ParsedTTL: 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, LoadTransparencyTrustListOptions{ + Issuer: ttlIssuer, + Subject: ttlSubject, + SVN: 1, + ParsedTTL: 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) + } +} + +// ttlEntryRego renders a single transparency_trust_lists entry as Rego. +func ttlEntryRego(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(`{"issuer": %q, "subject": %q, "minimum_svn": %d, "allowed_ledgers": [%s]}`, + issuer, subject, minimumSVN, strings.Join(quoted, ", ")) +} + +// ttlReceiptFragmentPolicy builds a policy that trusts the given TTL entries and +// allows a fragment from fragIssuer/fragFeed that requires the given list of +// receipt issuers (which may include literal ledger names, "*", or +// "TTL:"). +func ttlReceiptFragmentPolicy(ttlEntries []string, fragIssuer, fragFeed string, requiredIssuers []string) string { + quotedReq := make([]string, len(requiredIssuers)) + for i, r := range requiredIssuers { + quotedReq[i] = fmt.Sprintf("%q", r) + } + return fmt.Sprintf(`package policy + +api_version := "%s" +framework_version := "%s" + +transparency_trust_lists := [%s] + +fragments := [ + { + "issuer": "%s", + "feed": "%s", + "minimum_svn": 1, + "includes": [], + "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, strings.Join(ttlEntries, ", "), fragIssuer, fragFeed, strings.Join(quotedReq, ", ")) +} + +func Test_Rego_LoadFragment_WildcardReceipt(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy( + ttlReceiptFragmentPolicy( + []string{ttlEntryRego(ttlIssuer, ttlSubject, 1, []string{ledger})}, + fragIssuer, fragFeed, []string{"*"}), + []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, LoadTransparencyTrustListOptions{ + Issuer: ttlIssuer, + Subject: ttlSubject, + SVN: 1, + ParsedTTL: map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + }, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return nil + }) + + // A "*" requirement is satisfied by any validated receipt, regardless of + // which trusted ledger it came from. + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + if err := policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: ledger, Kid: "kid1"}}, + }); err != nil { + t.Fatalf("expected fragment requiring \"*\" to load with a valid receipt: %v", err) + } +} + +func Test_Rego_LoadFragment_WildcardReceipt_NoReceipt(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy( + ttlReceiptFragmentPolicy( + []string{ttlEntryRego(ttlIssuer, ttlSubject, 1, []string{ledger})}, + fragIssuer, fragFeed, []string{"*"}), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + // A fragment requiring "*" still needs at least one validated receipt. With + // no receipts attached, the load must be denied. + 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, + }) + if err == nil { + t.Fatalf("expected fragment requiring \"*\" to be denied when no receipt is attached") + } + if !assertDecisionJSONContains(t, err, "missing receipt from *") { + t.Fatalf("expected denial reason to mention the missing wildcard receipt, got: %v", err) + } + if !expectFragmentNotLoaded(t, policy, fragIssuer, fragFeed) { + return + } +} + +// setupTTLSubjectReceiptPolicy builds a policy where the same ledger is +// authorized by two different TTL subjects (A and B), each contributing a +// distinct key id, and a fragment that requires a receipt signed by a key from +// TTL subject A ("TTL:"). It loads both TTLs (subject A offering kid1, +// subject B offering kid2) and installs an accepting receipt validation mock. +func setupTTLSubjectReceiptPolicy(t *testing.T, ctx context.Context) (policy *regoEnforcer, fragIssuer, fragFeed, ledger string) { + t.Helper() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubjectA := testDataGenerator.uniqueFragmentFeed() + ttlSubjectB := testDataGenerator.uniqueFragmentFeed() + fragIssuer = testDataGenerator.uniqueFragmentIssuer() + fragFeed = testDataGenerator.uniqueFragmentFeed() + ledger = "ledger.example" + + var err error + policy, err = newRegoPolicy( + ttlReceiptFragmentPolicy( + []string{ + ttlEntryRego(ttlIssuer, ttlSubjectA, 1, []string{ledger}), + ttlEntryRego(ttlIssuer, ttlSubjectB, 1, []string{ledger}), + }, + fragIssuer, fragFeed, []string{"TTL:" + ttlSubjectA}), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + keyA := generateTestECDSAKey(t) + keyB := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, LoadTransparencyTrustListOptions{ + Issuer: ttlIssuer, + Subject: ttlSubjectA, + SVN: 1, + ParsedTTL: map[string]map[string]crypto.PublicKey{ledger: {"kid1": keyA}}, + }); err != nil { + t.Fatalf("unable to load TTL A: %v", err) + } + if err := policy.LoadTransparencyTrustList(ctx, LoadTransparencyTrustListOptions{ + Issuer: ttlIssuer, + Subject: ttlSubjectB, + SVN: 1, + ParsedTTL: map[string]map[string]crypto.PublicKey{ledger: {"kid2": keyB}}, + }); err != nil { + t.Fatalf("unable to load TTL B: %v", err) + } + + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return nil + }) + return policy, fragIssuer, fragFeed, ledger +} + +func Test_Rego_LoadFragment_TTLSubjectReceipt(t *testing.T) { + ctx := context.Background() + policy, fragIssuer, fragFeed, ledger := setupTTLSubjectReceiptPolicy(t, ctx) + + // kid1 was contributed by TTL subject A, so a receipt signed with it + // satisfies the fragment's "TTL:" requirement. + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + if err := policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: ledger, Kid: "kid1"}}, + }); err != nil { + t.Fatalf("expected fragment requiring a receipt from TTL subject A to load: %v", err) + } +} + +func Test_Rego_LoadFragment_TTLSubjectReceipt_WrongSubject(t *testing.T) { + ctx := context.Background() + policy, fragIssuer, fragFeed, ledger := setupTTLSubjectReceiptPolicy(t, ctx) + + // kid2 was contributed by TTL subject B, not A. Even though the receipt + // validates against a trusted key for the same ledger, it does not satisfy a + // requirement for TTL:. + 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: "kid2"}}, + }) + if err == nil { + t.Fatalf("expected fragment load to be denied: receipt signed by key from the wrong TTL subject") + } + if !assertDecisionJSONContains(t, err, "missing receipt from TTL:") { + t.Fatalf("expected denial reason to mention the missing ttl-subject receipt, got: %v", err) + } + if !expectFragmentNotLoaded(t, policy, fragIssuer, fragFeed) { + return + } +} + +func Test_Rego_LoadTransparencyTrustList_OfferedBy(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subjectA := testDataGenerator.uniqueFragmentFeed() + subjectB := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy( + ttlReceiptFragmentPolicy( + []string{ + ttlEntryRego(issuer, subjectA, 1, []string{ledger}), + ttlEntryRego(issuer, subjectB, 1, []string{ledger}), + }, + testDataGenerator.uniqueFragmentIssuer(), testDataGenerator.uniqueFragmentFeed(), nil), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + sharedKey := generateTestECDSAKey(t) + // Subject A offers sharedKey under kid1. + if err := policy.LoadTransparencyTrustList(ctx, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subjectA, + SVN: 1, + ParsedTTL: map[string]map[string]crypto.PublicKey{ledger: {"kid1": sharedKey}}, + }); err != nil { + t.Fatalf("unable to load TTL A: %v", err) + } + // Subject B offers the same key under the same kid, so offeredBy accumulates + // both subjects. + if err := policy.LoadTransparencyTrustList(ctx, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subjectB, + SVN: 1, + ParsedTTL: map[string]map[string]crypto.PublicKey{ledger: {"kid1": sharedKey}}, + }); err != nil { + t.Fatalf("unable to load TTL B: %v", err) + } + + entry := policy.ttlKeys[ledger]["kid1"] + if len(entry.offeredBy) != 2 || !slices.Contains(entry.offeredBy, subjectA) || !slices.Contains(entry.offeredBy, subjectB) { + t.Fatalf("expected kid1 to be offered by both subjects, got %v", entry.offeredBy) + } + + // Subject A now offers a different key under the same kid. The override must + // reset offeredBy to only the contributing subject (A). + newKey := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subjectA, + SVN: 1, + ParsedTTL: map[string]map[string]crypto.PublicKey{ledger: {"kid1": newKey}}, + }); err != nil { + t.Fatalf("unable to load overriding TTL: %v", err) + } + + entry = policy.ttlKeys[ledger]["kid1"] + if len(entry.offeredBy) != 1 || entry.offeredBy[0] != subjectA { + t.Fatalf("expected offeredBy to be reset to [subjectA] after key override, got %v", entry.offeredBy) + } + eq := entry.key.(interface{ Equal(crypto.PublicKey) bool }) + if !eq.Equal(newKey) { + t.Errorf("expected the key to be replaced with the new key") + } +} + func Test_Rego_LoadFragment_BadIssuer_AttemptOverrideFrameworkItems(t *testing.T) { f := func(p *generatedConstraints) bool { tc, err := setupSimpleRegoFragmentTestConfig(p) @@ -5125,7 +6228,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 +6278,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 +6308,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 +6344,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 +6386,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 +7052,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 +7089,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..1b568ba27e 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" @@ -14,12 +15,14 @@ import ( "github.com/Microsoft/cosesign1go/pkg/cosesign1" didx509resolver "github.com/Microsoft/didx509go/pkg/did-x509-resolver" "github.com/Microsoft/hcsshim/internal/log" + "github.com/Microsoft/hcsshim/internal/oc" "github.com/Microsoft/hcsshim/internal/protocol/guestresource" "github.com/Microsoft/hcsshim/pkg/amdsevsnp" "github.com/Microsoft/hcsshim/pkg/annotations" "github.com/opencontainers/runtime-spec/specs-go" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "go.opencensus.io/trace" ) type SecurityOptions struct { @@ -102,6 +105,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/ // @@ -113,7 +151,26 @@ func (s *SecurityOptions) SetConfidentialOptions(ctx context.Context, enforcerTy // 3 - Check that this issuer/feed match the requirement of the user provided // security policy (done in the regoby LoadFragment) func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestresource.SecurityPolicyFragment) (err error) { - log.G(ctx).WithField("fragment", fmt.Sprintf("%+v", fragment)).Debug("VerifyAndExtractFragment") + ctx, span := oc.StartSpan(ctx, "securitypolicy::InjectFragment") + defer span.End() + defer func() { oc.SetSpanStatus(span, err) }() + span.AddAttributes(trace.StringAttribute("fragment", fmt.Sprintf("%+v", fragment))) + + // 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 { @@ -134,16 +191,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,11 +230,84 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres return fmt.Errorf("failed to resolve DID: %w", err) } - // now offer the payload fragment to the policy - err = s.PolicyEnforcer.LoadFragment(ctx, issuer, feed, payloadString) - if err != nil { - return fmt.Errorf("error loading security policy fragment: %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 + } } + + switch mediaType { + case 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. + log.G(ctx).WithFields(logrus.Fields{ + "issuer": issuer, + "subject": feed, + "svn": svnFromCwt, + "numLedgers": len(parsedTTL), + }).Debugf("Offering transparency trust list containing %d ledgers with issuer %s subject %s to policy enforcer", len(parsedTTL), issuer, feed) + if log.G(ctx).Logger.IsLevelEnabled(logrus.TraceLevel) { + for ledgerName, ledger := range parsedTTL { + for keyId, entry := range ledger { + log.G(ctx).WithFields(logrus.Fields{ + "issuer": issuer, + "subject": feed, + "svn": svnFromCwt, + "ledger": ledgerName, + "keyId": keyId, + }).Tracef("Transparency trust list contains entry for ledger %s kid %s entry %T", ledgerName, keyId, entry) + } + } + } + if err := s.PolicyEnforcer.LoadTransparencyTrustList(ctx, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: feed, + SVN: *svnFromCwt, + ParsedTTL: parsedTTL, + }); err != nil { + return errors.Wrap(err, "error loading transparency trust list") + } + + case mediaTypeFragment: + // now offer the payload fragment to the policy + fragmentOptions := LoadFragmentOptions{ + Issuer: issuer, + Feed: feed, + HeaderSVN: svnFromCwt, + Rego: payloadString, + Receipts: unpacked.Receipts, + } + log.G(ctx).WithFields(logrus.Fields{ + "issuer": issuer, + "feed": feed, + "headerSvn": svnFromCwt, + "numReceipts": len(unpacked.Receipts), + }).Debugf("Offering fragment with issuer %s feed %s to policy enforcer with %d receipts", issuer, feed, len(unpacked.Receipts)) + log.G(ctx).WithFields(logrus.Fields{ + "feed": feed, + "rego": payloadString, + }).Tracef("Fragment Rego content") + if err = s.PolicyEnforcer.LoadFragment(ctx, fragmentOptions); err != nil { + 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..b182f67455 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,32 @@ 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 +} + +type LoadTransparencyTrustListOptions struct { + // Issuer of the signed Transparency Trust List (TTL). + Issuer string + // Subject of the TTL. + Subject string + // SVN carried in the TTL's COSE header. + SVN int64 + // ParsedTTL maps each ledger name (receipt issuer) to that ledger's kid -> + // public key map. This is the return value of cosesign1.ParseTTLPayload. + ParsedTTL map[string]map[string]crypto.PublicKey +} + const ( openDoorEnforcerName = "open_door" ) @@ -125,7 +154,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, opts LoadTransparencyTrustListOptions) 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 +322,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, LoadTransparencyTrustListOptions) error { return nil } @@ -425,10 +459,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, LoadTransparencyTrustListOptions) 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..726d47a8a6 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,27 @@ 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]TTLKeyEntry + // 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 +} + +// TTLKeyEntry is a single receipt-signing key learned from one or more loaded +// Transparency Trust Lists (TTLs), along with the subjects of the TTLs that +// contributed this exact key under its key id. +type TTLKeyEntry struct { + // key is the parsed public key. + key crypto.PublicKey + // offeredBy holds the subjects of all loaded TTLs that contributed this + // exact key under this kid. It is used to satisfy fragment receipt + // requirements of the form "TTL:". + offeredBy []string } var _ SecurityPolicyEnforcer = (*regoEnforcer)(nil) @@ -159,6 +182,10 @@ func newRegoPolicy(code string, defaultMounts []oci.Mount, privilegedMounts []oc return nil, err } policy.stdio = map[string]bool{} + policy.ttlKeys = map[string]map[string]TTLKeyEntry{} + 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 +1115,62 @@ 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. Each receipt we successfully validate is passed to the + // policy as an entry of input.receipts, of the form + // {"issuer": , "ttl_feeds": [, ...]}, where ttl_feeds + // lists the subjects of the TTLs that contributed the exact key that + // validated the receipt. + receipts := []interface{}{} + policy.ttlKeysLock.Lock() + defer policy.ttlKeysLock.Unlock() + + for _, receipt := range opts.Receipts { + entries, 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 + } + keys := make(map[string]crypto.PublicKey, len(entries)) + for kid, entry := range entries { + keys[kid] = entry.key + } + if err := policy.validateReceipt(receipt, keys); err != nil { + log.G(ctx).WithError(err).WithField("issuer", receipt.Issuer).Error("fragment receipt failed validation") + continue + } + // Validation succeeded against keys[receipt.Kid], so the validating key + // is entries[receipt.Kid]. Report which TTL subjects offered that key so + // the policy can satisfy "TTL:" requirements. + ttlFeeds := append([]string(nil), entries[receipt.Kid].offeredBy...) + receipts = append(receipts, inputData{ + "issuer": receipt.Issuer, + "ttl_feeds": ttlFeeds, + }) + } + fragment := &rpi.RegoModule{ Issuer: issuer, Feed: feed, @@ -1106,6 +1183,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, + "receipts": receipts, } // Check that the fragment is signed by the expected issuer before loading @@ -1116,7 +1196,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 +1220,95 @@ 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_trust_lists. 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, opts LoadTransparencyTrustListOptions) error { + parsedTTL := opts.ParsedTTL + ledgers := make([]string, 0, len(parsedTTL)) + for ledger := range parsedTTL { + ledgers = append(ledgers, ledger) + } + + input := inputData{ + "issuer": opts.Issuer, + "subject": opts.Subject, + "svn": opts.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]TTLKeyEntry, len(newKeys)) + policy.ttlKeys[ledger] = existingKeys + } + for kid, pk := range newKeys { + if existingEntry, exists := existingKeys[kid]; exists { + // Equal is implemented for all crypto.PublicKey types in std. + eq, ok := existingEntry.key.(interface{ Equal(crypto.PublicKey) bool }) + if ok && eq.Equal(pk) { + // The same key is offered again, possibly by a TTL with a + // different subject. Record this TTL's subject as also + // offering it (deduplicated). + if !slices.Contains(existingEntry.offeredBy, opts.Subject) { + existingEntry.offeredBy = append(existingEntry.offeredBy, opts.Subject) + existingKeys[kid] = existingEntry + } + } else { + // A different key is offered under the same kid. Replace it + // and reset offeredBy to only this TTL's subject, since the + // previously recorded subjects offered a now-superseded key. + log.G(ctx).Warnf("TTL for ledger %s overrides existing key with id %s with a different key", ledger, kid) + existingKeys[kid] = TTLKeyEntry{key: pk, offeredBy: []string{opts.Subject}} + } + } else { + existingKeys[kid] = TTLKeyEntry{key: pk, offeredBy: []string{opts.Subject}} + } + } + } + + log.G(ctx).Infof("Loaded TTL with subject %s signed by %s with keys for ledgers: %v", opts.Subject, opts.Issuer, allowedLedgers) + return nil +} + func (policy *regoEnforcer) EnforceScratchMountPolicy(ctx context.Context, scratchPath string, encrypted bool) error { input := map[string]interface{}{ "target": scratchPath, @@ -1211,14 +1381,43 @@ 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, as are the offeredBy slices, but the crypto.PublicKey +// values are shared. +func (policy *regoEnforcer) snapshotTTLKeys() map[string]map[string]TTLKeyEntry { + policy.ttlKeysLock.Lock() + defer policy.ttlKeysLock.Unlock() + snapshot := make(map[string]map[string]TTLKeyEntry, len(policy.ttlKeys)) + for ledger, keys := range policy.ttlKeys { + keysCopy := make(map[string]TTLKeyEntry, len(keys)) + for kid, entry := range keys { + keysCopy[kid] = TTLKeyEntry{ + key: entry.key, + offeredBy: append([]string(nil), entry.offeredBy...), + } + } + 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/aci-cc-ttl.ttl.json b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/aci-cc-ttl.ttl.json new file mode 100644 index 0000000000..0e3b5ade40 --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/aci-cc-ttl.ttl.json @@ -0,0 +1,91 @@ +{ + "esrp-cts-cp.confidential-ledger.azure.com": { + "keys": [ + { + "kty": "EC", + "crv": "P-384", + "kid": "a7ad3b7729516ca443fa472a0f2faa4a984ee3da7eafd17f98dcffbac4a6a10f", + "x": "m0kQ1A_uqHWuP9fdGSKatSq2brcAJ6-q3aZ5P35wjbgtNnlm2u-NLF1qM-yC4I2n", + "y": "J9cJFrdWvUf6PCMkrWFTgB16uEq4mSMCI4NPVytnwYX6xNnuJ2GTrPtafKYg1VNi" + } + ] + }, + "esrp-cts-db.confidential-ledger.azure.com": { + "keys": [ + { + "kty": "EC", + "crv": "P-384", + "kid": "23d48c280f71abf575c81e89f18a4dc9f3b33d8a3b149b16ad836c8553f95bc0", + "x": "2GIJv9nAhste7hDWrpea1-hd_BAPXg4ZIxLy4C4hAX2eCpqT4siLqohA2KIVJti8", + "y": "aTT6XYHZPBgdI4RLFo2BaP1RVuOG2rFg5JBhYvt871HIwmtzNtwXl3_NBwfcqr8O" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "da7694f16def5a056ca96afb21e89a9450e4cc875e2de351da76d99544a3e849", + "x": "GeQ_qA3ZxYoaan3D0nA7xriMcmiMqQ0UNY1DLs7C5kIEaI_RL_2duRcG1Ii6g-8-", + "y": "uKiRr4UU8aXumcA8wu6LOatH0qL2AjFy3_8iBx3mbt1foS5xNHlXchMMLTSCvRLn" + } + ] + }, + "esrp-cts-dev.confidential-ledger.azure.com": { + "keys": [ + { + "kty": "EC", + "crv": "P-384", + "kid": "46cfd71010b47ff5aed2f9df227c64dd1c9d41ff176b361418485128388e1743", + "x": "bhzry10ABDgGDmQXg93mFEwgSSK-ipreAagJQ_Ndr_sJAqc3boJhkuYYhcZtTC6F", + "y": "1KgEY8QcZK7xSKrIb0uYSunrI-uwxfgaGc0AYu2y3SSShTlpBRFUKKgl0-KMjCkL" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "46de8a67a5c6f7973b08ee68ddb055012260f9ce7dcf3bc68441ca51a23557f9", + "x": "56jLddbvtyM_E5wbxt8fvQ2vWUMcUr7FYnk28ffCZdt9wbaje1-u7BSw6iHlnckm", + "y": "YZzrJKJseIb_-q3IcoVQj4np-KafeObbcpIAfAD1Qcf_djsY5MfYWarR0zDGvPgD" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "9d5188c30b7aecf7b41efc036e319539df4ff3f92b5fe73d7421b7b00797efd6", + "x": "aolMQOA93pZGmpx4PNK606dGj9W1TJA7OiV5OGGXRjZHdvweFQAz8UXrOaL3VHhl", + "y": "7E_zBCvi9uYMChth4-te0GjEjBRMWv6puMa5xaZtUhdDFdEr0aYKNTOIjE5kiUXV" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "c655c18e511fa8e7d79f45f1c27feb4f3fd38764bb04ec485f17bc268062c2b1", + "x": "qh-rYFDD_OkPpOlUVvEPoq7WGVqkIp7ZFZ3bRJRiXlYOy72aDTXrXfsbRqE1kG3c", + "y": "jAHU-p01zOWxLpsoGI6WWxdvV5b8prvg260GoQOOUm0tXeSNwvGHid0eGDC3qH6M" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "c67ec820d26a8244870e3bf4fabfae9fae708dd5fad91058b13aa3d84d0c2cf9", + "x": "hwOsdjy-k2i0IzAdVi_CF6wX_VeqngDrC1_W6IVn2TvUsTrhYZdYS2c3Bg7mWbaS", + "y": "-b0KIKgyaBDUtvy3HhCeDtZs0EVcuq1kuyWNXDgemyyf_5zeqn9IWu178aCtxzsZ" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "c99fc3b42033f4773f36a8daf2daa431783ee385f6ad6405121aed144b4a1b8f", + "x": "YdYn3rv7XzOtafJrGx7n9u30tRwJJ1s7blLTzmVOXgU6wqcckucDFYdwT9R6WW_x", + "y": "beg_TRngn8MHtLDJF0vPc694NQxQhb36qAi3P-FInva76N6-N_JviS9SUw0GS7fE" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "cd73d37679fb39218c7e12d24cb443504d8535e783714d5529ebac335e897e85", + "x": "8lbnXdLxieQHMOFvfxQDTXTO8VY2-lrJO2YMKAKGf6A9kMNXaeR4oZEDN6XF5p-h", + "y": "uoJwrb4zUuAYe9CWyXhVJ5e2Fa-EQOihZHTbqEPtU5__kxq00HVvChxiZ5XZ0p47" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "dc5e4e671c3acc13fbb1d601ce84531e6a67c7ca003fe89805533471901f04a7", + "x": "JltCvnallmAFxQAaf7_TnPmS8XHgQCn70cOXte8uAZcr3RWtHYvt5iaOCn6q6EL-", + "y": "wQqGKW2g-FmI8bbMk2DBDaskQkKGgmPs_AV7ac5wU1YxyiEb0DOn_krv1U13IsN_" + } + ] + } +} \ No newline at end of file diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go index 7f10862daa..231771eb9f 100644 --- a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go @@ -344,6 +344,12 @@ func asInt64(v interface{}) (int64, bool) { // 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 diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go index eb4d01f7e6..f2215256aa 100644 --- a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go @@ -39,3 +39,5 @@ 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/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 8060c68489..290c00cd35 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.5.0 -## explicit; go 1.20 +# github.com/Microsoft/cosesign1go v1.6.0 +## explicit; go 1.21 github.com/Microsoft/cosesign1go/pkg/cosesign1 # github.com/Microsoft/didx509go v0.0.3 ## explicit; go 1.20