From 0fe5f8897a75b5f96c3a913e2338755762193fbd Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Wed, 17 Jun 2026 16:55:57 +0000 Subject: [PATCH 1/7] 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 48f55e3505583aec4eedca2ed77bd2d75ec965b4 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Sun, 7 Jun 2026 22:19:48 +0000 Subject: [PATCH 2/7] Bump cosesign1go to v1.6.0-alpha1 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, which we will need later. Signed-off-by: Tingmao Wang --- go.mod | 2 +- go.sum | 4 + .../cosesign1go/pkg/cosesign1/Makefile | 36 +- .../cosesign1go/pkg/cosesign1/Makefile.certs | 4 +- .../cosesign1go/pkg/cosesign1/check.go | 399 +++++++++++++++++- .../cosesign1go/pkg/cosesign1/constants.go | 43 ++ .../cosesign1/esrp_cp_ledger_pub_keys.json | 11 + .../cosesign1/esrp_db_ledger_pub_keys.json | 18 + .../cosesign1go/pkg/cosesign1/keyset.go | 98 +++++ vendor/modules.txt | 4 +- 10 files changed, 594 insertions(+), 25 deletions(-) create mode 100644 vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go create mode 100644 vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_cp_ledger_pub_keys.json create mode 100644 vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_db_ledger_pub_keys.json create mode 100644 vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/keyset.go diff --git a/go.mod b/go.mod index c12fab7e1a..c1a5cd1550 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ tool ( ) require ( - github.com/Microsoft/cosesign1go v1.4.0 + github.com/Microsoft/cosesign1go v1.6.0-alpha1 github.com/Microsoft/didx509go v0.0.3 github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 github.com/blang/semver/v4 v4.0.0 diff --git a/go.sum b/go.sum index ce144cee6c..56b378b958 100644 --- a/go.sum +++ b/go.sum @@ -364,6 +364,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/Microsoft/cosesign1go v1.4.0 h1:VdiqzsilEE6t1GQi98I/h0WpVFM7AyMEeyP8ud7V/BY= github.com/Microsoft/cosesign1go v1.4.0/go.mod h1:1La/HcGw19rRLhPW0S6u55K6LKfti+GQSgGCtrfhVe8= +github.com/Microsoft/cosesign1go v1.5.0 h1:YmQCF8z7dGp50Rp/+rLTLFOFgIfZ1GSUHXPgLLlOlNk= +github.com/Microsoft/cosesign1go v1.5.0/go.mod h1:s7E3nBWxb//ZLhuLAU5u9EZ1qMGBdgZzrKIUW1H/OIY= +github.com/Microsoft/cosesign1go v1.6.0-alpha1 h1:hFyLTdNp9JCBbeRCx7DGwS6secReX4t9JzlIGHTiz6w= +github.com/Microsoft/cosesign1go v1.6.0-alpha1/go.mod h1:7x+fdYtZ4ureEgfVtl2K+nY4MMfujMsCIb5kRuncpmg= github.com/Microsoft/didx509go v0.0.3 h1:n/owuFOXVzCEzSyzivMEolKEouBm9G0NrEDgoTekM8A= github.com/Microsoft/didx509go v0.0.3/go.mod h1:wWt+iQsLzn3011+VfESzznLIp/Owhuj7rLF7yLglYbk= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile index 8c982132c2..6527ca105d 100644 --- a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile @@ -24,12 +24,11 @@ # -# note test-fail is expected to fail - -AUTOPARSE_CHAIN:=0 +# Opaque label written into the COSE `iss` header. It is NOT validated against +# the chain at create time; the Go unit test asserts the round-trip preserves +# this exact string, so don't change it without updating the test. ISSUER_DID:="TestIssuer" FEED:="TestFeed" -DID_FINGERPRINT:="" all: chain.pem cose test-fail test-pass @@ -38,11 +37,6 @@ cose: infra.rego.cose %.pem: $(MAKE) -f Makefile.certs chain.pem -ifeq "$(AUTOPARSE_CHAIN)" "1" -ISSUER_DID = $(shell ./sign1util did-x509 -chain chain.pem -policy cn) -DID_FINGERPRINT = $(shell ./sign1util did-x509 -chain chain.pem -policy cn | cut -d: -f5) -endif - # from these media types have to match containerd. The also need to change and the security policy one ought to be x-ms-ccepolicy-frag # fragment atrifact type = application/x-ms-ccepolicy-frag # fragment media type = application/cose-x509+rego @@ -74,13 +68,13 @@ show: sign1util didx509: chain.pem sign1util ./sign1util did-x509 -chain chain.pem -i 1 -policy "subject:CN:Test Leaf (DO NOT TRUST)" -verbose -info: chain.pem sign1util - @echo "ISSUER_DID: $(ISSUER_DID)" - @echo "DID_FINGERPRINT: $(DID_FINGERPRINT)" - -# for this to pass the did:x509 fingerprint (RgpNsHOK5hPlCAfTtiGY_BcDhFRxQbJnhlxNDhxps6U here) needs to be the one output from make print -did-check: chain.pem infra.rego.cose sign1util info - ./sign1util check -in infra.rego.cose -did $(ISSUER_DID) +# did-check derives the REAL did:x509 from chain.pem at run time and resolves +# it against the chain. Fails loudly if did-x509 returns empty. +did-check: chain.pem infra.rego.cose sign1util + @did="$$(./sign1util did-x509 -chain chain.pem -policy cn)"; \ + test -n "$$did" || { echo "did-x509 returned empty - check chain.pem"; exit 1; }; \ + echo "did-check: using did=$$did"; \ + ./sign1util check -in infra.rego.cose -did "$$did" # For normal workflow start from the chain.pem, here we'd take the chain from inside the cose sign1 doc, eg to manually confirm it is # as otherwise expected (ie that the issuer DID matches the chain) or to shortcut getting a DID from a cose document. @@ -92,12 +86,18 @@ did-from-cose: sign1util infra.rego.cose # note that since the infra.rego.cose is actually good the first part of the check will report a pass "checkCoseSign1 passed" # expect "DID resolvers failed: err: DID verification failed: unexpected certificate fingerprint" +# The recipe is expected to fail at the tool level; invert the exit code so the make target succeeds. did-fail-fingerprint: chain.pem sign1util infra.rego.cose - ./sign1util check -in infra.rego.cose -did did:x509:0:sha256:XXXi_nuWegx4NiLaeGabiz36bDUhDDiHEFl8HXMA_4o::subject:CN:Test+Leaf+%28DO+NOT+TRUST%29 + ! ./sign1util check -in infra.rego.cose -did did:x509:0:sha256:XXXi_nuWegx4NiLaeGabiz36bDUhDDiHEFl8HXMA_4o::subject:CN:Test+Leaf+%28DO+NOT+TRUST%29 # expect "DID resolvers failed: err: DID verification failed: invalid subject value: CN=Test XXXX (DO NOT TRUST)" +# Builds a DID with the REAL fingerprint but a WRONG subject; recipe must still +# fail at the tool level - `!` inverts that into a make-level success. did-fail-subject: chain.pem sign1util infra.rego.cose - ./sign1util check -in infra.rego.cose -did did:x509:0:sha256:$(DID_FINGERPRINT)::subject:CN:Test+XXXX+%28DO+NOT+TRUST%29 + @fp="$$(./sign1util did-x509 -chain chain.pem -policy cn | cut -d: -f5)"; \ + test -n "$$fp" || { echo "could not derive fingerprint - check chain.pem"; exit 1; }; \ + ! ./sign1util check -in infra.rego.cose \ + -did "did:x509:0:sha256:$$fp::subject:CN:Test+XXXX+%28DO+NOT+TRUST%29" did-fail: did-fail-subject did-fail-fingerprint diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile.certs b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile.certs index 9a0ce35257..e2452a7728 100644 --- a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile.certs +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/Makefile.certs @@ -8,12 +8,12 @@ all: chain.pem root.cert.pem: root.private.pem openssl req -new -key $< -out $@.tmp.csr -subj "/CN=Test Root CA (DO NOT TRUST)" -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=digitalSignature,keyCertSign' - openssl x509 -req -days 365 -in $@.tmp.csr -signkey $< -out $@ -CAcreateserial -extfile cert.extensions.cfg + openssl x509 -req -days 3650 -in $@.tmp.csr -signkey $< -out $@ -CAcreateserial -extfile cert.extensions.cfg rm -rf $@.tmp.csr intermediate.cert.pem: intermediate.private.pem | root.private.pem openssl req -new -key $< -out $@.tmp.csr -subj "/CN=Test Intermediate CA (DO NOT TRUST)" -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=digitalSignature,keyCertSign' - openssl x509 -req -days 365 -in $@.tmp.csr -CA ${subst private,cert,$|} -CAkey $| -out $@ -CAcreateserial -extfile cert.extensions.cfg + openssl x509 -req -days 1825 -in $@.tmp.csr -CA ${subst private,cert,$|} -CAkey $| -out $@ -CAcreateserial -extfile cert.extensions.cfg rm $@.tmp.csr leaf.cert.pem: leaf.private.pem | intermediate.private.pem diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go index 7366c37897..231771eb9f 100644 --- a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/check.go @@ -1,11 +1,16 @@ package cosesign1 import ( + "bytes" + "crypto" + "crypto/sha256" "crypto/x509" "fmt" + "math" didx509resolver "github.com/Microsoft/didx509go/pkg/did-x509-resolver" + "github.com/fxamacker/cbor/v2" "github.com/sirupsen/logrus" "github.com/veraison/go-cose" @@ -63,6 +68,84 @@ type UnpackedCoseSign1 struct { ChainPem string Payload []byte CertChain []*x509.Certificate + Protected cose.ProtectedHeader + Unprotected cose.UnprotectedHeader + // Receipts contains the parsed COSE Receipts attached to the unprotected + // `receipts` header (label 394), if any. Receipts are parsed but not + // validated; use (ParsedCOSEReceipt).Validate to validate each. + Receipts []ParsedCOSEReceipt +} + +// ParsedCOSEReceipt is a parsed COSE Receipt attached to a COSE Sign1 +// envelope. It carries the original CBOR-encoded blob alongside the decoded +// COSE_Sign1 message and a few convenience fields extracted from its +// protected header. +type ParsedCOSEReceipt struct { + // Raw is the original CBOR-encoded COSE_Sign1 receipt blob. + Raw []byte + // Message is the decoded COSE_Sign1 receipt. + Message cose.Sign1Message + // Issuer is the value of CWT claim `iss` from the receipt's protected CWT + // Claims header + Issuer string + // The value of the receipt's protected `kid` header, interpreted + // as a string (CCF uses ASCII hex) + Kid string + // The expected hash of the Signed Statement this receipt is for. + ExpectedDataHash []byte +} + +// parseCOSEReceipts decodes the unprotected `receipts` header (label 394) +// into []ParsedCOSEReceipt. It does not validate the receipts. +func parseCOSEReceipts(unprotected cose.UnprotectedHeader) ([]ParsedCOSEReceipt, error) { + rcptsVal, ok := unprotected[COSE_Header_Receipts] + if !ok { + return nil, nil + } + rcptsArr, ok := rcptsVal.([]interface{}) + if !ok { + return nil, fmt.Errorf("receipts header is not an array (got %T)", rcptsVal) + } + out := make([]ParsedCOSEReceipt, 0, len(rcptsArr)) + for i, r := range rcptsArr { + rb, ok := r.([]byte) + if !ok { + return nil, fmt.Errorf("receipt %d is not a byte string (got %T)", i, r) + } + var msg cose.Sign1Message + if err := msg.UnmarshalCBOR(rb); err != nil { + return nil, fmt.Errorf("receipt %d: parsing COSE_Sign1: %w", i, err) + } + rcpt := ParsedCOSEReceipt{Raw: rb, Message: msg} + if kidVal, ok := msg.Headers.Protected[COSE_Header_kid]; ok { + if kidBytes, ok := kidVal.([]byte); ok { + rcpt.Kid = string(kidBytes) + } else { + return nil, fmt.Errorf("receipt %d: kid is not a byte string (got %T)", i, kidVal) + } + } else { + return nil, fmt.Errorf("receipt %d: kid header missing", i) + } + if cwtVal, ok := msg.Headers.Protected[COSE_Header_CWTClaims]; ok { + if cwt, ok := cwtVal.(map[interface{}]interface{}); ok { + issVal, issPresent := cwt[CWT_Issuer] + if !issPresent { + return nil, fmt.Errorf("receipt %d: issuer (iss) claim missing from CWT claims", i) + } + if iss, ok := issVal.(string); ok { + rcpt.Issuer = iss + } else { + return nil, fmt.Errorf("receipt %d: issuer is not a string (got %T)", i, issVal) + } + } else { + return nil, fmt.Errorf("receipt %d: CWT claims is not a map (got %T)", i, cwtVal) + } + } else { + return nil, fmt.Errorf("receipt %d: CWT claims missing", i) + } + out = append(out, rcpt) + } + return out, nil } // This function is rather unpleasant in that it both decodes the COSE Sign1 document and its various @@ -177,10 +260,36 @@ func UnpackAndValidateCOSE1CertChain(raw []byte) (*UnpackedCoseSign1, error) { return nil, err } - issuer := getStringValue(protected, "iss") - feed := getStringValue(protected, "feed") + cwt, hasCwt := protected[COSE_Header_CWTClaims] + var issuer, feed string + if hasCwt { + cwt, ok := cwt.(map[interface{}]interface{}) + if !ok { + return nil, fmt.Errorf("expected CWTClaims header to be a map[any]any, got %T", cwt) + } + issuer = getStringValue(cwt, CWT_Issuer) + feed = getStringValue(cwt, CWT_Subject) + } else { + issuer = getStringValue(protected, "iss") + feed = getStringValue(protected, "feed") + } + contenttype := getStringValue(protected, cose.HeaderLabelContentType) + receipts, err := parseCOSEReceipts(msg.Headers.Unprotected) + if err != nil { + return nil, fmt.Errorf("parsing receipts: %w", err) + } + if len(receipts) > 0 { + dataHash, err := computeSignedStatementDataHash(raw) + if err != nil { + return nil, fmt.Errorf("computing signed statement data hash: %w", err) + } + for i := range receipts { + receipts[i].ExpectedDataHash = dataHash + } + } + return &UnpackedCoseSign1{ Pubcert: leafCertBase64, Feed: feed, @@ -190,5 +299,291 @@ func UnpackAndValidateCOSE1CertChain(raw []byte) (*UnpackedCoseSign1, error) { ContentType: contenttype, Payload: msg.Payload, CertChain: chain, + Protected: protected, + Unprotected: msg.Headers.Unprotected, + Receipts: receipts, }, nil } + +// asInt64 coerces a CBOR-decoded integer value (which may be returned as +// int64, uint64 or int by different decoders) to an int64. +func asInt64(v interface{}) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case int: + return int64(n), true + case uint64: + if n > math.MaxInt64 { + logrus.Errorf("Unable to convert %v to int64 due to overflow", n) + return 0, false + } + return int64(n), true + case uint: + // uint is 64bit on 64bit platforms, so can overflow int64 + if n > math.MaxInt64 { + logrus.Errorf("Unable to convert %v to int64 due to overflow", n) + return 0, false + } + return int64(n), true + } + return 0, false +} + +// Validate validates the COSE Receipt's structure and signature. See +// https://www.ietf.org/archive/id/draft-ietf-cose-merkle-tree-proofs-18.html +// for details about COSE Receipts. +// +// It checks that: +// - the protected header carries a vds (label 395), +// - the payload is detached, +// - the unprotected `vdp` header (label 396) contains at least one +// inclusion proof (key -1) encoded as a byte string, +// - the Merkle root recomputed from each inclusion proof verifies the +// receipt's COSE_Sign1 signature, using the public key in `keys` indexed by +// r.Kid. +// - The data-hash in the receipt matches the expected hash of the signed +// statement it is for. +// +// keys is a map of key IDs to public keys for this ledger. The caller must +// acquire this via some other means, e.g. via a signed trusted key list, or via +// the JWKS endpoint of the ledger (see example code in +// cmd/sign1util/ccf_keyfetch.go) with additional attestation verification which +// is not implemented in this library. +func (r ParsedCOSEReceipt) Validate(keys map[string]crypto.PublicKey) error { + msg := r.Message + + vdsVal, ok := msg.Headers.Protected[COSE_Header_vds] + if !ok { + return fmt.Errorf("missing vds (label %d) in protected header", COSE_Header_vds) + } + vds, ok := asInt64(vdsVal) + if !ok { + return fmt.Errorf("vds has wrong type: %T", vdsVal) + } + + if msg.Payload != nil { + return fmt.Errorf("payload must be detached but has %d bytes", len(msg.Payload)) + } + + algoVal, ok := msg.Headers.Protected[cose.HeaderLabelAlgorithm] + if !ok { + return fmt.Errorf("missing algorithm in protected header") + } + algo, ok := algoVal.(cose.Algorithm) + if !ok { + return fmt.Errorf("algorithm has wrong type: %T", algoVal) + } + + pubKey, ok := keys[r.Kid] + if !ok { + return fmt.Errorf("no key for kid %s", r.Kid) + } + + vdpVal, ok := msg.Headers.Unprotected[COSE_Header_vdp] + if !ok { + return fmt.Errorf("missing vdp (label %d) in unprotected header", COSE_Header_vdp) + } + vdpMap, ok := vdpVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("vdp has wrong type: %T", vdpVal) + } + inclVal, ok := vdpMap[COSE_ProofInclusion] + if !ok { + return fmt.Errorf("no inclusion proofs (key %d) in vdp", COSE_ProofInclusion) + } + inclArr, ok := inclVal.([]interface{}) + if !ok { + return fmt.Errorf("inclusion proofs has wrong type: %T", inclVal) + } + if len(inclArr) == 0 { + return fmt.Errorf("inclusion proofs array is empty") + } + + verifier, err := cose.NewVerifier(algo, pubKey) + if err != nil { + return fmt.Errorf("cose.NewVerifier (algo %d): %w", algo, err) + } + + for i, p := range inclArr { + pb, ok := p.([]byte) + if !ok { + return fmt.Errorf("inclusion proof %d is not a byte string (got %T)", i, p) + } + var root, dataHash []byte + switch vds { + case COSE_vds_CCF_LEDGER_SHA256: + root, dataHash, err = CCF_ComputeRoot(pb) + default: + return fmt.Errorf("only receipts with CCF profile supported (got vds %d)", vds) + } + if err != nil { + return fmt.Errorf("inclusion proof %d: %w", i, err) + } + if !bytes.Equal(dataHash, r.ExpectedDataHash) { + return fmt.Errorf("inclusion proof %d: leaf data-hash %x does not match the expected value %x for the signed envelope", i, dataHash, r.ExpectedDataHash) + } + logrus.Debugf("receipt inclusion proof %d recomputed root: %x", i, root) + // Verify the receipt's COSE_Sign1 signature using the recomputed + // Merkle root as the detached payload. + msg.Payload = root + if err := msg.Verify(nil, verifier); err != nil { + return fmt.Errorf("inclusion proof %d: signature verification failed (recomputed root=%x, kid=%s, alg=%d): %w", i, root, r.Kid, algo, err) + } + msg.Payload = nil + } + return nil +} + +// Decodes a CCF inclusion proof (the bstr-wrapped CBOR `ccf-inclusion-proof` +// structure) and recomputes the Merkle root using the algorithm described in +// section 3.2 of +// https://datatracker.ietf.org/doc/html/draft-ietf-scitt-receipts-ccf-profile-02 +// Returns the recomputed Merkle root and the data-hash from the leaf (this +// needs to be verified by the caller against an expected value). +func CCF_ComputeRoot(proofBytes []byte) ([]byte, []byte, error) { + var proof map[int64]interface{} + if err := cbor.Unmarshal(proofBytes, &proof); err != nil { + return nil, nil, fmt.Errorf("decoding inclusion proof: %w", err) + } + // ccf-inclusion-proof = bstr .cbor { + // &(leaf: 1) => ccf-leaf + // &(path: 2) => [+ ccf-proof-element] + // } + leafVal, ok := proof[1] + if !ok { + return nil, nil, fmt.Errorf("missing leaf (key 1)") + } + pathVal, ok := proof[2] + if !ok { + return nil, nil, fmt.Errorf("missing path (key 2)") + } + + // ccf-leaf = [ + // ; Byte string of size HASH_SIZE(32) + // internal-transaction-hash: bstr .size 32 + // + // ; Text string of at most 1024 bytes + // internal-evidence: tstr .size (1..1024) + // + // ; Byte string of size HASH_SIZE(32) + // data-hash: bstr .size 32 + // ] + leafArr, ok := leafVal.([]interface{}) + if !ok || len(leafArr) != 3 { + return nil, nil, fmt.Errorf("leaf must be a 3-element array, got %T len %d", leafVal, lenOf(leafVal)) + } + internalTxHash, ok := leafArr[0].([]byte) + if !ok || len(internalTxHash) != 32 { + return nil, nil, fmt.Errorf("leaf.internal-transaction-hash must be a 32-byte bstr, got %T", leafArr[0]) + } + internalEvidenceStr, ok := leafArr[1].(string) + if !ok { + return nil, nil, fmt.Errorf("leaf.internal-evidence must be a text tstr, got %T", leafArr[1]) + } + internalEvidence := []byte(internalEvidenceStr) + if len(internalEvidence) < 1 || len(internalEvidence) > 1024 { + return nil, nil, fmt.Errorf("leaf.internal-evidence has invalid length %d", len(internalEvidence)) + } + dataHash, ok := leafArr[2].([]byte) + if !ok || len(dataHash) != 32 { + return nil, nil, fmt.Errorf("leaf.data-hash must be a 32-byte bstr, got %T", leafArr[2]) + } + + // Leaf hash: + // h := HASH(internal-transaction-hash || HASH(internal-evidence) || data-hash) + evidenceHash := sha256.Sum256(internalEvidence) + leafConcat := make([]byte, 0, 32+32+32) + leafConcat = append(leafConcat, internalTxHash...) + leafConcat = append(leafConcat, evidenceHash[:]...) + leafConcat = append(leafConcat, dataHash...) + leafHash := sha256.Sum256(leafConcat) + h := leafHash[:] + logrus.Debugf("CCF leaf: internal-tx-hash=%x evidence=%q (hash=%x) data-hash=%x -> leaf=%x", internalTxHash, internalEvidence, evidenceHash[:], dataHash, h) + + pathArr, ok := pathVal.([]interface{}) + if !ok { + return nil, nil, fmt.Errorf("path must be an array") + } + if len(pathArr) == 0 { + return nil, nil, fmt.Errorf("path must contain at least one element") + } + + for i, el := range pathArr { + // ccf-proof-element = [ + // ; Position of the element + // left: bool + // + // ; Hash of the proof element: byte string of size HASH_SIZE(32) + // hash: bstr .size 32 + // ] + elArr, ok := el.([]interface{}) + if !ok || len(elArr) != 2 { + return nil, nil, fmt.Errorf("path element %d must be a 2-element array", i) + } + left, ok := elArr[0].(bool) + if !ok { + return nil, nil, fmt.Errorf("path element %d left flag must be a bool", i) + } + hash, ok := elArr[1].([]byte) + if !ok { + return nil, nil, fmt.Errorf("path element %d hash must be a 32-byte bstr, got %T", i, elArr[1]) + } + if len(hash) != 32 { + return nil, nil, fmt.Errorf("path element %d hash must be 32 bytes, got %d bytes", i, len(hash)) + } + var concat []byte + if left { + concat = append(concat, hash...) + concat = append(concat, h...) + } else { + concat = append(concat, h...) + concat = append(concat, hash...) + } + sum := sha256.Sum256(concat) + h = sum[:] + logrus.Debugf("CCF path step %d: left=%v sibling=%x -> h=%x", i, left, hash, h) + } + return h, dataHash, nil +} + +// computeSignedStatementDataHash returns sha256 of the tagged COSE_Sign1 +// envelope with its unprotected header reset to an empty map. This should match +// the data-hash in the CCF receipt. +// +// This is the hash of the Signed Statement as defined by +// https://datatracker.ietf.org/doc/html/draft-ietf-scitt-architecture-22 +func computeSignedStatementDataHash(envelope []byte) ([]byte, error) { + var arr struct { + _ struct{} `cbor:",toarray"` + Protected cbor.RawMessage + Unprot map[interface{}]interface{} + Payload cbor.RawMessage + Signature cbor.RawMessage + } + if err := cbor.Unmarshal(envelope, &arr); err != nil { + return nil, fmt.Errorf("decoding COSE_Sign1: %w", err) + } + arr.Unprot = map[interface{}]interface{}{} + em, err := cbor.CanonicalEncOptions().EncMode() + if err != nil { + return nil, err + } + body, err := em.Marshal(arr) + if err != nil { + return nil, fmt.Errorf("encoding stripped COSE_Sign1: %w", err) + } + tagged, err := em.Marshal(cbor.Tag{Number: COSE_Sign1_Tag, Content: cbor.RawMessage(body)}) + if err != nil { + return nil, fmt.Errorf("tagging COSE_Sign1: %w", err) + } + digest := sha256.Sum256(tagged) + return digest[:], nil +} + +func lenOf(v interface{}) int { + if a, ok := v.([]interface{}); ok { + return len(a) + } + return -1 +} diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go new file mode 100644 index 0000000000..f2215256aa --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/constants.go @@ -0,0 +1,43 @@ +package cosesign1 + +const COSE_Sign1_Tag = 18 + +// COSE Header Parameters +// https://www.iana.org/assignments/cose/cose.xhtml +const ( + COSE_Header_kid = int64(4) + COSE_Header_CWTClaims = int64(15) + COSE_Header_x5chain = int64(33) + COSE_Header_x5t = int64(34) + COSE_Header_PayloadHashAlg = int64(258) + COSE_Header_PreimageContentType = int64(259) + COSE_Header_PayloadLocation = int64(260) + COSE_Header_Receipts = int64(394) + COSE_Header_vds = int64(395) + COSE_Header_vdp = int64(396) +) + +// COSE Verifiable Data Structure Algorithms +// (Values for COSE_HeaderLabelvds) +const ( + COSE_vds_RFC9162_SHA256 = int64(1) + + // TBD_1 in https://www.ietf.org/archive/id/draft-birkholz-cose-receipts-ccf-profile-05.html + COSE_vds_CCF_LEDGER_SHA256 = int64(2) +) + +// COSE Verifiable Data Structure Proofs +// (These are the map keys inside a COSE_HeaderLabelReceipts header). +const ( + COSE_ProofInclusion = int64(-1) + COSE_ProofConsistency = int64(-2) +) + +// CWT Claims +// https://www.iana.org/assignments/cwt/cwt.xhtml +const ( + CWT_Issuer = int64(1) + CWT_Subject = int64(2) +) + +const TTL_LedgerEntry_Keys = int64(1) diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_cp_ledger_pub_keys.json b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_cp_ledger_pub_keys.json new file mode 100644 index 0000000000..5f9507329e --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_cp_ledger_pub_keys.json @@ -0,0 +1,11 @@ +{ + "keys": [ + { + "crv": "P-384", + "kid": "a7ad3b7729516ca443fa472a0f2faa4a984ee3da7eafd17f98dcffbac4a6a10f", + "kty": "EC", + "x": "m0kQ1A_uqHWuP9fdGSKatSq2brcAJ6-q3aZ5P35wjbgtNnlm2u-NLF1qM-yC4I2n", + "y": "J9cJFrdWvUf6PCMkrWFTgB16uEq4mSMCI4NPVytnwYX6xNnuJ2GTrPtafKYg1VNi" + } + ] +} \ No newline at end of file diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_db_ledger_pub_keys.json b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_db_ledger_pub_keys.json new file mode 100644 index 0000000000..c1355ba425 --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/esrp_db_ledger_pub_keys.json @@ -0,0 +1,18 @@ +{ + "keys": [ + { + "crv": "P-384", + "kid": "23d48c280f71abf575c81e89f18a4dc9f3b33d8a3b149b16ad836c8553f95bc0", + "kty": "EC", + "x": "2GIJv9nAhste7hDWrpea1-hd_BAPXg4ZIxLy4C4hAX2eCpqT4siLqohA2KIVJti8", + "y": "aTT6XYHZPBgdI4RLFo2BaP1RVuOG2rFg5JBhYvt871HIwmtzNtwXl3_NBwfcqr8O" + }, + { + "crv": "P-384", + "kid": "da7694f16def5a056ca96afb21e89a9450e4cc875e2de351da76d99544a3e849", + "kty": "EC", + "x": "GeQ_qA3ZxYoaan3D0nA7xriMcmiMqQ0UNY1DLs7C5kIEaI_RL_2duRcG1Ii6g-8-", + "y": "uKiRr4UU8aXumcA8wu6LOatH0qL2AjFy3_8iBx3mbt1foS5xNHlXchMMLTSCvRLn" + } + ] +} diff --git a/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/keyset.go b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/keyset.go new file mode 100644 index 0000000000..68b3cb7ebb --- /dev/null +++ b/vendor/github.com/Microsoft/cosesign1go/pkg/cosesign1/keyset.go @@ -0,0 +1,98 @@ +package cosesign1 + +import ( + "crypto" + + "github.com/fxamacker/cbor/v2" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + cose "github.com/veraison/go-cose" +) + +// Parses a COSE_KeySet, which is a CBOR array of raw COSE_Key objects, into a +// map from key IDs to public keys, to be used for receipt validation. +// +// Reference: https://www.rfc-editor.org/rfc/rfc9052.html#name-cose-keys +func ParseKeySetAsMap(data []byte) (map[string]crypto.PublicKey, error) { + var rawKeys []cbor.RawMessage + if err := cbor.Unmarshal(data, &rawKeys); err != nil { + return nil, errors.Wrap(err, "Failed to parse the COSE_KeySet") + } + if len(rawKeys) == 0 { + return nil, errors.New("empty COSE Key Set") + } + var lastKeyError error + keys := make(map[string]crypto.PublicKey) + for i, raw := range rawKeys { + // From RFC: Each element in a COSE Key Set MUST be processed + // independently. If one element in a COSE Key Set is either malformed + // or uses a key that is not understood by an application, that key is + // ignored, and the other keys are processed normally. + var k cose.Key + if err := k.UnmarshalCBOR(raw); err != nil { + logrus.Warnf("Failed to parse element %d of the COSE Key Set: %v", i, err) + lastKeyError = errors.Wrapf(err, "UnmarshalCBOR element %d", i) + continue + } + kid := string(k.ID) + if kid == "" { + logrus.Warnf("Failed to parse element %d of the COSE Key Set: missing key ID, ignoring this key", i) + lastKeyError = errors.Errorf("missing key ID in element %d", i) + continue + } + pk, err := k.PublicKey() + if err != nil { + logrus.Warnf("Failed to construct public key from element %d of the COSE Key Set (kid=%q): %v", i, kid, err) + lastKeyError = errors.Wrapf(err, "construct PublicKey from element %d", i) + continue + } + if existingKey, exists := keys[kid]; exists { + // Equal is implemented for all crypto.PublicKey types in std + eq, ok := existingKey.(interface{ Equal(crypto.PublicKey) bool }) + if !ok || !eq.Equal(pk) { + logrus.Warnf("Parsing element %d of the COSE Key Set: Key with ID %q already seen earlier but got another conflicting key with same ID, ignoring this one", i, kid) + continue + } + } + keys[kid] = pk + } + if len(keys) == 0 { + logrus.Errorf("Failed to parse any element of the provided COSE Key Set") + return nil, lastKeyError + } + return keys, nil +} + +// ParseTTLPayload parses an unsigned body of a Transparency Trust List (TTL), +// which is a CBOR map from issuer strings to LedgerEntry maps. Each LedgerEntry +// is a CBOR map keyed by integer attributes; the TTL_LedgerEntry_Keys (1) +// attribute holds that issuer's COSE_KeySet. The result is a map from issuer to +// that issuer's map of key IDs to public keys. +// +// Reference: https://github.com/achamayou/scitt-ccf-ledger/blob/ttl/docs/transparent_trust_lists.md +func ParseTTLPayload(data []byte) (map[string]map[string]crypto.PublicKey, error) { + var rawIssuers map[string]cbor.RawMessage + if err := cbor.Unmarshal(data, &rawIssuers); err != nil { + return nil, errors.Wrap(err, "Failed to parse the TTL payload") + } + if len(rawIssuers) == 0 { + return nil, errors.New("empty TTL payload") + } + out := make(map[string]map[string]crypto.PublicKey, len(rawIssuers)) + for issuer, rawEntry := range rawIssuers { + var entry map[int64]cbor.RawMessage + if err := cbor.Unmarshal(rawEntry, &entry); err != nil { + return nil, errors.Wrapf(err, "parsing LedgerEntry for issuer %q", issuer) + } + rawKeySet, ok := entry[TTL_LedgerEntry_Keys] + if !ok { + return nil, errors.Errorf("LedgerEntry for issuer %q is missing the keys attribute (%d)", issuer, TTL_LedgerEntry_Keys) + } + keys, err := ParseKeySetAsMap(rawKeySet) + if err != nil { + return nil, errors.Wrapf(err, "parsing COSE_KeySet for issuer %q", issuer) + } + out[issuer] = keys + } + return out, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index c9d971c854..94ecc0dc38 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -4,8 +4,8 @@ cyphar.com/go-pathrs cyphar.com/go-pathrs/internal/fdutils cyphar.com/go-pathrs/internal/libpathrs cyphar.com/go-pathrs/procfs -# github.com/Microsoft/cosesign1go v1.4.0 -## explicit; go 1.20 +# github.com/Microsoft/cosesign1go v1.6.0-alpha1 +## explicit; go 1.21 github.com/Microsoft/cosesign1go/pkg/cosesign1 # github.com/Microsoft/didx509go v0.0.3 ## explicit; go 1.20 From 3def557f37e9e27a14df1d464f71ba5ee399dce2 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 8 Jun 2026 00:47:58 +0000 Subject: [PATCH 3/7] 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 1f8b48450ae2b70b1ef456ecb811a79c278fcfcd Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 8 Jun 2026 00:51:05 +0000 Subject: [PATCH 4/7] 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 3c61afe2e9eadba45aabbaf3b814cedf706ac6ca Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 8 Jun 2026 16:20:30 +0000 Subject: [PATCH 5/7] 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 31862c163cf43dc4e85456ea4d62146731c90df8 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Tue, 16 Jun 2026 14:04:32 +0000 Subject: [PATCH 6/7] 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_roots 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_roots 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 | 87 ++++++++- 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, 434 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..cf2196148e 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1141,6 +1141,9 @@ default fragment_external_processes := [] fragment_external_processes := data[input.namespace].external_processes +default fragment_transparency_roots := [] +fragment_transparency_roots := data[input.namespace].transparency_roots + apply_defaults(name, raw_values, framework_version) := values { semver.compare(framework_version, version) == 0 values := raw_values @@ -1170,6 +1173,20 @@ apply_defaults("fragment", raw_values, framework_version) := values { ] } +# transparency_roots is introduced in framework version 0.5.0. If an old policy +# has it, silently ignore as it might be using the name for something else. + +apply_defaults("transparency_roots", raw_values, framework_version) := values { + semver.compare(framework_version, version) < 0 + semver.compare(framework_version, "0.5.0") >= 0 + values := raw_values +} + +apply_defaults("transparency_roots", raw_values, framework_version) := values { + semver.compare(framework_version, "0.5.0") < 0 + values := [] +} + default fragment_framework_version := null fragment_framework_version := data[input.namespace].framework_version @@ -1178,7 +1195,8 @@ extract_fragment_includes(includes) := fragment { objects := { "containers": apply_defaults("container", fragment_containers, framework_version), "fragments": apply_defaults("fragment", fragment_fragments, framework_version), - "external_processes": apply_defaults("external_process", fragment_external_processes, framework_version) + "external_processes": apply_defaults("external_process", fragment_external_processes, framework_version), + "transparency_roots": apply_defaults("transparency_roots", fragment_transparency_roots, framework_version), } fragment := { @@ -1328,6 +1346,54 @@ load_fragment := {"metadata": [updateIssuer], "add_module": add_module, "allowed add_module := "namespace" in fragment.includes } +# transparency_roots declares which signed Transparency Trust Lists (TTLs) the +# policy is willing to accept, and which ledgers each such TTL may contribute +# keys for. Like candidate_fragments, the candidate set is the union of the +# top-level policy's transparency_roots and any contributed by already-loaded +# fragments that included "transparency_roots". +default policy_transparency_roots := [] +policy_transparency_roots := data.policy.transparency_roots + +candidate_transparency_roots := roots { + fragment_roots := [r | + feed := data.metadata.issuers[_].feeds[_] + fragment := feed[_] + r := fragment.transparency_roots[_] + ] + + roots := array.concat(policy_transparency_roots, fragment_roots) +} + +# The set of ledger names a matching transparency root authorizes for the given +# (issuer, subject, svn). "*" is a wildcard meaning "any ledger". +ttl_allowed_ledgers_for_issuer_subject_svn(issuer, subject, svn) := allowed_ledgers { + allowed_ledgers := {l | + ttl := candidate_transparency_roots[_] + ttl.issuer == issuer + ttl.subject == subject + svn_ok(svn, ttl.minimum_svn) + l := ttl.allowed_ledgers[_] + } +} + +intersect_or_allow_all_if_wildcard(allowed_ledgers, input_ledgers) := result { + not "*" in allowed_ledgers + result := {l | l := input_ledgers[_]; l in allowed_ledgers} +} + +intersect_or_allow_all_if_wildcard(allowed_ledgers, input_ledgers) := result { + "*" in allowed_ledgers + result := {l | l := input_ledgers[_]} +} + +default load_transparency_trust_list := {"allowed": false} + +load_transparency_trust_list := {"allowed": true, "allowed_ledgers": allowed_ledgers} { + root_ledgers := ttl_allowed_ledgers_for_issuer_subject_svn(input.issuer, input.subject, input.svn) + allowed_ledgers := intersect_or_allow_all_if_wildcard(root_ledgers, input.ledgers) + count(allowed_ledgers) > 0 +} + default scratch_mount := {"allowed": false} scratch_mounted(target) { @@ -1937,6 +2003,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_roots + ttl.issuer == input.issuer + ttl.subject == input.subject + svn_ok(input.svn, ttl.minimum_svn) +} + +errors["no transparency root matches the trust list issuer, subject and svn"] { + input.rule == "load_transparency_trust_list" + not transparency_root_matches +} + +errors["transparency trust list carries no ledgers authorized by any transparency root"] { + input.rule == "load_transparency_trust_list" + transparency_root_matches +} + errors["scratch already mounted at path"] { input.rule == "scratch_mount" scratch_mounted(input.target) 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..59239e4c59 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_roots := [ + { + "issuer": "%s", + "subject": "%s", + "minimum_svn": %d, + "allowed_ledgers": [%s], + }, +] + +load_transparency_trust_list := data.framework.load_transparency_trust_list +reason := data.framework.reason +`, apiVersion, frameworkVersion, issuer, subject, minimumSVN, strings.Join(quoted, ", ")) +} + +func Test_Rego_LoadTransparencyTrustList(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + ledger := "esrp-cts-dev.confidential-ledger.azure.com" + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{ledger}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + "unauthorized.ledger.example": {"kid2": key}, + } + + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 2, parsedTTL); err != nil { + t.Fatalf("expected TTL to load: %v", err) + } + + if _, ok := policy.ttlKeys[ledger]["kid1"]; !ok { + t.Errorf("expected key for authorized ledger to be stored") + } + if _, ok := policy.ttlKeys["unauthorized.ledger.example"]; ok { + t.Errorf("keys for an unauthorized ledger must not be stored") + } +} + +func Test_Rego_LoadTransparencyTrustList_Wildcard(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{"*"}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + "ledger.one.example": {"kid1": key}, + "ledger.two.example": {"kid2": key}, + } + + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, parsedTTL); err != nil { + t.Fatalf("expected TTL to load: %v", err) + } + + for _, ledger := range []string{"ledger.one.example", "ledger.two.example"} { + if _, ok := policy.ttlKeys[ledger]; !ok { + t.Errorf("expected wildcard root to authorize ledger %s", ledger) + } + } +} + +func Test_Rego_LoadTransparencyTrustList_NoAuthorizedLedgers(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{"only.this.ledger.example"}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + "some.other.ledger.example": {"kid1": key}, + } + + err = policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, parsedTTL) + if err == nil { + t.Fatalf("expected TTL load to be denied when no ledgers are authorized") + } + if len(policy.ttlKeys) != 0 { + t.Errorf("no keys should be stored when the TTL is denied") + } +} + +func Test_Rego_LoadTransparencyTrustList_SVNBelowMinimum(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 5, []string{ledger}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + parsedTTL := map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + } + + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 4, parsedTTL); err == nil { + t.Fatalf("expected TTL load to be denied when svn is below the minimum") + } +} + +func Test_Rego_LoadTransparencyTrustList_MergeOverride(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + subject := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy(ttlPolicyCode(issuer, subject, 1, []string{ledger}), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + firstKey := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": firstKey}, + }); err != nil { + t.Fatalf("expected first TTL to load: %v", err) + } + + // A second TTL for the same ledger that adds a new kid and overrides the + // existing one with a different key. + secondKey := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, issuer, subject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": secondKey, "kid2": firstKey}, + }); err != nil { + t.Fatalf("expected second TTL to load: %v", err) + } + + eq := policy.ttlKeys[ledger]["kid1"].(interface{ Equal(crypto.PublicKey) bool }) + if !eq.Equal(secondKey) { + t.Errorf("expected kid1 to be overridden with the newer key") + } + if _, ok := policy.ttlKeys[ledger]["kid2"]; !ok { + t.Errorf("expected kid2 to be merged in") + } +} + func Test_Rego_LoadFragment_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..44eaf0d763 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_roots. The keys for the allowed +// ledgers are then merged into the enforcer's TTL key store for use when +// validating fragment receipts. +func (policy *regoEnforcer) LoadTransparencyTrustList(ctx context.Context, issuer string, subject string, svn int64, parsedTTL map[string]map[string]crypto.PublicKey) error { + ledgers := make([]string, 0, len(parsedTTL)) + for ledger := range parsedTTL { + ledgers = append(ledgers, ledger) + } + + input := inputData{ + "issuer": issuer, + "subject": subject, + "svn": svn, + "ledgers": ledgers, + } + + results, err := policy.enforce(ctx, "load_transparency_trust_list", input) + if err != nil { + return err + } + + allowedLedgersRaw, err := results.Array("allowed_ledgers") + if err != nil { + return errors.Wrap(err, "unable to get allowed_ledgers from load_transparency_trust_list result") + } + + if len(allowedLedgersRaw) == 0 { + return errors.New("transparency trust list carries no ledgers authorized by the policy") + } + + allowedLedgers := make([]string, 0, len(allowedLedgersRaw)) + for _, l := range allowedLedgersRaw { + ledger, ok := l.(string) + if !ok { + return fmt.Errorf("Elements of result.allowed_ledgers must be strings, got %T", l) + } + allowedLedgers = append(allowedLedgers, ledger) + } + + policy.ttlKeysLock.Lock() + defer policy.ttlKeysLock.Unlock() + for _, ledger := range allowedLedgers { + newKeys := parsedTTL[ledger] + existingKeys, ok := policy.ttlKeys[ledger] + if !ok { + existingKeys = make(map[string]crypto.PublicKey, len(newKeys)) + policy.ttlKeys[ledger] = existingKeys + } + for kid, pk := range newKeys { + if existingKey, exists := existingKeys[kid]; exists { + // Equal is implemented for all crypto.PublicKey types in std. + eq, ok := existingKey.(interface{ Equal(crypto.PublicKey) bool }) + if !ok || !eq.Equal(pk) { + log.G(ctx).Warnf("TTL for ledger %s overrides existing key with id %s with a different key", ledger, kid) + existingKeys[kid] = pk + } + } else { + existingKeys[kid] = pk + } + } + } + + log.G(ctx).Infof("Loaded TTL with subject %s signed by %s with keys for ledgers: %v", subject, issuer, allowedLedgers) + return nil +} + func (policy *regoEnforcer) EnforceScratchMountPolicy(ctx context.Context, scratchPath string, encrypted bool) error { input := map[string]interface{}{ "target": scratchPath, @@ -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 9f91c1b1ac1d9a7920b250abed565f4d37d47100 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Tue, 16 Jun 2026 14:21:48 +0000 Subject: [PATCH 7/7] securitypolicy: require transparency receipts for fragments Fragments may now declare required_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 required_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 cf2196148e..96573f1376 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1308,6 +1308,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, "required_receipt_issuers", []) + every required_issuer in required { + required_issuer in input.receipt_issuers + } +} + default load_fragment := {"allowed": false} # load_fragment gets called twice - first before loading the fragment as a Rego @@ -1325,6 +1336,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} { @@ -2003,6 +2015,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, "required_receipt_issuers", []) + some required_issuer in required + not required_issuer in input.receipt_issuers + receipt_error := sprintf("missing receipt from %s", [required_issuer]) +} + default transparency_root_matches := false transparency_root_matches { @@ -2508,6 +2531,13 @@ check_fragment(raw_fragment, framework_version) := fragment { "feed": raw_fragment.feed, "minimum_svn": raw_fragment.minimum_svn, "includes": raw_fragment.includes, + + # required_receipt_issuers was added in 0.5.0. Older policies default to + # [], i.e. no transparency receipts are required, but if any is + # specified, even when the policy has an older framework_version, we + # respect it since it is restrictive. + "required_receipt_issuers": object.get(raw_fragment, "required_receipt_issuers", []), + # Additional fields need to have default logic applied } } diff --git a/pkg/securitypolicy/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index 59239e4c59..91325ff684 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": [], + "required_receipt_issuers": ["%s"], + }, +] + +load_fragment := data.framework.load_fragment +reason := data.framework.reason +`, apiVersion, frameworkVersion, issuer, feed, ledger) + + policy, err := newRegoPolicy(policyCode, []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + // No TTL has been loaded, so the enforcer has no keys to validate any + // receipt, and the fragment requires one. The load must be denied. + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{Issuer: issuer, Feed: feed, HeaderSVN: &svn, Rego: fragmentCode}) + if err == nil { + t.Fatalf("expected fragment load to be denied for missing required receipt") + } + if !assertDecisionJSONContains(t, err, fmt.Sprintf("missing receipt from %s", ledger)) { + t.Fatalf("expected denial reason to mention the missing receipt, got: %v", err) + } + + if !expectFragmentNotLoaded(t, policy, issuer, feed) { + return + } +} + +func Test_Rego_LoadFragment_NoReceiptRequired(t *testing.T) { + ctx := context.Background() + issuer := testDataGenerator.uniqueFragmentIssuer() + feed := testDataGenerator.uniqueFragmentFeed() + + fragmentCode := fmt.Sprintf(`package fragment + +svn := 1 +framework_version := "%s" +`, frameworkVersion) + + // A fragment object with no required_receipt_issuers must load without any + // receipts, exactly as before this feature existed. + policyCode := fmt.Sprintf(`package policy + +api_version := "%s" +framework_version := "%s" + +fragments := [ + { + "issuer": "%s", + "feed": "%s", + "minimum_svn": 1, + "includes": [], + }, +] + +load_fragment := data.framework.load_fragment +reason := data.framework.reason +`, apiVersion, frameworkVersion, issuer, feed) + + policy, err := newRegoPolicy(policyCode, []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + svn := int64(1) + if err := policy.LoadFragment(ctx, LoadFragmentOptions{Issuer: issuer, Feed: feed, HeaderSVN: &svn, Rego: fragmentCode}); err != nil { + t.Fatalf("expected fragment with no required receipts to load: %v", err) + } +} + +// receiptFragmentPolicy builds a policy that both trusts a TTL signed by +// ttlIssuer/ttlSubject (authorizing the given ledger) and allows a fragment +// from fragIssuer/fragFeed that requires a receipt from requiredLedger. +func receiptFragmentPolicy(ttlIssuer, ttlSubject, allowedLedger, fragIssuer, fragFeed, requiredLedger string) string { + return fmt.Sprintf(`package policy + +api_version := "%s" +framework_version := "%s" + +transparency_roots := [ + { + "issuer": "%s", + "subject": "%s", + "minimum_svn": 1, + "allowed_ledgers": ["%s"], + }, +] + +fragments := [ + { + "issuer": "%s", + "feed": "%s", + "minimum_svn": 1, + "includes": [], + "required_receipt_issuers": ["%s"], + }, +] + +load_fragment := data.framework.load_fragment +load_transparency_trust_list := data.framework.load_transparency_trust_list +reason := data.framework.reason +`, apiVersion, frameworkVersion, ttlIssuer, ttlSubject, allowedLedger, fragIssuer, fragFeed, requiredLedger) +} + +func Test_Rego_LoadFragment_ValidReceipt(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + ledger := "esrp-cts-dev.confidential-ledger.azure.com" + + policy, err := newRegoPolicy( + receiptFragmentPolicy(ttlIssuer, ttlSubject, ledger, fragIssuer, fragFeed, ledger), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // Mock receipt validation: assert the enforcer only ever offers us the keys + // belonging to the receipt's own claimed issuer, then accept. + validateCalled := false + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + validateCalled = true + if receipt.Issuer != ledger { + t.Errorf("validate called with unexpected issuer %q", receipt.Issuer) + } + if _, ok := keys["kid1"]; !ok || len(keys) != 1 { + t.Errorf("validate offered the wrong key set: %v", keys) + } + return nil + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: ledger, Kid: "kid1"}}, + }) + if err != nil { + t.Fatalf("expected fragment with a valid receipt to load: %v", err) + } + if !validateCalled { + t.Errorf("expected receipt validation to be invoked") + } +} + +func Test_Rego_LoadFragment_ReceiptWrongIssuer(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + requiredLedger := "required.ledger.example" + otherLedger := "other.ledger.example" + + // The TTL only authorizes otherLedger, and the fragment requires a receipt + // from requiredLedger. The attached receipt claims to be from otherLedger. + policy, err := newRegoPolicy( + receiptFragmentPolicy(ttlIssuer, ttlSubject, otherLedger, fragIssuer, fragFeed, requiredLedger), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + otherLedger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // Even though the mock would accept the receipt, its issuer is otherLedger, + // not the requiredLedger, so the requirement is not satisfied. + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return nil + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: otherLedger, Kid: "kid1"}}, + }) + if err == nil { + t.Fatalf("expected fragment load to be denied: receipt issuer does not match the requirement") + } + if !assertDecisionJSONContains(t, err, fmt.Sprintf("missing receipt from %s", requiredLedger)) { + t.Fatalf("expected denial reason to mention the missing receipt, got: %v", err) + } +} + +func Test_Rego_LoadFragment_ReceiptValidationFails(t *testing.T) { + ctx := context.Background() + ttlIssuer := testDataGenerator.uniqueFragmentIssuer() + ttlSubject := testDataGenerator.uniqueFragmentFeed() + fragIssuer := testDataGenerator.uniqueFragmentIssuer() + fragFeed := testDataGenerator.uniqueFragmentFeed() + ledger := "ledger.example" + + policy, err := newRegoPolicy( + receiptFragmentPolicy(ttlIssuer, ttlSubject, ledger, fragIssuer, fragFeed, ledger), + []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("unable to create Rego policy: %v", err) + } + + key := generateTestECDSAKey(t) + if err := policy.LoadTransparencyTrustList(ctx, ttlIssuer, ttlSubject, 1, map[string]map[string]crypto.PublicKey{ + ledger: {"kid1": key}, + }); err != nil { + t.Fatalf("unable to load TTL: %v", err) + } + + // A receipt whose cryptographic validation fails must not count. + policy.SetReceiptValidationFunction(func(receipt cosesign1.ParsedCOSEReceipt, keys map[string]crypto.PublicKey) error { + return errors.New("bad signature") + }) + + fragmentCode := fmt.Sprintf("package fragment\n\nsvn := 1\nframework_version := \"%s\"\n", frameworkVersion) + svn := int64(1) + err = policy.LoadFragment(ctx, LoadFragmentOptions{ + Issuer: fragIssuer, + Feed: fragFeed, + HeaderSVN: &svn, + Rego: fragmentCode, + Receipts: []cosesign1.ParsedCOSEReceipt{{Issuer: ledger, Kid: "kid1"}}, + }) + if err == nil { + t.Fatalf("expected fragment load to be denied: receipt failed validation") + } + if !assertDecisionJSONContains(t, err, fmt.Sprintf("missing receipt from %s", ledger)) { + t.Fatalf("expected denial reason to mention the missing receipt, got: %v", err) + } +} + func Test_Rego_LoadFragment_BadIssuer_AttemptOverrideFrameworkItems(t *testing.T) { f := func(p *generatedConstraints) bool { tc, err := setupSimpleRegoFragmentTestConfig(p) 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 44eaf0d763..b84498367f 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