From 92d9f8374cbca261fe36f571d5968c6925706081 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Wed, 17 Jun 2026 16:55:57 +0000 Subject: [PATCH 01/10] regopolicyinterpreter: Add RegoQueryResult.Array() helper Signed-off-by: Tingmao Wang --- .../regopolicyinterpreter/regopolicyinterpreter.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 { From 511dd075a1390d531735ba9dae6502495835c515 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Fri, 19 Jun 2026 00:00:30 +0000 Subject: [PATCH 02/10] Bump cosesign1go to v1.6.0 This version update is backwards compatible with v1.4.0, so gcs still builds. It adds support for parsing new "SCITT"-style COSESign1 envelops, and the issuer and feed is automatically extracted from their new location in the CWT claims, if we get a new style fragment. It also adds support for parsing receipts and TTLs. Signed-off-by: Tingmao Wang --- go.mod | 2 +- go.sum | 4 +- .../pkg/cosesign1/aci-cc-ttl.ttl.json | 91 +++++++++++++++++ .../cosesign1go/pkg/cosesign1/check.go | 6 ++ .../cosesign1go/pkg/cosesign1/constants.go | 2 + .../cosesign1go/pkg/cosesign1/keyset.go | 98 +++++++++++++++++++ vendor/modules.txt | 4 +- 7 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/aci-cc-ttl.ttl.json create mode 100644 vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/keyset.go 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/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 From 56a19e69bd59954c35a9e7de3dabd0b057baa601 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 8 Jun 2026 00:47:58 +0000 Subject: [PATCH 03/10] rego: Check SVN from CWT header when loading fragment Refactor LoadFragment parameter to add a HeaderSvn field. If the SVN is set in the protected headers, pass it to LoadFragment, which will let framework validate that it is at least the minimum required SVN before loading any fragment Rego code. Assisted-by: GitHub Copilot:auto copilot-review Signed-off-by: Tingmao Wang --- pkg/securitypolicy/framework.rego | 98 ++++++++++++++++++- pkg/securitypolicy/regopolicy_linux_test.go | 62 ++++++------ pkg/securitypolicy/securitypolicy_options.go | 65 ++++++++++-- pkg/securitypolicy/securitypolicyenforcer.go | 17 +++- .../securitypolicyenforcer_rego.go | 18 +++- 5 files changed, 215 insertions(+), 45 deletions(-) diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 76e5b048a0..3db458c5e9 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1260,6 +1260,36 @@ fragment_issuer_feed_ok(fragment) { input.feed == fragment.feed } +header_svn_ok(fragment) { + not input.has_header_svn +} + +header_svn_ok(fragment) { + input.has_header_svn + svn_ok(input.header_svn, fragment.minimum_svn) +} + +svn_ok_if_defined(minimum_svn) { + data[input.namespace].svn # This also works if the svn is 0 + not input.has_header_svn + svn_ok(data[input.namespace].svn, minimum_svn) +} + +svn_ok_if_defined(minimum_svn) { + data[input.namespace].svn + input.has_header_svn + # Use to_number as fragment may define svn as a string + to_number(input.header_svn) == to_number(data[input.namespace].svn) + svn_ok(data[input.namespace].svn, minimum_svn) +} + +# If not defined in fragment, require SVN to present in the header +svn_ok_if_defined(minimum_svn) { + not data[input.namespace].svn + input.has_header_svn + svn_ok(input.header_svn, minimum_svn) +} + default load_fragment := {"allowed": false} # load_fragment gets called twice - first before loading the fragment as a Rego @@ -1267,20 +1297,25 @@ 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) } 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 := { @@ -1810,6 +1845,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 +1873,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 +1921,22 @@ 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["scratch already mounted at path"] { input.rule == "scratch_mount" scratch_mounted(input.target) diff --git a/pkg/securitypolicy/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index 51da87a18c..6fccb7ff09 100644 --- a/pkg/securitypolicy/regopolicy_linux_test.go +++ b/pkg/securitypolicy/regopolicy_linux_test.go @@ -4139,7 +4139,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 +4203,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 +4267,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 +4324,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 +4367,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 +4403,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 +4437,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 +4562,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 +4604,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 +4637,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 +4670,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 +4722,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 +4750,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 @@ -4783,7 +4783,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 +4836,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 +4888,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 +4948,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 +4979,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 +5010,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 +5084,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) } @@ -5125,7 +5125,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 +5175,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 +5205,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 +5241,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 +5283,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 +5949,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 +5986,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..b04a88a9f5 100644 --- a/pkg/securitypolicy/securitypolicy_options.go +++ b/pkg/securitypolicy/securitypolicy_options.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "io" + "math" "os" "path/filepath" "sync" @@ -102,6 +103,30 @@ func (s *SecurityOptions) SetConfidentialOptions(ctx context.Context, enforcerTy return nil } +// asInt64 coerces a CBOR-decoded integer value (which may be returned as +// int64, uint64 or int by different decoders) to an int64. +func asInt64(v interface{}) (int64, 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/ // @@ -134,16 +159,27 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres return fmt.Errorf("InjectFragment failed COSE validation: %w", err) } + cwtClaimsRaw, hasCwtClaims := unpacked.Protected[cosesign1.COSE_Header_CWTClaims] + var cwtClaims map[any]any + if hasCwtClaims { + var ok bool + cwtClaims, ok = cwtClaimsRaw.(map[any]any) + if !ok { + return fmt.Errorf("CWT claims header present, expected it to be a map[any]any, but got %T", cwtClaimsRaw) + } + } + payloadString := string(unpacked.Payload[:]) issuer := unpacked.Issuer feed := unpacked.Feed chainPem := unpacked.ChainPem log.G(ctx).WithFields(logrus.Fields{ - "issuer": issuer, // eg the DID:x509:blah.... - "feed": feed, - "cty": unpacked.ContentType, - "chainPem": chainPem, + "issuer": issuer, // eg the DID:x509:blah.... + "feed": feed, + "cty": unpacked.ContentType, + "chainPem": chainPem, + "cwtClaims": cwtClaims, }).Debugf("unpacked COSE1 cert chain") log.G(ctx).WithFields(logrus.Fields{ @@ -162,10 +198,27 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres return fmt.Errorf("failed to resolve DID: %w", err) } + var svnFromCwt *int64 = nil + if hasCwtClaims { + svnFromCwtRaw, hasSvn := cwtClaims["svn"] + if hasSvn { + svn, err := asInt64(svnFromCwtRaw) + if err != nil { + return errors.Wrap(err, "SVN present in CWT claims, but failed to convert it to int64") + } + svnFromCwt = &svn + } + } + // now offer the payload fragment to the policy - err = s.PolicyEnforcer.LoadFragment(ctx, issuer, feed, payloadString) + err = s.PolicyEnforcer.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: issuer, + Feed: feed, + HeaderSVN: svnFromCwt, + Rego: payloadString, + }) if err != nil { - return fmt.Errorf("error loading security policy fragment: %w", err) + return errors.Wrap(err, "error loading security policy fragment") } return nil } diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index 0c2a98e998..82bc963472 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -33,6 +33,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 +44,16 @@ 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 +} + const ( openDoorEnforcerName = "open_door" ) @@ -125,7 +136,7 @@ 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 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 +303,7 @@ 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 } @@ -425,7 +436,7 @@ 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") } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index 5e196ebd9a..321f01b1e6 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -1088,7 +1088,18 @@ 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) @@ -1106,6 +1117,8 @@ 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, } // Check that the fragment is signed by the expected issuer before loading @@ -1116,7 +1129,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 From 66b9f4000aae35f6448073996dc5c825892b6560 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 8 Jun 2026 00:51:05 +0000 Subject: [PATCH 04/10] Bump framework version to 0.5.0 Signed-off-by: Tingmao Wang --- pkg/securitypolicy/version_framework | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 90ee79c4ef19d778dc6a693516692739e36c3f00 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 8 Jun 2026 16:20:30 +0000 Subject: [PATCH 05/10] regopolicy_linux_test: Add test for header SVN Assisted-by: GitHub Copilot:auto copilot-review Signed-off-by: Tingmao Wang --- pkg/securitypolicy/regopolicy_linux_test.go | 316 ++++++++++++++++++++ 1 file changed, 316 insertions(+) diff --git a/pkg/securitypolicy/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index 6fccb7ff09..3eb0c2ad1e 100644 --- a/pkg/securitypolicy/regopolicy_linux_test.go +++ b/pkg/securitypolicy/regopolicy_linux_test.go @@ -4774,6 +4774,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) From 2dff6b46cec97e1d898cafe9c22df60b580e511d Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Tue, 16 Jun 2026 14:04:32 +0000 Subject: [PATCH 06/10] securitypolicy: add transparency trust list (TTL) enforcement point Let InjectFragment accept an extra media type field, populated by azcri, which will determine if we're handling a Rego policy fragment or a Transparency Trust List (TTL). Backward compatibility is maintained by defaulting to application/cose-x509+rego. Add a new load_transparency_trust_list enforcement point so that whether a TTL is accepted can be gated by policy, and the policy can select which ledgers the TTL can add keys for. The framework uses transparency_trust_lists in a policy / fragments as the set of allowed TTL signers. Use cosesign1.ParseTTLPayload to parse the payload. Bump version_api to 0.12.0. Add apply_defaults for transparency_trust_lists Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- internal/protocol/guestresource/resources.go | 4 + internal/uvm/security_policy.go | 3 +- pkg/ctrdtaskapi/update.go | 6 + pkg/securitypolicy/api.rego | 1 + pkg/securitypolicy/framework.rego | 88 ++++++++- pkg/securitypolicy/open_door.rego | 1 + pkg/securitypolicy/policy.rego | 1 + pkg/securitypolicy/regopolicy_linux_test.go | 174 ++++++++++++++++++ pkg/securitypolicy/securitypolicy_options.go | 46 +++++ pkg/securitypolicy/securitypolicyenforcer.go | 10 + .../securitypolicyenforcer_rego.go | 102 ++++++++++ pkg/securitypolicy/version_api | 2 +- 12 files changed, 435 insertions(+), 3 deletions(-) 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/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 3db458c5e9..6aac1408d4 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 := { @@ -1328,6 +1347,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) { @@ -1937,6 +2004,25 @@ errors["missing fragment svn in either header or rego payload"] { missing_svn } +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) 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 3eb0c2ad1e..e87f06d715 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" @@ -5419,6 +5423,176 @@ 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, issuer, subject, 2, parsedTTL); err != nil { + t.Fatalf("expected TTL to load: %v", err) + } + + if _, ok := policy.ttlKeys[ledger]["kid1"]; !ok { + t.Errorf("expected key for authorized ledger to be stored") + } + if _, ok := policy.ttlKeys["unauthorized.ledger.example"]; ok { + t.Errorf("keys for an unauthorized ledger must not be stored") + } +} + +func Test_Rego_LoadTransparencyTrustList_Wildcard(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{"*"}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + "ledger.one.example": {"kid1": key}, + "ledger.two.example": {"kid2": key}, + } + + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, parsedTTL); err != nil { + t.Fatalf("expected TTL to load: %v", err) + } + + for _, ledger := range []string{"ledger.one.example", "ledger.two.example"} { + if _, ok := policy.ttlKeys[ledger]; !ok { + t.Errorf("expected wildcard root to authorize ledger %s", ledger) + } + } +} + +func Test_Rego_LoadTransparencyTrustList_NoAuthorizedLedgers(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{"only.this.ledger.example"}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + "some.other.ledger.example": {"kid1": key}, + } + + err = policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, parsedTTL) + if err == nil { + t.Fatalf("expected TTL load to be denied when no ledgers are authorized") + } + if len(policy.ttlKeys) != 0 { + t.Errorf("no keys should be stored when the TTL is denied") + } +} + +func Test_Rego_LoadTransparencyTrustList_SVNBelowMinimum(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 5, []string{ledger}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + } + + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 4, parsedTTL); err == nil { + t.Fatalf("expected TTL load to be denied when svn is below the minimum") + } +} + +func Test_Rego_LoadTransparencyTrustList_MergeOverride(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{ledger}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + firstKey := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": firstKey}, + }); err != nil { + t.Fatalf("expected first TTL to load: %v", err) + } + + // A second TTL for the same ledger that adds a new kid and overrides the + // existing one with a different key. + secondKey := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": secondKey, "kid2": firstKey}, + }); err != nil { + t.Fatalf("expected second TTL to load: %v", err) + } + + eq := policy.ttlKeys[ledger]["kid1"].(interface{ Equal(crypto.PublicKey) bool }) + if !eq.Equal(secondKey) { + t.Errorf("expected kid1 to be overridden with the newer key") + } + if _, ok := policy.ttlKeys[ledger]["kid2"]; !ok { + t.Errorf("expected kid2 to be merged in") + } +} + func Test_Rego_LoadFragment_BadIssuer_AttemptOverrideFrameworkItems(t *testing.T) { f := func(p *generatedConstraints) bool { tc, err := setupSimpleRegoFragmentTestConfig(p) diff --git a/pkg/securitypolicy/securitypolicy_options.go b/pkg/securitypolicy/securitypolicy_options.go index b04a88a9f5..8284958823 100644 --- a/pkg/securitypolicy/securitypolicy_options.go +++ b/pkg/securitypolicy/securitypolicy_options.go @@ -103,6 +103,17 @@ 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) { @@ -140,6 +151,22 @@ func asInt64(v interface{}) (int64, error) { func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestresource.SecurityPolicyFragment) (err error) { log.G(ctx).WithField("fragment", fmt.Sprintf("%+v", fragment)).Debug("VerifyAndExtractFragment") + // An empty media type defaults to a Rego policy fragment, for backward + // compatibility with older hosts that do not set the field. + mediaType := fragment.MediaType + if mediaType == "" { + mediaType = mediaTypeFragment + } + switch mediaType { + case mediaTypeFragment, mediaTypeTransparencyTrustList: + default: + // The host (azcri) only ever injects blobs whose media type it knows + // we handle, so receiving an unrecognized one means either a host bug + // or a newer host paired with an older guest. Fail loudly rather than + // silently ignoring it; a failed injection is non-fatal to the host. + return fmt.Errorf("cannot inject fragment blob with unsupported media type %q", mediaType) + } + raw, err := base64.StdEncoding.DecodeString(fragment.Fragment) if err != nil { return fmt.Errorf("failed to decode fragment: %w", err) @@ -210,6 +237,25 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres } } + if mediaType == mediaTypeTransparencyTrustList { + // A TTL must carry its SVN in the COSE header; there is nowhere else for + // it to go. + if svnFromCwt == nil { + return fmt.Errorf("transparency trust list is missing an SVN in its CWT claims") + } + + parsedTTL, err := cosesign1.ParseTTLPayload(unpacked.Payload) + if err != nil { + return errors.Wrap(err, "failed to parse transparency trust list payload") + } + + // feed is the "subject" in the new-style envelope terminology. + if err := s.PolicyEnforcer.LoadTransparencyTrustList(ctx, issuer, feed, *svnFromCwt, parsedTTL); err != nil { + return errors.Wrap(err, "error loading transparency trust list") + } + return nil + } + // now offer the payload fragment to the policy err = s.PolicyEnforcer.LoadFragment(ctx, LoadFragmentOptions{ Issuer: issuer, diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index 82bc963472..c381b3d1bd 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -2,6 +2,7 @@ package securitypolicy import ( "context" + "crypto" "fmt" "syscall" @@ -137,6 +138,7 @@ type SecurityPolicyEnforcer interface { EnforceDumpStacksPolicy(ctx context.Context) error EnforceRuntimeLoggingPolicy(ctx context.Context) (err error) LoadFragment(ctx context.Context, opts LoadFragmentOptions) error + LoadTransparencyTrustList(ctx context.Context, issuer string, subject string, svn int64, parsedTTL map[string]map[string]crypto.PublicKey) error EnforceScratchMountPolicy(ctx context.Context, scratchPath string, encrypted bool) (err error) EnforceScratchUnmountPolicy(ctx context.Context, scratchPath string) (err error) GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) @@ -307,6 +309,10 @@ func (OpenDoorSecurityPolicyEnforcer) LoadFragment(context.Context, LoadFragment return nil } +func (OpenDoorSecurityPolicyEnforcer) LoadTransparencyTrustList(context.Context, string, string, int64, map[string]map[string]crypto.PublicKey) error { + return nil +} + func (OpenDoorSecurityPolicyEnforcer) ExtendDefaultMounts([]oci.Mount) error { return nil } @@ -440,6 +446,10 @@ func (ClosedDoorSecurityPolicyEnforcer) LoadFragment(context.Context, LoadFragme return errors.New("loading fragments is denied by policy") } +func (ClosedDoorSecurityPolicyEnforcer) LoadTransparencyTrustList(context.Context, string, string, int64, map[string]map[string]crypto.PublicKey) error { + return errors.New("loading transparency trust lists is denied by policy") +} + func (ClosedDoorSecurityPolicyEnforcer) ExtendDefaultMounts(_ []oci.Mount) error { return nil } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index 321f01b1e6..f781620b3f 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" @@ -61,6 +62,11 @@ type regoEnforcer struct { osType string // Mutex to ensure only one transaction is active transactionLock sync.Mutex + // ttlKeysLock guards access to ttlKeys. + ttlKeysLock sync.Mutex + // ttlKeys holds the receipt-signing keys learned from loaded Transparency + // Trust Lists, keyed by ledger name (receipt issuer) and then by key id. + ttlKeys map[string]map[string]crypto.PublicKey } var _ SecurityPolicyEnforcer = (*regoEnforcer)(nil) @@ -159,6 +165,7 @@ func newRegoPolicy(code string, defaultMounts []oci.Mount, privilegedMounts []oc return nil, err } policy.stdio = map[string]bool{} + policy.ttlKeys = map[string]map[string]crypto.PublicKey{} policy.base64policy = "" policy.rego.AddModule("framework.rego", &rpi.RegoModule{Namespace: "framework", Code: FrameworkCode}) @@ -1153,6 +1160,76 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentO return nil } +// 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, issuer string, subject string, svn int64, parsedTTL map[string]map[string]crypto.PublicKey) error { + ledgers := make([]string, 0, len(parsedTTL)) + for ledger := range parsedTTL { + ledgers = append(ledgers, ledger) + } + + input := inputData{ + "issuer": issuer, + "subject": subject, + "svn": svn, + "ledgers": ledgers, + } + + results, err := policy.enforce(ctx, "load_transparency_trust_list", input) + if err != nil { + return err + } + + allowedLedgersRaw, err := results.Array("allowed_ledgers") + if err != nil { + return errors.Wrap(err, "unable to get allowed_ledgers from load_transparency_trust_list result") + } + + if len(allowedLedgersRaw) == 0 { + return errors.New("transparency trust list carries no ledgers authorized by the policy") + } + + allowedLedgers := make([]string, 0, len(allowedLedgersRaw)) + for _, l := range allowedLedgersRaw { + ledger, ok := l.(string) + if !ok { + return fmt.Errorf("Elements of result.allowed_ledgers must be strings, got %T", l) + } + allowedLedgers = append(allowedLedgers, ledger) + } + + policy.ttlKeysLock.Lock() + defer policy.ttlKeysLock.Unlock() + for _, ledger := range allowedLedgers { + newKeys := parsedTTL[ledger] + existingKeys, ok := policy.ttlKeys[ledger] + if !ok { + existingKeys = make(map[string]crypto.PublicKey, len(newKeys)) + policy.ttlKeys[ledger] = existingKeys + } + for kid, pk := range newKeys { + if existingKey, exists := existingKeys[kid]; exists { + // Equal is implemented for all crypto.PublicKey types in std. + eq, ok := existingKey.(interface{ Equal(crypto.PublicKey) bool }) + if !ok || !eq.Equal(pk) { + log.G(ctx).Warnf("TTL for ledger %s overrides existing key with id %s with a different key", ledger, kid) + existingKeys[kid] = pk + } + } else { + existingKeys[kid] = pk + } + } + } + + log.G(ctx).Infof("Loaded TTL with subject %s signed by %s with keys for ledgers: %v", subject, issuer, allowedLedgers) + return nil +} + func (policy *regoEnforcer) EnforceScratchMountPolicy(ctx context.Context, scratchPath string, encrypted bool) error { input := map[string]interface{}{ "target": scratchPath, @@ -1225,14 +1302,39 @@ func (policy *regoEnforcer) WithMetadataRollback(fn func() error) error { return errors.Wrap(err, "failed to snapshot policy metadata") } + // The TTL key store is Go-side enforcer state, not Rego metadata, so it is + // not covered by SaveMetadata/RestoreMetadata. Snapshot it here so it is + // rolled back alongside the metadata if fn fails. We only copy the per-ledger + // map references, not deep-copy the crypto.PublicKey values. + savedTTLKeys := policy.snapshotTTLKeys() + err = fn() if err != nil { if restoreErr := policy.rego.RestoreMetadata(saved); restoreErr != nil { panic(fmt.Sprintf("failed to rollback policy metadata: %v (caused by error: %v)", restoreErr, err)) } + policy.ttlKeysLock.Lock() + policy.ttlKeys = savedTTLKeys + policy.ttlKeysLock.Unlock() log.G(context.Background()).WithError(err).Warn("rolled back policy metadata due to error") return err } return nil } + +// snapshotTTLKeys returns a shallow copy of the TTL key store: the outer and +// inner maps are copied, but the crypto.PublicKey values are shared. +func (policy *regoEnforcer) snapshotTTLKeys() map[string]map[string]crypto.PublicKey { + policy.ttlKeysLock.Lock() + defer policy.ttlKeysLock.Unlock() + snapshot := make(map[string]map[string]crypto.PublicKey, len(policy.ttlKeys)) + for ledger, keys := range policy.ttlKeys { + keysCopy := make(map[string]crypto.PublicKey, len(keys)) + for kid, pk := range keys { + keysCopy[kid] = pk + } + snapshot[ledger] = keysCopy + } + return snapshot +} diff --git a/pkg/securitypolicy/version_api b/pkg/securitypolicy/version_api index d9df1bbc0c..ac454c6a1f 100644 --- a/pkg/securitypolicy/version_api +++ b/pkg/securitypolicy/version_api @@ -1 +1 @@ -0.11.0 +0.12.0 From 2cf640e4d914db180eb282cb74e5f9341d5187a9 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Tue, 16 Jun 2026 14:21:48 +0000 Subject: [PATCH 07/10] securitypolicy: require transparency receipts for fragments Fragments may now declare receipt_issuers, a list of ledgers from which a valid transparency receipt, verifiable with loaded TTLs, must be present on the fragment's COSE envelope before the fragment is allowed to load. Add a Receipts field to LoadFragmentOptions, populated from the COSE envelope in InjectFragment. The Rego enforcer validates each receipt against the keys for its claimed issuer only, so a ledger cannot sign a receipt pretending to be a different ledger, and passes the set of validated issuers to the policy as input.receipt_issuers in both load_fragment phases. framework.rego load_fragment now checks receipt_issuers against input.receipt_issuers and denies with 'missing receipt from '; check_fragment defaults the field to [] so older policies are unaffected. Receipt validation is behind a swappable closure so tests can exercise the logic without a real CCF receipt. Signed-off-by: Tingmao Wang --- pkg/securitypolicy/framework.rego | 30 ++ pkg/securitypolicy/regopolicy_linux_test.go | 275 ++++++++++++++++++ pkg/securitypolicy/securitypolicy_options.go | 1 + pkg/securitypolicy/securitypolicyenforcer.go | 5 + .../securitypolicyenforcer_rego.go | 44 +++ 5 files changed, 355 insertions(+) diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 6aac1408d4..53dee7b00c 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1309,6 +1309,17 @@ svn_ok_if_defined(minimum_svn) { svn_ok(input.header_svn, minimum_svn) } +# A fragment rule may require transparency receipts from one or more ledgers +# (the receipt issuers). input.receipt_issuers is the set of ledgers for which +# the enforcer successfully validated a receipt attached to this fragment. If +# not set, no receipts are required. +fragment_receipts_ok(fragment) { + required := object.get(fragment, "receipt_issuers", []) + every required_issuer in required { + required_issuer in input.receipt_issuers + } +} + default load_fragment := {"allowed": false} # load_fragment gets called twice - first before loading the fragment as a Rego @@ -1326,6 +1337,7 @@ load_fragment := {"allowed": true} { 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} { @@ -2004,6 +2016,17 @@ errors["missing fragment svn in either header or rego payload"] { 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 required_issuer in input.receipt_issuers + receipt_error := sprintf("missing receipt from %s", [required_issuer]) +} + default transparency_root_matches := false transparency_root_matches { @@ -2509,6 +2532,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/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index e87f06d715..6ce39d792a 100644 --- a/pkg/securitypolicy/regopolicy_linux_test.go +++ b/pkg/securitypolicy/regopolicy_linux_test.go @@ -27,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" @@ -5593,6 +5595,279 @@ func Test_Rego_LoadTransparencyTrustList_MergeOverride(t *testing.T) { } } +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, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // Mock receipt validation: assert the enforcer only ever offers us the keys + // belonging to the receipt's own claimed issuer, then accept. + validateCalled := false + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + validateCalled = true + if receipt.Issuer != ledger { + t.Errorf("validate called with unexpected issuer %q", receipt.Issuer) + } + if _, ok := keys["kid1"]; !ok || len(keys) != 1 { + t.Errorf("validate offered the wrong key set: %v", keys) + } + return nil + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: ledger, Kid: "kid1"}}, + }) + if err != nil { + t.Fatalf("expected fragment with a valid receipt to load: %v", err) + } + if !validateCalled { + t.Errorf("expected receipt validation to be invoked") + } +} + +func Test_Rego_LoadFragment_ReceiptWrongIssuer(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + requiredLedger := "required.ledger.example" + otherLedger := "other.ledger.example" + + // The TTL only authorizes otherLedger, and the fragment requires a receipt + // from requiredLedger. The attached receipt claims to be from otherLedger. + policy, err := newRegoPolicy( + receiptFragmentPolicy(ttlIssuer, ttlSubject, otherLedger, fragIssuer, fragFeed, requiredLedger), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + otherLedger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // Even though the mock would accept the receipt, its issuer is otherLedger, + // not the requiredLedger, so the requirement is not satisfied. + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return nil + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: otherLedger, Kid: "kid1"}}, + }) + if err == nil { + t.Fatalf("expected fragment load to be denied: receipt issuer does not match the requirement") + } + if !assertDecisionJSONContains(t, err, fmt.Sprintf("missing receipt from %s", requiredLedger)) { + t.Fatalf("expected denial reason to mention the missing receipt, got: %v", err) + } +} + +func Test_Rego_LoadFragment_ReceiptValidationFails(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy( + receiptFragmentPolicy(ttlIssuer, ttlSubject, ledger, fragIssuer, fragFeed, ledger), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // A receipt whose cryptographic validation fails must not count. + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return errors.New("bad signature") + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: ledger, Kid: "kid1"}}, + }) + if err == nil { + t.Fatalf("expected fragment load to be denied: receipt failed validation") + } + if !assertDecisionJSONContains(t, err, fmt.Sprintf("missing receipt from %s", ledger)) { + t.Fatalf("expected denial reason to mention the missing receipt, got: %v", err) + } +} + func Test_Rego_LoadFragment_BadIssuer_AttemptOverrideFrameworkItems(t *testing.T) { f := func(p *generatedConstraints) bool { tc, err := setupSimpleRegoFragmentTestConfig(p) diff --git a/pkg/securitypolicy/securitypolicy_options.go b/pkg/securitypolicy/securitypolicy_options.go index 8284958823..e43f84857a 100644 --- a/pkg/securitypolicy/securitypolicy_options.go +++ b/pkg/securitypolicy/securitypolicy_options.go @@ -262,6 +262,7 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres Feed: feed, HeaderSVN: svnFromCwt, Rego: payloadString, + Receipts: unpacked.Receipts, }) if err != nil { return errors.Wrap(err, "error loading security policy fragment") diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index c381b3d1bd..ffc547a75d 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -6,6 +6,7 @@ import ( "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" @@ -53,6 +54,10 @@ type LoadFragmentOptions struct { HeaderSVN *int64 // Rego is the fragment's Rego payload. Rego string + // Receipts are the COSE transparency receipts attached to the fragment's + // COSE envelope, if any. Validation is handled by the enforcer, caller + // does not have to validate them. + Receipts []cosesign1.ParsedCOSEReceipt } const ( diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index f781620b3f..cb30ed1eaa 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -16,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" @@ -67,6 +68,10 @@ type regoEnforcer struct { // ttlKeys holds the receipt-signing keys learned from loaded Transparency // Trust Lists, keyed by ledger name (receipt issuer) and then by key id. ttlKeys map[string]map[string]crypto.PublicKey + // validateReceipt validates a single transparency receipt against the given + // keys. It defaults to (cosesign1.ParsedCOSEReceipt).Validate and exists as + // a field so tests can substitute a mock. + validateReceipt func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error } var _ SecurityPolicyEnforcer = (*regoEnforcer)(nil) @@ -166,6 +171,9 @@ func newRegoPolicy(code string, defaultMounts []oci.Mount, privilegedMounts []oc } policy.stdio = map[string]bool{} policy.ttlKeys = map[string]map[string]crypto.PublicKey{} + policy.validateReceipt = func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return receipt.Validate(keys) + } policy.base64policy = "" policy.rego.AddModule("framework.rego", &rpi.RegoModule{Namespace: "framework", Code: FrameworkCode}) @@ -1112,6 +1120,34 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentO return fmt.Errorf("unable to load fragment: %w", err) } + // Validate each attached transparency receipt against the keys we have for + // its claimed issuer (ledger), learned from previously loaded TTLs. We only + // ever offer Validate the keys belonging to the receipt's own claimed + // issuer, so a ledger cannot sign a receipt while pretending to be a + // different ledger. The set of issuers for which we successfully validated a + // receipt is then passed to the policy as input.receipt_issuers. + receiptIssuersSet := make(map[string]struct{}) + policy.ttlKeysLock.Lock() + for _, receipt := range opts.Receipts { + keys, ok := policy.ttlKeys[receipt.Issuer] + if !ok { + // We have no TTL keys for this issuer, so we cannot validate the + // receipt. Ignore it. + log.G(ctx).WithField("issuer", receipt.Issuer).Debug("skipping fragment receipt: no TTL keys for claimed issuer") + continue + } + if err := policy.validateReceipt(receipt, keys); err != nil { + log.G(ctx).WithError(err).WithField("issuer", receipt.Issuer).Error("fragment receipt failed validation") + continue + } + receiptIssuersSet[receipt.Issuer] = struct{}{} + } + policy.ttlKeysLock.Unlock() + receiptIssuers := make([]string, 0, len(receiptIssuersSet)) + for issuer := range receiptIssuersSet { + receiptIssuers = append(receiptIssuers, issuer) + } + fragment := &rpi.RegoModule{ Issuer: issuer, Feed: feed, @@ -1126,6 +1162,7 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentO "fragment_loaded": false, "has_header_svn": headerSvn != nil, "header_svn": headerSvn, + "receipt_issuers": receiptIssuers, } // Check that the fragment is signed by the expected issuer before loading @@ -1160,6 +1197,13 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentO 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 From 13e62bafbf16c3ae8ddc5285866f7f63b701fad9 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Fri, 19 Jun 2026 10:13:38 +0000 Subject: [PATCH 08/10] Log a Span for securitypolicy::InjectFragment Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- pkg/securitypolicy/securitypolicy_options.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/securitypolicy/securitypolicy_options.go b/pkg/securitypolicy/securitypolicy_options.go index e43f84857a..314d3a3d90 100644 --- a/pkg/securitypolicy/securitypolicy_options.go +++ b/pkg/securitypolicy/securitypolicy_options.go @@ -15,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 { @@ -149,7 +151,10 @@ func asInt64(v interface{}) (int64, error) { // 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. From 6f8623b832144a38cc75e2a9ead3172daf8667c0 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Fri, 19 Jun 2026 10:06:23 +0000 Subject: [PATCH 09/10] Address review comments Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- pkg/securitypolicy/framework.rego | 7 ++ pkg/securitypolicy/regopolicy_linux_test.go | 73 +++++++++++++++---- pkg/securitypolicy/securitypolicy_options.go | 64 ++++++++++++---- pkg/securitypolicy/securitypolicyenforcer.go | 18 ++++- .../securitypolicyenforcer_rego.go | 14 ++-- 5 files changed, 139 insertions(+), 37 deletions(-) diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 53dee7b00c..334b5876a1 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1279,6 +1279,13 @@ 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 } diff --git a/pkg/securitypolicy/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index 6ce39d792a..b4ec1d97e9 100644 --- a/pkg/securitypolicy/regopolicy_linux_test.go +++ b/pkg/securitypolicy/regopolicy_linux_test.go @@ -5475,7 +5475,12 @@ func Test_Rego_LoadTransparencyTrustList(t *testing.T) { "unauthorized.ledger.example": {"kid2": key}, } - if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 2, parsedTTL); err != nil { + if err := policy.LoadTransparencyTrustList(ctx, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subject, + SVN: 2, + ParsedTTL: parsedTTL, + }); err != nil { t.Fatalf("expected TTL to load: %v", err) } @@ -5503,7 +5508,12 @@ func Test_Rego_LoadTransparencyTrustList_Wildcard(t *testing.T) { "ledger.two.example": {"kid2": key}, } - if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, parsedTTL); err != nil { + if err := policy.LoadTransparencyTrustList(ctx, LoadTransparencyTrustListOptions{ + Issuer: issuer, + Subject: subject, + SVN: 1, + ParsedTTL: parsedTTL, + }); err != nil { t.Fatalf("expected TTL to load: %v", err) } @@ -5529,7 +5539,12 @@ func Test_Rego_LoadTransparencyTrustList_NoAuthorizedLedgers(t *testing.T) { "some.other.ledger.example": {"kid1": key}, } - err = policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, parsedTTL) + 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") } @@ -5554,7 +5569,12 @@ func Test_Rego_LoadTransparencyTrustList_SVNBelowMinimum(t *testing.T) { ledger: {"kid1": key}, } - if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 4, parsedTTL); err == nil { + 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") } } @@ -5571,8 +5591,13 @@ func Test_Rego_LoadTransparencyTrustList_MergeOverride(t *testing.T) { } firstKey := generateTestECDSAKey(t) - if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, map[string]map[string]crypto.PublicKey{ - ledger: {"kid1": firstKey}, + 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) } @@ -5580,8 +5605,13 @@ func Test_Rego_LoadTransparencyTrustList_MergeOverride(t *testing.T) { // A second TTL for the same ledger that adds a new kid and overrides the // existing one with a different key. secondKey := generateTestECDSAKey(t) - if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, map[string]map[string]crypto.PublicKey{ - ledger: {"kid1": secondKey, "kid2": firstKey}, + 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) } @@ -5739,8 +5769,13 @@ func Test_Rego_LoadFragment_ValidReceipt(t *testing.T) { } key := generateTestECDSAKey(t) - if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ - ledger: {"kid1": key}, + 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) } @@ -5795,8 +5830,13 @@ func Test_Rego_LoadFragment_ReceiptWrongIssuer(t *testing.T) { } key := generateTestECDSAKey(t) - if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ - otherLedger: {"kid1": key}, + 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) } @@ -5840,8 +5880,13 @@ func Test_Rego_LoadFragment_ReceiptValidationFails(t *testing.T) { } key := generateTestECDSAKey(t) - if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ - ledger: {"kid1": key}, + 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) } diff --git a/pkg/securitypolicy/securitypolicy_options.go b/pkg/securitypolicy/securitypolicy_options.go index 314d3a3d90..1b568ba27e 100644 --- a/pkg/securitypolicy/securitypolicy_options.go +++ b/pkg/securitypolicy/securitypolicy_options.go @@ -242,7 +242,8 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres } } - if mediaType == mediaTypeTransparencyTrustList { + 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 { @@ -255,23 +256,58 @@ func (s *SecurityOptions) InjectFragment(ctx context.Context, fragment *guestres } // feed is the "subject" in the new-style envelope terminology. - if err := s.PolicyEnforcer.LoadTransparencyTrustList(ctx, issuer, feed, *svnFromCwt, parsedTTL); err != nil { + 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") } - return nil - } - // now offer the payload fragment to the policy - err = s.PolicyEnforcer.LoadFragment(ctx, LoadFragmentOptions{ - Issuer: issuer, - Feed: feed, - HeaderSVN: svnFromCwt, - Rego: payloadString, - Receipts: unpacked.Receipts, - }) - if err != nil { - return errors.Wrap(err, "error loading security policy fragment") + 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 ffc547a75d..b182f67455 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -60,6 +60,18 @@ type LoadFragmentOptions struct { 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" ) @@ -143,7 +155,7 @@ type SecurityPolicyEnforcer interface { EnforceDumpStacksPolicy(ctx context.Context) error EnforceRuntimeLoggingPolicy(ctx context.Context) (err error) LoadFragment(ctx context.Context, opts LoadFragmentOptions) error - LoadTransparencyTrustList(ctx context.Context, issuer string, subject string, svn int64, parsedTTL map[string]map[string]crypto.PublicKey) error + 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) @@ -314,7 +326,7 @@ func (OpenDoorSecurityPolicyEnforcer) LoadFragment(context.Context, LoadFragment return nil } -func (OpenDoorSecurityPolicyEnforcer) LoadTransparencyTrustList(context.Context, string, string, int64, map[string]map[string]crypto.PublicKey) error { +func (OpenDoorSecurityPolicyEnforcer) LoadTransparencyTrustList(context.Context, LoadTransparencyTrustListOptions) error { return nil } @@ -451,7 +463,7 @@ func (ClosedDoorSecurityPolicyEnforcer) LoadFragment(context.Context, LoadFragme return errors.New("loading fragments is denied by policy") } -func (ClosedDoorSecurityPolicyEnforcer) LoadTransparencyTrustList(context.Context, string, string, int64, map[string]map[string]crypto.PublicKey) error { +func (ClosedDoorSecurityPolicyEnforcer) LoadTransparencyTrustList(context.Context, LoadTransparencyTrustListOptions) error { return errors.New("loading transparency trust lists is denied by policy") } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index cb30ed1eaa..da42bf0249 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -1128,6 +1128,8 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentO // receipt is then passed to the policy as input.receipt_issuers. receiptIssuersSet := make(map[string]struct{}) policy.ttlKeysLock.Lock() + defer policy.ttlKeysLock.Unlock() + for _, receipt := range opts.Receipts { keys, ok := policy.ttlKeys[receipt.Issuer] if !ok { @@ -1142,7 +1144,6 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentO } receiptIssuersSet[receipt.Issuer] = struct{}{} } - policy.ttlKeysLock.Unlock() receiptIssuers := make([]string, 0, len(receiptIssuersSet)) for issuer := range receiptIssuersSet { receiptIssuers = append(receiptIssuers, issuer) @@ -1211,16 +1212,17 @@ func (policy *regoEnforcer) SetReceiptValidationFunction(fn func(receipt cosesig // 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, issuer string, subject string, svn int64, parsedTTL map[string]map[string]crypto.PublicKey) error { +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": issuer, - "subject": subject, - "svn": svn, + "issuer": opts.Issuer, + "subject": opts.Subject, + "svn": opts.SVN, "ledgers": ledgers, } @@ -1270,7 +1272,7 @@ func (policy *regoEnforcer) LoadTransparencyTrustList(ctx context.Context, issue } } - log.G(ctx).Infof("Loaded TTL with subject %s signed by %s with keys for ledgers: %v", subject, issuer, allowedLedgers) + log.G(ctx).Infof("Loaded TTL with subject %s signed by %s with keys for ledgers: %v", opts.Subject, opts.Issuer, allowedLedgers) return nil } From 1dc0ab77add2439dc19476ab9bcc831ff46bcc5e Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Fri, 19 Jun 2026 18:35:18 +0000 Subject: [PATCH 10/10] rego: Add ability to specify "TTL:" and "*" in a fragment's required receipt_issuers list. This allows a fragment to be signed by any ledger keys introduced by specific TTLs, either constraining the eligible TTL's subject, or allow any trusted TTL. Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- pkg/securitypolicy/framework.rego | 42 ++- pkg/securitypolicy/regopolicy_linux_test.go | 295 +++++++++++++++++- .../securitypolicyenforcer_rego.go | 85 +++-- 3 files changed, 391 insertions(+), 31 deletions(-) diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 334b5876a1..da0627f724 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1316,17 +1316,47 @@ svn_ok_if_defined(minimum_svn) { svn_ok(input.header_svn, minimum_svn) } -# A fragment rule may require transparency receipts from one or more ledgers -# (the receipt issuers). input.receipt_issuers is the set of ledgers for which -# the enforcer successfully validated a receipt attached to this fragment. If -# not set, no receipts are required. +# 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 { - required_issuer in input.receipt_issuers + 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 @@ -2030,7 +2060,7 @@ errors[receipt_error] { fragment_issuer_feed_ok(fragment) required := object.get(fragment, "receipt_issuers", []) some required_issuer in required - not required_issuer in input.receipt_issuers + not receipt_requirement_satisfied(required_issuer) receipt_error := sprintf("missing receipt from %s", [required_issuer]) } diff --git a/pkg/securitypolicy/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index b4ec1d97e9..ac7b2ccc50 100644 --- a/pkg/securitypolicy/regopolicy_linux_test.go +++ b/pkg/securitypolicy/regopolicy_linux_test.go @@ -5616,7 +5616,7 @@ func Test_Rego_LoadTransparencyTrustList_MergeOverride(t *testing.T) { t.Fatalf("expected second TTL to load: %v", err) } - eq := policy.ttlKeys[ledger]["kid1"].(interface{ Equal(crypto.PublicKey) bool }) + 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") } @@ -5913,6 +5913,299 @@ func Test_Rego_LoadFragment_ReceiptValidationFails(t *testing.T) { } } +// 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) diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index da42bf0249..726d47a8a6 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -67,13 +67,25 @@ type regoEnforcer struct { ttlKeysLock sync.Mutex // ttlKeys holds the receipt-signing keys learned from loaded Transparency // Trust Lists, keyed by ledger name (receipt issuer) and then by key id. - ttlKeys map[string]map[string]crypto.PublicKey + 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) func toStringSet(items []string) stringSet { @@ -170,7 +182,7 @@ func newRegoPolicy(code string, defaultMounts []oci.Mount, privilegedMounts []oc return nil, err } policy.stdio = map[string]bool{} - policy.ttlKeys = map[string]map[string]crypto.PublicKey{} + policy.ttlKeys = map[string]map[string]TTLKeyEntry{} policy.validateReceipt = func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { return receipt.Validate(keys) } @@ -1124,29 +1136,39 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentO // its claimed issuer (ledger), learned from previously loaded TTLs. We only // ever offer Validate the keys belonging to the receipt's own claimed // issuer, so a ledger cannot sign a receipt while pretending to be a - // different ledger. The set of issuers for which we successfully validated a - // receipt is then passed to the policy as input.receipt_issuers. - receiptIssuersSet := make(map[string]struct{}) + // 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 { - keys, ok := policy.ttlKeys[receipt.Issuer] + 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 } - receiptIssuersSet[receipt.Issuer] = struct{}{} - } - receiptIssuers := make([]string, 0, len(receiptIssuersSet)) - for issuer := range receiptIssuersSet { - receiptIssuers = append(receiptIssuers, issuer) + // 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{ @@ -1163,7 +1185,7 @@ func (policy *regoEnforcer) LoadFragment(ctx context.Context, opts LoadFragmentO "fragment_loaded": false, "has_header_svn": headerSvn != nil, "header_svn": headerSvn, - "receipt_issuers": receiptIssuers, + "receipts": receipts, } // Check that the fragment is signed by the expected issuer before loading @@ -1255,19 +1277,30 @@ func (policy *regoEnforcer) LoadTransparencyTrustList(ctx context.Context, opts newKeys := parsedTTL[ledger] existingKeys, ok := policy.ttlKeys[ledger] if !ok { - existingKeys = make(map[string]crypto.PublicKey, len(newKeys)) + existingKeys = make(map[string]TTLKeyEntry, len(newKeys)) policy.ttlKeys[ledger] = existingKeys } for kid, pk := range newKeys { - if existingKey, exists := existingKeys[kid]; exists { + if existingEntry, exists := existingKeys[kid]; exists { // Equal is implemented for all crypto.PublicKey types in std. - eq, ok := existingKey.(interface{ Equal(crypto.PublicKey) bool }) - if !ok || !eq.Equal(pk) { + 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] = pk + existingKeys[kid] = TTLKeyEntry{key: pk, offeredBy: []string{opts.Subject}} } } else { - existingKeys[kid] = pk + existingKeys[kid] = TTLKeyEntry{key: pk, offeredBy: []string{opts.Subject}} } } } @@ -1370,15 +1403,19 @@ func (policy *regoEnforcer) WithMetadataRollback(fn func() error) error { } // snapshotTTLKeys returns a shallow copy of the TTL key store: the outer and -// inner maps are copied, but the crypto.PublicKey values are shared. -func (policy *regoEnforcer) snapshotTTLKeys() map[string]map[string]crypto.PublicKey { +// 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]crypto.PublicKey, len(policy.ttlKeys)) + snapshot := make(map[string]map[string]TTLKeyEntry, len(policy.ttlKeys)) for ledger, keys := range policy.ttlKeys { - keysCopy := make(map[string]crypto.PublicKey, len(keys)) - for kid, pk := range keys { - keysCopy[kid] = pk + 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 }