From b23f30ba35dd352bd21cdbfe4243c0e6c5f377db Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Tue, 9 Jun 2026 15:31:01 +0000 Subject: [PATCH 01/13] Upgrade github.com/veraison/go-cose to v1.3.0 This is required to use the new cose.Key struct in order to parse a TTL (COSE_KeySet, which is an array pf COSE_Key objects, which the new version of go-cose contain parsing code for). Signed-off-by: Tingmao Wang --- go.mod | 6 +++--- go.sum | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 9cfa76c..1800fd6 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/Microsoft/cosesign1go -go 1.20 +go 1.21 require ( github.com/Microsoft/didx509go v0.0.3 - github.com/fxamacker/cbor/v2 v2.4.0 + github.com/fxamacker/cbor/v2 v2.5.0 github.com/sirupsen/logrus v1.9.3 github.com/urfave/cli v1.22.15 - github.com/veraison/go-cose v1.1.0 + github.com/veraison/go-cose v1.3.0 ) require ( diff --git a/go.sum b/go.sum index baf4647..7baf65d 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= -github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= +github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= @@ -47,8 +47,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli v1.22.15 h1:nuqt+pdC/KqswQKhETJjo7pvn/k4xMUxgW6liI7XpnM= github.com/urfave/cli v1.22.15/go.mod h1:wSan1hmo5zeyLGBjRJbzRTNk8gwoYa2B9n4q9dmRIc0= -github.com/veraison/go-cose v1.1.0 h1:AalPS4VGiKavpAzIlBjrn7bhqXiXi4jbMYY/2+UC+4o= -github.com/veraison/go-cose v1.1.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4= +github.com/veraison/go-cose v1.3.0 h1:2/H5w8kdSpQJyVtIhx8gmwPJ2uSz1PkyWFx0idbd7rk= +github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= From 4df2eb3f7051ea4dbe9d1bf2797d374a2c75dc23 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Tue, 9 Jun 2026 18:41:41 +0000 Subject: [PATCH 02/13] Add fetch-ledger-keyset command Assisted-by: GitHub Copilot:claude-opus-4.8 Signed-off-by: Tingmao Wang --- cmd/sign1util/ccf_keyfetch.go | 71 ++++++++++++++++++++++++++--------- cmd/sign1util/main.go | 34 +++++++++++++++++ go.mod | 2 +- pkg/cosesign1/check.go | 6 +++ 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/cmd/sign1util/ccf_keyfetch.go b/cmd/sign1util/ccf_keyfetch.go index 0392b8c..8dce2b3 100644 --- a/cmd/sign1util/ccf_keyfetch.go +++ b/cmd/sign1util/ccf_keyfetch.go @@ -18,6 +18,9 @@ import ( "time" "github.com/Microsoft/cosesign1go/pkg/cosesign1" + "github.com/fxamacker/cbor/v2" + "github.com/pkg/errors" + "github.com/veraison/go-cose" ) // jwk is a minimal JSON Web Key representation used to parse CCF transparency @@ -103,14 +106,19 @@ func validateIssuerForJWKSFetch(issuer string, allowedDomains []string) (string, // error aborts the connection. type CertVerifier func(issuer string, cert *x509.Certificate) error +type kidWithParsedKey struct { + Kid string + Key crypto.PublicKey +} + // fetchIssuerJWKS GETs https:///jwks and returns the keys keyed by -// their `kid`. If verifyCert is not nil, the leaf certificate presented by the -// server is passed to verifyCert. The issuer host is validated against -// allowedDomains before any network request is made. -func fetchIssuerJWKS(issuer string, allowedDomains []string, verifyCert CertVerifier) (map[string]crypto.PublicKey, error) { +// their `kid` as either a map or a list. If verifyCert is not nil, the leaf +// certificate presented by the server is passed to verifyCert. The issuer host +// is validated against allowedDomains before any network request is made. +func fetchIssuerJWKS(issuer string, allowedDomains []string, verifyCert CertVerifier) (outMap map[string]crypto.PublicKey, outList []kidWithParsedKey, err error) { host, err := validateIssuerForJWKSFetch(issuer, allowedDomains) if err != nil { - return nil, err + return nil, nil, err } reqURL := (&url.URL{Scheme: "https", Host: host, Path: "/jwks"}).String() tlsConfig := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // CCF uses self-signed certs that are supposed to be validated via attestation, and so will never pass the normal verification. @@ -135,43 +143,72 @@ func fetchIssuerJWKS(issuer string, allowedDomains []string, verifyCert CertVeri } resp, err := client.Get(reqURL) if err != nil { - return nil, fmt.Errorf("GET %s: %w", reqURL, err) + return nil, nil, fmt.Errorf("GET %s: %w", reqURL, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET %s: status %d", reqURL, resp.StatusCode) + return nil, nil, fmt.Errorf("GET %s: status %d", reqURL, resp.StatusCode) } // Limit the response body to a sane size to avoid excessive memory usage // from a hostile or misconfigured server. const maxJWKSBytes = 1 << 20 // 1 MiB body, err := io.ReadAll(io.LimitReader(resp.Body, maxJWKSBytes+1)) if err != nil { - return nil, fmt.Errorf("reading %s: %w", reqURL, err) + return nil, nil, fmt.Errorf("reading %s: %w", reqURL, err) } if int64(len(body)) > maxJWKSBytes { - return nil, fmt.Errorf("reading %s: response exceeds %d bytes", reqURL, maxJWKSBytes) + return nil, nil, fmt.Errorf("reading %s: response exceeds %d bytes", reqURL, maxJWKSBytes) } var set jwkSet if err := json.Unmarshal(body, &set); err != nil { - return nil, fmt.Errorf("parsing %s: %w", reqURL, err) + return nil, nil, fmt.Errorf("parsing %s: %w", reqURL, err) } - out := make(map[string]crypto.PublicKey, len(set.Keys)) + outMap = make(map[string]crypto.PublicKey, len(set.Keys)) + outList = make([]kidWithParsedKey, 0, len(set.Keys)) for i, k := range set.Keys { pub, err := jwkToPublicKey(k) if err != nil { - return nil, fmt.Errorf("key %d (kid=%s): %w", i, k.Kid, err) + return nil, nil, fmt.Errorf("key %d (kid=%s): %w", i, k.Kid, err) } - if existingKey, exists := out[k.Kid]; exists { + if existingKey, exists := outMap[k.Kid]; exists { // Equal is implemented for all crypto.PublicKey types in std eq, ok := existingKey.(interface{ Equal(crypto.PublicKey) bool }) if !ok || !eq.Equal(pub) { - return nil, fmt.Errorf("conflicting kid %s seen in JWKS from %s", k.Kid, reqURL) + return nil, nil, fmt.Errorf("conflicting kid %s seen in JWKS from %s", k.Kid, reqURL) } continue } - out[k.Kid] = pub + outMap[k.Kid] = pub + outList = append(outList, kidWithParsedKey{Kid: k.Kid, Key: pub}) + } + return outMap, outList, nil +} + +// Encodes a list of fetched keys into a COSE_KeySet. +func encodeKeySet(keys []kidWithParsedKey) ([]byte, error) { + if len(keys) == 0 { + return nil, errors.New("empty keys list") + } + rawKeys := make([]cbor.RawMessage, 0, len(keys)) + for _, kidWithKey := range keys { + kid := kidWithKey.Kid + pk := kidWithKey.Key + k, err := cose.NewKeyFromPublic(pk) + if err != nil { + return nil, errors.Wrapf(err, "construct cose.Key for key ID %q", kid) + } + k.ID = []byte(kid) + raw, err := k.MarshalCBOR() + if err != nil { + return nil, errors.Wrapf(err, "MarshalCBOR for key ID %q", kid) + } + rawKeys = append(rawKeys, raw) + } + data, err := cbor.Marshal(rawKeys) + if err != nil { + return nil, errors.Wrap(err, "Failed to encode the COSE_KeySet") } - return out, nil + return data, nil } // fetchCCFReceiptKeys returns a kid->PublicKey map by fetching the JWKS for @@ -190,7 +227,7 @@ func fetchCCFReceiptKeys(receipts []cosesign1.ParsedCOSEReceipt, allowedDomains continue } seen[r.Issuer] = true - issuerKeys, err := fetchIssuerJWKS(r.Issuer, allowedDomains, verifyCert) + issuerKeys, _, err := fetchIssuerJWKS(r.Issuer, allowedDomains, verifyCert) if err != nil { return nil, err } diff --git a/cmd/sign1util/main.go b/cmd/sign1util/main.go index feaae20..9e79f95 100644 --- a/cmd/sign1util/main.go +++ b/cmd/sign1util/main.go @@ -623,6 +623,39 @@ var chainCmd = cli.Command{ }, } +var fetchLedgerKeysetCmd = cli.Command{ + Name: "fetch-ledger-keyset", + Usage: "fetch the JWKS for a ledger and write it as a COSE_KeySet", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "ledger", + Usage: "ledger name (required)", + Required: true, + }, + cli.StringFlag{ + Name: "out", + Usage: "output COSE_KeySet file (required)", + Required: true, + }, + }, + Action: func(ctx *cli.Context) error { + ledger := ctx.String("ledger") + _, keyList, err := fetchIssuerJWKS(ledger, []string{ledger}, acceptAndPrintCert) + if err != nil { + return fmt.Errorf("fetching JWKS from ledger %q: %w", ledger, err) + } + keyset, err := encodeKeySet(keyList) + if err != nil { + return fmt.Errorf("encoding COSE_KeySet: %w", err) + } + if err := cosesign1.WriteBlob(ctx.String("out"), keyset); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + fmt.Fprintf(os.Stdout, "wrote COSE_KeySet with %d key(s) to %s\n", len(keyList), ctx.String("out")) + return nil + }, +} + func main() { app := cli.NewApp() app.Name = "sign1util" @@ -649,6 +682,7 @@ func main() { leafCmd, didX509Cmd, chainCmd, + fetchLedgerKeysetCmd, } if err := app.Run(os.Args); err != nil { diff --git a/go.mod b/go.mod index 1800fd6..96bef9b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/Microsoft/didx509go v0.0.3 github.com/fxamacker/cbor/v2 v2.5.0 + github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 github.com/urfave/cli v1.22.15 github.com/veraison/go-cose v1.3.0 @@ -20,7 +21,6 @@ require ( github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/jwx v1.2.29 // indirect github.com/lestrrat-go/option v1.0.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/crypto v0.21.0 // indirect diff --git a/pkg/cosesign1/check.go b/pkg/cosesign1/check.go index 7f10862..231771e 100644 --- a/pkg/cosesign1/check.go +++ b/pkg/cosesign1/check.go @@ -344,6 +344,12 @@ func asInt64(v interface{}) (int64, bool) { // r.Kid. // - The data-hash in the receipt matches the expected hash of the signed // statement it is for. +// +// keys is a map of key IDs to public keys for this ledger. The caller must +// acquire this via some other means, e.g. via a signed trusted key list, or via +// the JWKS endpoint of the ledger (see example code in +// cmd/sign1util/ccf_keyfetch.go) with additional attestation verification which +// is not implemented in this library. func (r ParsedCOSEReceipt) Validate(keys map[string]crypto.PublicKey) error { msg := r.Message From 9cae652dd54892ea1d5b8ddda6e8f308339bd083 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Tue, 9 Jun 2026 18:42:25 +0000 Subject: [PATCH 03/13] Add keyset parsing and let sign1util check be able to use a keyset instead of fetching keys. Assisted-by: GitHub Copilot:auto copilot-review Signed-off-by: Tingmao Wang --- cmd/sign1util/ccf_keyfetch.go | 19 ++++++++---- cmd/sign1util/main.go | 46 +++++++++++++++++++++++++++-- pkg/cosesign1/keyset.go | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 pkg/cosesign1/keyset.go diff --git a/cmd/sign1util/ccf_keyfetch.go b/cmd/sign1util/ccf_keyfetch.go index 8dce2b3..ca533bd 100644 --- a/cmd/sign1util/ccf_keyfetch.go +++ b/cmd/sign1util/ccf_keyfetch.go @@ -214,9 +214,10 @@ func encodeKeySet(keys []kidWithParsedKey) ([]byte, error) { // fetchCCFReceiptKeys returns a kid->PublicKey map by fetching the JWKS for // each unique receipt issuer. allowedDomains is the list of domains that // receipt issuers must match (equal or subdomain) before any network request -// is made. If not nil, verifyCert is invoked with the leaf certificate -// presented by each issuer. -func fetchCCFReceiptKeys(receipts []cosesign1.ParsedCOSEReceipt, allowedDomains []string, verifyCert CertVerifier) (map[string]crypto.PublicKey, error) { +// is made. If a receipt's issuer is present in preloaded, the supplied keys are +// used for that issuer instead of fetching its JWKS over the network. If not +// nil, verifyCert is invoked with the leaf certificate presented by each issuer. +func fetchCCFReceiptKeys(receipts []cosesign1.ParsedCOSEReceipt, allowedDomains []string, preloaded map[string]map[string]crypto.PublicKey, verifyCert CertVerifier) (map[string]crypto.PublicKey, error) { seen := map[string]bool{} keys := map[string]crypto.PublicKey{} for _, r := range receipts { @@ -227,9 +228,15 @@ func fetchCCFReceiptKeys(receipts []cosesign1.ParsedCOSEReceipt, allowedDomains continue } seen[r.Issuer] = true - issuerKeys, _, err := fetchIssuerJWKS(r.Issuer, allowedDomains, verifyCert) - if err != nil { - return nil, err + var issuerKeys map[string]crypto.PublicKey + if preloadedKeys, ok := preloaded[r.Issuer]; ok { + issuerKeys = preloadedKeys + } else { + var err error + issuerKeys, _, err = fetchIssuerJWKS(r.Issuer, allowedDomains, verifyCert) + if err != nil { + return nil, err + } } for kid, k := range issuerKeys { if _, exists := keys[kid]; exists { diff --git a/cmd/sign1util/main.go b/cmd/sign1util/main.go index 9e79f95..d291c5d 100644 --- a/cmd/sign1util/main.go +++ b/cmd/sign1util/main.go @@ -92,6 +92,10 @@ type checkCoseSign1Options struct { // issuer are validated; other receipts are ignored. JWKS fetching is // implicitly restricted to this domain. RequireReceiptFrom string + // LedgerKeysets maps a ledger (receipt issuer) name to a preloaded set of + // keys (kid->PublicKey). When a receipt's issuer is present here, those + // keys are used instead of fetching the ledger's JWKS over the network. + LedgerKeysets map[string]map[string]crypto.PublicKey } func checkCoseSign1(inputFilename string, chainFilename string, didString string, verbose bool, opts checkCoseSign1Options) (*cosesign1.UnpackedCoseSign1, error) { @@ -139,7 +143,7 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string fmt.Fprintf(os.Stdout, "no receipt from required issuer %q found\n", opts.RequireReceiptFrom) return nil, fmt.Errorf("no receipt from required issuer %q found", opts.RequireReceiptFrom) } - receiptKeys, err = fetchCCFReceiptKeys(matching, allowed, acceptAndPrintCert) + receiptKeys, err = fetchCCFReceiptKeys(matching, allowed, opts.LedgerKeysets, acceptAndPrintCert) if err != nil { fmt.Fprintf(os.Stdout, "fetching CCF receipt keys failed - %s\n", err) return nil, fmt.Errorf("fetching CCF receipt keys: %w", err) @@ -156,7 +160,7 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string fmt.Fprintf(os.Stdout, "ignored %d receipt(s) not from required issuer %q\n", ignored, opts.RequireReceiptFrom) } case opts.ValidateReceipts && len(unpacked.Receipts) > 0: - receiptKeys, err = fetchCCFReceiptKeys(unpacked.Receipts, opts.AllowedJWKSDomains, acceptAndPrintCert) + receiptKeys, err = fetchCCFReceiptKeys(unpacked.Receipts, opts.AllowedJWKSDomains, opts.LedgerKeysets, acceptAndPrintCert) if err != nil { fmt.Fprintf(os.Stdout, "fetching CCF receipt keys failed - %s\n", err) return nil, fmt.Errorf("fetching CCF receipt keys: %w", err) @@ -275,6 +279,35 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string return unpacked, err } +// parseLedgerKeysets parses --ledger-keyset specifications of the form +// "ledger_name:keyset_file". Each named keyset file is read and parsed as a +// COSE_KeySet, yielding a map from ledger name to its kid->PublicKey map. +func parseLedgerKeysets(specs []string) (map[string]map[string]crypto.PublicKey, error) { + if len(specs) == 0 { + return nil, nil + } + out := make(map[string]map[string]crypto.PublicKey, len(specs)) + for _, spec := range specs { + ledger, file, found := strings.Cut(spec, ":") + if !found || ledger == "" || file == "" { + return nil, fmt.Errorf("invalid --ledger-keyset %q: expected form ledger_name:keyset_file", spec) + } + if _, exists := out[ledger]; exists { + return nil, fmt.Errorf("duplicate --ledger-keyset for ledger %q", ledger) + } + data, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("reading keyset file %q for ledger %q: %w", file, ledger, err) + } + keys, err := cosesign1.ParseKeySetAsMap(data) + if err != nil { + return nil, fmt.Errorf("parsing keyset file %q for ledger %q: %w", file, ledger, err) + } + out[ledger] = keys + } + return out, nil +} + var createCmd = cli.Command{ Name: "create", Usage: "", @@ -394,10 +427,18 @@ var checkCmd = cli.Command{ Name: "require-receipt-from", Usage: "If set, require at least one attached transparent receipt to have this exact domain as its issuer, and validate it by fetching JWKS from this domain. Any other receipts present are ignored. Issuer matching is an exact equality check, not a subdomain match.", }, + cli.StringSliceFlag{ + Name: "ledger-keyset", + Usage: "Use a preloaded COSE_KeySet to validate receipts from a given ledger instead of fetching its JWKS. Form: ledger_name:keyset_file. May be repeated for multiple ledgers.", + }, }, Action: func(ctx *cli.Context) error { didString := ctx.String("did") requireFrom := ctx.String("require-receipt-from") + ledgerKeysets, err := parseLedgerKeysets(ctx.StringSlice("ledger-keyset")) + if err != nil { + return fmt.Errorf("failed check: %w", err) + } unpacked, err := checkCoseSign1( ctx.String("in"), ctx.String("chain"), @@ -405,6 +446,7 @@ var checkCmd = cli.Command{ ctx.Bool("verbose"), checkCoseSign1Options{ RequireReceiptFrom: requireFrom, + LedgerKeysets: ledgerKeysets, }, ) if err != nil { diff --git a/pkg/cosesign1/keyset.go b/pkg/cosesign1/keyset.go new file mode 100644 index 0000000..479418c --- /dev/null +++ b/pkg/cosesign1/keyset.go @@ -0,0 +1,55 @@ +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) + 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 _, exists := keys[kid]; exists { + logrus.Warnf("Parsing element %d of the COSE Key Set: Key with ID %q already seen earlier, 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 +} From 223699dff1d2b0c871072191841a0dc103897667 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Wed, 10 Jun 2026 01:05:42 +0000 Subject: [PATCH 04/13] ParseKeySetAsMap: Only warn when there are different keys with same KID Signed-off-by: Tingmao Wang --- pkg/cosesign1/keyset.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/cosesign1/keyset.go b/pkg/cosesign1/keyset.go index 479418c..abdbf33 100644 --- a/pkg/cosesign1/keyset.go +++ b/pkg/cosesign1/keyset.go @@ -41,9 +41,13 @@ func ParseKeySetAsMap(data []byte) (map[string]crypto.PublicKey, error) { lastKeyError = errors.Wrapf(err, "construct PublicKey from element %d", i) continue } - if _, exists := keys[kid]; exists { - logrus.Warnf("Parsing element %d of the COSE Key Set: Key with ID %q already seen earlier, ignoring this one", i, kid) - 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 } From fd8106a14c4b785f005786b96586a9a7a562c5c9 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Wed, 10 Jun 2026 09:47:17 +0000 Subject: [PATCH 05/13] Parses an actual (unsigned) TTL payload instead of just the COSE KeySet Assisted-by: GitHub Copilot:auto copilot-review Signed-off-by: Tingmao Wang --- cmd/sign1util/main.go | 72 ++++++++++++++++++++--------------------- pkg/cosesign1/keyset.go | 24 ++++++++++++++ 2 files changed, 59 insertions(+), 37 deletions(-) diff --git a/cmd/sign1util/main.go b/cmd/sign1util/main.go index d291c5d..129e8d7 100644 --- a/cmd/sign1util/main.go +++ b/cmd/sign1util/main.go @@ -92,10 +92,10 @@ type checkCoseSign1Options struct { // issuer are validated; other receipts are ignored. JWKS fetching is // implicitly restricted to this domain. RequireReceiptFrom string - // LedgerKeysets maps a ledger (receipt issuer) name to a preloaded set of - // keys (kid->PublicKey). When a receipt's issuer is present here, those - // keys are used instead of fetching the ledger's JWKS over the network. - LedgerKeysets map[string]map[string]crypto.PublicKey + // TTL maps a ledger (receipt issuer) name to a preloaded set of keys + // (kid->PublicKey). When a receipt's issuer is present here, those keys are + // used instead of fetching the ledger's JWKS over the network. + TTL map[string]map[string]crypto.PublicKey } func checkCoseSign1(inputFilename string, chainFilename string, didString string, verbose bool, opts checkCoseSign1Options) (*cosesign1.UnpackedCoseSign1, error) { @@ -143,7 +143,7 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string fmt.Fprintf(os.Stdout, "no receipt from required issuer %q found\n", opts.RequireReceiptFrom) return nil, fmt.Errorf("no receipt from required issuer %q found", opts.RequireReceiptFrom) } - receiptKeys, err = fetchCCFReceiptKeys(matching, allowed, opts.LedgerKeysets, acceptAndPrintCert) + receiptKeys, err = fetchCCFReceiptKeys(matching, allowed, opts.TTL, acceptAndPrintCert) if err != nil { fmt.Fprintf(os.Stdout, "fetching CCF receipt keys failed - %s\n", err) return nil, fmt.Errorf("fetching CCF receipt keys: %w", err) @@ -160,7 +160,7 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string fmt.Fprintf(os.Stdout, "ignored %d receipt(s) not from required issuer %q\n", ignored, opts.RequireReceiptFrom) } case opts.ValidateReceipts && len(unpacked.Receipts) > 0: - receiptKeys, err = fetchCCFReceiptKeys(unpacked.Receipts, opts.AllowedJWKSDomains, opts.LedgerKeysets, acceptAndPrintCert) + receiptKeys, err = fetchCCFReceiptKeys(unpacked.Receipts, opts.AllowedJWKSDomains, opts.TTL, acceptAndPrintCert) if err != nil { fmt.Fprintf(os.Stdout, "fetching CCF receipt keys failed - %s\n", err) return nil, fmt.Errorf("fetching CCF receipt keys: %w", err) @@ -279,33 +279,21 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string return unpacked, err } -// parseLedgerKeysets parses --ledger-keyset specifications of the form -// "ledger_name:keyset_file". Each named keyset file is read and parsed as a -// COSE_KeySet, yielding a map from ledger name to its kid->PublicKey map. -func parseLedgerKeysets(specs []string) (map[string]map[string]crypto.PublicKey, error) { - if len(specs) == 0 { +// parseTTLFile reads and parses an unsigned body of a Transparency Trust List +// (TTL) from a file. +func parseTTLFile(file string) (map[string]map[string]crypto.PublicKey, error) { + if file == "" { return nil, nil } - out := make(map[string]map[string]crypto.PublicKey, len(specs)) - for _, spec := range specs { - ledger, file, found := strings.Cut(spec, ":") - if !found || ledger == "" || file == "" { - return nil, fmt.Errorf("invalid --ledger-keyset %q: expected form ledger_name:keyset_file", spec) - } - if _, exists := out[ledger]; exists { - return nil, fmt.Errorf("duplicate --ledger-keyset for ledger %q", ledger) - } - data, err := os.ReadFile(file) - if err != nil { - return nil, fmt.Errorf("reading keyset file %q for ledger %q: %w", file, ledger, err) - } - keys, err := cosesign1.ParseKeySetAsMap(data) - if err != nil { - return nil, fmt.Errorf("parsing keyset file %q for ledger %q: %w", file, ledger, err) - } - out[ledger] = keys + data, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("reading TTL file %q: %w", file, err) + } + keysets, err := cosesign1.ParseTTLPayload(data) + if err != nil { + return nil, fmt.Errorf("parsing TTL file %q: %w", file, err) } - return out, nil + return keysets, nil } var createCmd = cli.Command{ @@ -427,17 +415,27 @@ var checkCmd = cli.Command{ Name: "require-receipt-from", Usage: "If set, require at least one attached transparent receipt to have this exact domain as its issuer, and validate it by fetching JWKS from this domain. Any other receipts present are ignored. Issuer matching is an exact equality check, not a subdomain match.", }, - cli.StringSliceFlag{ - Name: "ledger-keyset", - Usage: "Use a preloaded COSE_KeySet to validate receipts from a given ledger instead of fetching its JWKS. Form: ledger_name:keyset_file. May be repeated for multiple ledgers.", + cli.StringFlag{ + Name: "ttl", + Usage: "Optional path to a file containing an unsigned Transparency Trust List (TTL) payload, which is a CBOR map from issuer to COSE_KeySet. When a receipt's issuer is present in the TTL, its keys are used to validate the receipt instead of fetching the ledger's JWKS.", }, }, Action: func(ctx *cli.Context) error { didString := ctx.String("did") requireFrom := ctx.String("require-receipt-from") - ledgerKeysets, err := parseLedgerKeysets(ctx.StringSlice("ledger-keyset")) - if err != nil { - return fmt.Errorf("failed check: %w", err) + ttlFile := ctx.String("ttl") + var ttl map[string]map[string]crypto.PublicKey + if ttlFile != "" { + var err error + ttl, err = parseTTLFile(ttlFile) + if err != nil { + return fmt.Errorf("failed to load TTL: %w", err) + } + keys := make([]string, 0, len(ttl)) + for issuer, keyset := range ttl { + keys = append(keys, fmt.Sprintf("%s (%d keys)", issuer, len(keyset))) + } + logrus.Debugf("TTL contains %d issuers: %s", len(ttl), strings.Join(keys, ", ")) } unpacked, err := checkCoseSign1( ctx.String("in"), @@ -446,7 +444,7 @@ var checkCmd = cli.Command{ ctx.Bool("verbose"), checkCoseSign1Options{ RequireReceiptFrom: requireFrom, - LedgerKeysets: ledgerKeysets, + TTL: ttl, }, ) if err != nil { diff --git a/pkg/cosesign1/keyset.go b/pkg/cosesign1/keyset.go index abdbf33..ba87654 100644 --- a/pkg/cosesign1/keyset.go +++ b/pkg/cosesign1/keyset.go @@ -57,3 +57,27 @@ func ParseKeySetAsMap(data []byte) (map[string]crypto.PublicKey, error) { } return keys, nil } + +// ParseTTLPayload parses an unsigned body of a Transparency Trust List (TTL), +// which is a CBOR map from issuer strings to COSE_KeySet, into 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, rawKeySet := range rawIssuers { + 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 +} From 276958040ed6a9db89a52c812a19a3dbecf0dd55 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Wed, 10 Jun 2026 09:50:28 +0000 Subject: [PATCH 06/13] Add generation of unsigned TTLs Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- cmd/sign1util/ccf_keyfetch.go | 22 ++++++++++++++++++ cmd/sign1util/main.go | 43 +++++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/cmd/sign1util/ccf_keyfetch.go b/cmd/sign1util/ccf_keyfetch.go index ca533bd..22a0552 100644 --- a/cmd/sign1util/ccf_keyfetch.go +++ b/cmd/sign1util/ccf_keyfetch.go @@ -211,6 +211,28 @@ func encodeKeySet(keys []kidWithParsedKey) ([]byte, error) { return data, nil } +// encodeTTLPayload encodes a map from issuer to that issuer's fetched keys into +// an (unsigned) Transparency Trust List payload: a CBOR map from issuer strings +// to COSE_KeySet. +func encodeTTLPayload(issuerKeys map[string][]kidWithParsedKey) ([]byte, error) { + if len(issuerKeys) == 0 { + return nil, errors.New("empty TTL payload") + } + payload := make(map[string]cbor.RawMessage, len(issuerKeys)) + for issuer, keys := range issuerKeys { + keySet, err := encodeKeySet(keys) + if err != nil { + return nil, errors.Wrapf(err, "encoding COSE_KeySet for issuer %q", issuer) + } + payload[issuer] = keySet + } + data, err := cbor.Marshal(payload) + if err != nil { + return nil, errors.Wrap(err, "Failed to encode the TTL payload") + } + return data, nil +} + // fetchCCFReceiptKeys returns a kid->PublicKey map by fetching the JWKS for // each unique receipt issuer. allowedDomains is the list of domains that // receipt issuers must match (equal or subdomain) before any network request diff --git a/cmd/sign1util/main.go b/cmd/sign1util/main.go index 129e8d7..0c617f4 100644 --- a/cmd/sign1util/main.go +++ b/cmd/sign1util/main.go @@ -663,35 +663,44 @@ var chainCmd = cli.Command{ }, } -var fetchLedgerKeysetCmd = cli.Command{ - Name: "fetch-ledger-keyset", - Usage: "fetch the JWKS for a ledger and write it as a COSE_KeySet", +var ttlFromLedgerCmd = cli.Command{ + Name: "ttl-from-ledger", + Usage: "fetch the JWKS for one or more ledgers and write them as an (unsigned) Transparency Trust List", Flags: []cli.Flag{ - cli.StringFlag{ - Name: "ledger", - Usage: "ledger name (required)", - Required: true, + cli.StringSliceFlag{ + Name: "ledger", + Usage: "ledger name to fetch keys from; may be repeated to include multiple ledgers in the TTL (required)", }, cli.StringFlag{ Name: "out", - Usage: "output COSE_KeySet file (required)", + Usage: "output TTL file (required)", Required: true, }, }, Action: func(ctx *cli.Context) error { - ledger := ctx.String("ledger") - _, keyList, err := fetchIssuerJWKS(ledger, []string{ledger}, acceptAndPrintCert) - if err != nil { - return fmt.Errorf("fetching JWKS from ledger %q: %w", ledger, err) + ledgers := ctx.StringSlice("ledger") + if len(ledgers) == 0 { + return fmt.Errorf("at least one --ledger is required") + } + issuerKeys := make(map[string][]kidWithParsedKey, len(ledgers)) + for _, ledger := range ledgers { + if _, exists := issuerKeys[ledger]; exists { + return fmt.Errorf("duplicate --ledger %q", ledger) + } + _, keyList, err := fetchIssuerJWKS(ledger, []string{ledger}, acceptAndPrintCert) + if err != nil { + return fmt.Errorf("fetching JWKS from ledger %q: %w", ledger, err) + } + issuerKeys[ledger] = keyList } - keyset, err := encodeKeySet(keyList) + ttl, err := encodeTTLPayload(issuerKeys) if err != nil { - return fmt.Errorf("encoding COSE_KeySet: %w", err) + return fmt.Errorf("encoding TTL: %w", err) } - if err := cosesign1.WriteBlob(ctx.String("out"), keyset); err != nil { + if err := cosesign1.WriteBlob(ctx.String("out"), ttl); err != nil { return fmt.Errorf("failed to write output file: %w", err) } - fmt.Fprintf(os.Stdout, "wrote COSE_KeySet with %d key(s) to %s\n", len(keyList), ctx.String("out")) + fmt.Fprintf(os.Stdout, "wrote TTL with %d ledger(s) to %s\n", len(issuerKeys), ctx.String("out")) return nil }, } @@ -722,7 +731,7 @@ func main() { leafCmd, didX509Cmd, chainCmd, - fetchLedgerKeysetCmd, + ttlFromLedgerCmd, } if err := app.Run(os.Args); err != nil { From 8152a0258f479e6e28c30db496637a32b77171eb Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 15 Jun 2026 14:01:08 +0000 Subject: [PATCH 07/13] Suggestions from Amaury Signed-off-by: Tingmao Wang --- cmd/sign1util/main.go | 2 +- pkg/cosesign1/keyset.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/sign1util/main.go b/cmd/sign1util/main.go index 0c617f4..61c2201 100644 --- a/cmd/sign1util/main.go +++ b/cmd/sign1util/main.go @@ -417,7 +417,7 @@ var checkCmd = cli.Command{ }, cli.StringFlag{ Name: "ttl", - Usage: "Optional path to a file containing an unsigned Transparency Trust List (TTL) payload, which is a CBOR map from issuer to COSE_KeySet. When a receipt's issuer is present in the TTL, its keys are used to validate the receipt instead of fetching the ledger's JWKS.", + Usage: "Optional path to a file containing an unsigned Transparency Trust List (TTL) payload, which is a CBOR map from issuer to COSE_KeySet. When a receipt's issuer is present in the TTL, its keys are used to validate the receipt instead of fetching the keys from the ledger.", }, }, Action: func(ctx *cli.Context) error { diff --git a/pkg/cosesign1/keyset.go b/pkg/cosesign1/keyset.go index ba87654..4f15193 100644 --- a/pkg/cosesign1/keyset.go +++ b/pkg/cosesign1/keyset.go @@ -35,6 +35,11 @@ func ParseKeySetAsMap(data []byte) (map[string]crypto.PublicKey, error) { 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) From f5fd2256318f94e3c1c43665a401b34a75eefa99 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 15 Jun 2026 14:01:22 +0000 Subject: [PATCH 08/13] Use go 1.21 in CI Signed-off-by: Tingmao Wang --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0526a79..dcec069 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '1.20' + go-version: '1.21' - name: Build run: go build -v ./... @@ -28,6 +28,6 @@ jobs: run: | cd pkg/cosesign1 make test-all - + - name: Test run: go test -v ./... From 89d7676ed1f40da27f8e1f42170be0ece76fcb12 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Mon, 15 Jun 2026 14:19:44 +0000 Subject: [PATCH 09/13] Update TTL structure based on in-progress TTL spec Signed-off-by: Tingmao Wang --- cmd/sign1util/ccf_keyfetch.go | 9 +++++---- pkg/cosesign1/constants.go | 2 ++ pkg/cosesign1/keyset.go | 16 +++++++++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/cmd/sign1util/ccf_keyfetch.go b/cmd/sign1util/ccf_keyfetch.go index 22a0552..180425c 100644 --- a/cmd/sign1util/ccf_keyfetch.go +++ b/cmd/sign1util/ccf_keyfetch.go @@ -212,19 +212,20 @@ func encodeKeySet(keys []kidWithParsedKey) ([]byte, error) { } // encodeTTLPayload encodes a map from issuer to that issuer's fetched keys into -// an (unsigned) Transparency Trust List payload: a CBOR map from issuer strings -// to COSE_KeySet. +// an (unsigned) Transparency Trust List payload. func encodeTTLPayload(issuerKeys map[string][]kidWithParsedKey) ([]byte, error) { if len(issuerKeys) == 0 { return nil, errors.New("empty TTL payload") } - payload := make(map[string]cbor.RawMessage, len(issuerKeys)) + payload := make(map[string]map[int64]cbor.RawMessage, len(issuerKeys)) for issuer, keys := range issuerKeys { keySet, err := encodeKeySet(keys) if err != nil { return nil, errors.Wrapf(err, "encoding COSE_KeySet for issuer %q", issuer) } - payload[issuer] = keySet + payload[issuer] = map[int64]cbor.RawMessage{ + cosesign1.TTL_LedgerEntry_Keys: keySet, + } } data, err := cbor.Marshal(payload) if err != nil { diff --git a/pkg/cosesign1/constants.go b/pkg/cosesign1/constants.go index eb4d01f..f221525 100644 --- a/pkg/cosesign1/constants.go +++ b/pkg/cosesign1/constants.go @@ -39,3 +39,5 @@ const ( CWT_Issuer = int64(1) CWT_Subject = int64(2) ) + +const TTL_LedgerEntry_Keys = int64(1) diff --git a/pkg/cosesign1/keyset.go b/pkg/cosesign1/keyset.go index 4f15193..68b3cb7 100644 --- a/pkg/cosesign1/keyset.go +++ b/pkg/cosesign1/keyset.go @@ -64,8 +64,10 @@ func ParseKeySetAsMap(data []byte) (map[string]crypto.PublicKey, error) { } // ParseTTLPayload parses an unsigned body of a Transparency Trust List (TTL), -// which is a CBOR map from issuer strings to COSE_KeySet, into a map from -// issuer to that issuer's map of key IDs to public keys. +// 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) { @@ -77,7 +79,15 @@ func ParseTTLPayload(data []byte) (map[string]map[string]crypto.PublicKey, error return nil, errors.New("empty TTL payload") } out := make(map[string]map[string]crypto.PublicKey, len(rawIssuers)) - for issuer, rawKeySet := range 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) From 0a6b599c3846248829772bcb59c4236f43908a4c Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Thu, 18 Jun 2026 23:28:13 +0000 Subject: [PATCH 10/13] sign1util: support both signed and unsigned TTLs Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- cmd/sign1util/main.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/cmd/sign1util/main.go b/cmd/sign1util/main.go index 61c2201..c74b729 100644 --- a/cmd/sign1util/main.go +++ b/cmd/sign1util/main.go @@ -279,8 +279,10 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string return unpacked, err } -// parseTTLFile reads and parses an unsigned body of a Transparency Trust List -// (TTL) from a file. +// parseTTLFile reads and parses a Transparency Trust List (TTL) from a file. +// The file may be a raw (unsigned) TTL payload (a CBOR map), or a signed +// COSE_Sign1 envelope wrapping that payload; in the latter case the envelope is +// unwrapped to recover the payload. func parseTTLFile(file string) (map[string]map[string]crypto.PublicKey, error) { if file == "" { return nil, nil @@ -289,7 +291,20 @@ func parseTTLFile(file string) (map[string]map[string]crypto.PublicKey, error) { if err != nil { return nil, fmt.Errorf("reading TTL file %q: %w", file, err) } - keysets, err := cosesign1.ParseTTLPayload(data) + // A COSE_Sign1 is a CBOR tag 18 (#6.18) item, encoded as the single byte + // 0xD2 (major type 6 | tag 18). Treat anything carrying that tag as a + // signed envelope to be unwrapped; otherwise parse the bytes as a raw TTL + // payload. + const coseSign1TagByte = 0xC0 | cosesign1.COSE_Sign1_Tag // 0xD2 + payload := data + if len(data) > 0 && data[0] == coseSign1TagByte { + unpacked, err := cosesign1.UnpackAndValidateCOSE1CertChain(data) + if err != nil { + return nil, fmt.Errorf("unwrapping signed TTL file %q: %w", file, err) + } + payload = unpacked.Payload + } + keysets, err := cosesign1.ParseTTLPayload(payload) if err != nil { return nil, fmt.Errorf("parsing TTL file %q: %w", file, err) } From 07dbbdd502ee031e1dce181a2da8993f160c6192 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Thu, 18 Jun 2026 23:30:05 +0000 Subject: [PATCH 11/13] Add tests for TTL testing Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- pkg/cosesign1/aci-cc-ttl.ttl.cose | Bin 0 -> 6210 bytes pkg/cosesign1/cosesign1util_test.go | 33 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 pkg/cosesign1/aci-cc-ttl.ttl.cose diff --git a/pkg/cosesign1/aci-cc-ttl.ttl.cose b/pkg/cosesign1/aci-cc-ttl.ttl.cose new file mode 100644 index 0000000000000000000000000000000000000000..5504d7e8464f86deb1f40c37e995597062482c7b GIT binary patch literal 6210 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 YrXI3w9$nrk7>P*X2HhKdv6sVt0K#&j@c;k- literal 0 HcmV?d00001 diff --git a/pkg/cosesign1/cosesign1util_test.go b/pkg/cosesign1/cosesign1util_test.go index 4a18904..0ff8959 100644 --- a/pkg/cosesign1/cosesign1util_test.go +++ b/pkg/cosesign1/cosesign1util_test.go @@ -244,6 +244,7 @@ const ( envelopeReceiptCWTSubject = "scitt.ccf.signature.v1" graftedEnvelopeFile = "esrp_with_grafted_receipt.cose" graftedEnvelopeJWKSFile = "esrp_cp_ledger_pub_keys.json" + signedTTLFile = "aci-cc-ttl.ttl.cose" ) // Test_UnpackTransparentHashEnvelope verifies that a CWT-based envelope's @@ -399,3 +400,35 @@ func Test_GraftedReceiptIsRejected(t *testing.T) { t.Errorf("grafted receipt unexpectedly passed validation: envelope/receipt mismatch is not checked") } } + +// Test_ParseSignedTTLKeySet tests TTL parsing with a pre-made TTL. +func Test_ParseSignedTTLKeySet(t *testing.T) { + raw, err := os.ReadFile(signedTTLFile) + if err != nil { + t.Fatalf("reading %s: %s", signedTTLFile, err) + } + unpacked, err := UnpackAndValidateCOSE1CertChain(raw) + if err != nil { + t.Fatalf("UnpackAndValidateCOSE1CertChain failed: %s", err) + } + keysets, err := ParseTTLPayload(unpacked.Payload) + if err != nil { + t.Fatalf("ParseTTLPayload failed: %s", err) + } + if len(keysets) == 0 { + t.Fatalf("ParseTTLPayload returned no ledgers") + } + for issuer, keys := range keysets { + if len(keys) == 0 { + t.Errorf("ledger %q has no keys", issuer) + } + for kid, pk := range keys { + if kid == "" { + t.Errorf("ledger %q has a key with an empty kid", issuer) + } + if pk == nil { + t.Errorf("ledger %q kid %q has a nil public key", issuer, kid) + } + } + } +} From 885d95a6f36980600ecddfefaea0e01e0dba9b29 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Thu, 18 Jun 2026 23:46:21 +0000 Subject: [PATCH 12/13] Add ability to dump a TTL as JWKs JSON Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- cmd/sign1util/ccf_keyfetch.go | 48 ---------------- cmd/sign1util/jwks.go | 104 ++++++++++++++++++++++++++++++++++ cmd/sign1util/main.go | 35 ++++++++++++ 3 files changed, 139 insertions(+), 48 deletions(-) create mode 100644 cmd/sign1util/jwks.go diff --git a/cmd/sign1util/ccf_keyfetch.go b/cmd/sign1util/ccf_keyfetch.go index 180425c..4d41558 100644 --- a/cmd/sign1util/ccf_keyfetch.go +++ b/cmd/sign1util/ccf_keyfetch.go @@ -2,15 +2,11 @@ package main import ( "crypto" - "crypto/ecdsa" - "crypto/elliptic" "crypto/tls" "crypto/x509" - "encoding/base64" "encoding/json" "fmt" "io" - "math/big" "net/http" "net/url" "regexp" @@ -23,50 +19,6 @@ import ( "github.com/veraison/go-cose" ) -// jwk is a minimal JSON Web Key representation used to parse CCF transparency -// service /jwks responses. -type jwk struct { - Kty string `json:"kty"` - Crv string `json:"crv"` - Kid string `json:"kid"` - X string `json:"x"` - Y string `json:"y"` -} - -type jwkSet struct { - Keys []jwk `json:"keys"` -} - -func jwkToPublicKey(k jwk) (crypto.PublicKey, error) { - if k.Kty != "EC" { - return nil, fmt.Errorf("unsupported kty %q", k.Kty) - } - var curve elliptic.Curve - switch k.Crv { - case "P-256": - curve = elliptic.P256() - case "P-384": - curve = elliptic.P384() - case "P-521": - curve = elliptic.P521() - default: - return nil, fmt.Errorf("unsupported curve %q", k.Crv) - } - xBytes, err := base64.RawURLEncoding.DecodeString(k.X) - if err != nil { - return nil, fmt.Errorf("decoding x: %w", err) - } - yBytes, err := base64.RawURLEncoding.DecodeString(k.Y) - if err != nil { - return nil, fmt.Errorf("decoding y: %w", err) - } - return &ecdsa.PublicKey{ - Curve: curve, - X: new(big.Int).SetBytes(xBytes), - Y: new(big.Int).SetBytes(yBytes), - }, nil -} - // DefaultAllowedJWKSDomains is the default allow list of domains from which // JWKS will be fetched when validating CCF receipts. By default only Azure // Confidential Ledger endpoints are permitted; additional domains can be diff --git a/cmd/sign1util/jwks.go b/cmd/sign1util/jwks.go new file mode 100644 index 0000000..cff1e2f --- /dev/null +++ b/cmd/sign1util/jwks.go @@ -0,0 +1,104 @@ +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "sort" +) + +// jwk is a minimal JSON Web Key representation used to parse CCF transparency +// service /jwks responses. +type jwk struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + Kid string `json:"kid"` + X string `json:"x"` + Y string `json:"y"` +} + +type jwkSet struct { + Keys []jwk `json:"keys"` +} + +func jwkToPublicKey(k jwk) (crypto.PublicKey, error) { + if k.Kty != "EC" { + return nil, fmt.Errorf("unsupported kty %q", k.Kty) + } + var curve elliptic.Curve + switch k.Crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("unsupported curve %q", k.Crv) + } + xBytes, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { + return nil, fmt.Errorf("decoding x: %w", err) + } + yBytes, err := base64.RawURLEncoding.DecodeString(k.Y) + if err != nil { + return nil, fmt.Errorf("decoding y: %w", err) + } + return &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + }, nil +} + +// publicKeyToJWK converts an EC public key into a jwk. kid is the COSE key ID +// (used verbatim as the JWK kid). Only EC keys are supported. +func publicKeyToJWK(kid string, pk crypto.PublicKey) (jwk, error) { + ec, ok := pk.(*ecdsa.PublicKey) + if !ok { + return jwk{}, fmt.Errorf("unsupported key type %T (only EC keys are supported)", pk) + } + var crv string + switch ec.Curve { + case elliptic.P256(): + crv = "P-256" + case elliptic.P384(): + crv = "P-384" + case elliptic.P521(): + crv = "P-521" + default: + return jwk{}, fmt.Errorf("unsupported curve %q", ec.Curve.Params().Name) + } + byteLen := (ec.Curve.Params().BitSize + 7) / 8 + return jwk{ + Kty: "EC", + Kid: kid, + Crv: crv, + X: base64.RawURLEncoding.EncodeToString(ec.X.FillBytes(make([]byte, byteLen))), + Y: base64.RawURLEncoding.EncodeToString(ec.Y.FillBytes(make([]byte, byteLen))), + }, nil +} + +// ttlToJWKSJSON renders a parsed TTL as prettified JSON of the form +// {"ledger-name": {"keys": [jwk, ...]}}. Keys within each ledger are sorted by +// kid for deterministic output. +func ttlToJWKSJSON(ttl map[string]map[string]crypto.PublicKey) ([]byte, error) { + out := make(map[string]jwkSet, len(ttl)) + for issuer, keys := range ttl { + jwks := make([]jwk, 0, len(keys)) + for kid, pk := range keys { + j, err := publicKeyToJWK(kid, pk) + if err != nil { + return nil, fmt.Errorf("ledger %q: %w", issuer, err) + } + jwks = append(jwks, j) + } + sort.Slice(jwks, func(i, j int) bool { return jwks[i].Kid < jwks[j].Kid }) + out[issuer] = jwkSet{Keys: jwks} + } + return json.MarshalIndent(out, "", " ") +} diff --git a/cmd/sign1util/main.go b/cmd/sign1util/main.go index c74b729..dce090a 100644 --- a/cmd/sign1util/main.go +++ b/cmd/sign1util/main.go @@ -678,6 +678,40 @@ var chainCmd = cli.Command{ }, } +var dumpTTLCmd = cli.Command{ + Name: "dump-ttl", + Usage: "parse a (signed or unsigned) Transparency Trust List and print its keys as prettified JSON", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "in", + Usage: "input TTL file, either a raw unsigned TTL payload or a signed COSE_Sign1 envelope (required)", + Required: true, + }, + cli.StringFlag{ + Name: "out", + Usage: "output JSON file (default: stdout)", + }, + }, + Action: func(ctx *cli.Context) error { + ttl, err := parseTTLFile(ctx.String("in")) + if err != nil { + return fmt.Errorf("failed to load TTL: %w", err) + } + jsonBytes, err := ttlToJWKSJSON(ttl) + if err != nil { + return fmt.Errorf("rendering TTL as JSON: %w", err) + } + if out := ctx.String("out"); out != "" { + if err := cosesign1.WriteBlob(out, jsonBytes); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + } else { + fmt.Fprintf(os.Stdout, "%s\n", string(jsonBytes)) + } + return nil + }, +} + var ttlFromLedgerCmd = cli.Command{ Name: "ttl-from-ledger", Usage: "fetch the JWKS for one or more ledgers and write them as an (unsigned) Transparency Trust List", @@ -746,6 +780,7 @@ func main() { leafCmd, didX509Cmd, chainCmd, + dumpTTLCmd, ttlFromLedgerCmd, } From 1694c76ac30e5f29161915732e34ff08cf3c25e0 Mon Sep 17 00:00:00 2001 From: Tingmao Wang Date: Thu, 18 Jun 2026 23:49:06 +0000 Subject: [PATCH 13/13] Add test for TTL parsing Assisted-by: GitHub Copilot copilot-review Signed-off-by: Tingmao Wang --- pkg/cosesign1/aci-cc-ttl.ttl.json | 91 +++++++++++++++++++++++++++++ pkg/cosesign1/cosesign1util_test.go | 81 +++++++++++++++++++++---- 2 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 pkg/cosesign1/aci-cc-ttl.ttl.json diff --git a/pkg/cosesign1/aci-cc-ttl.ttl.json b/pkg/cosesign1/aci-cc-ttl.ttl.json new file mode 100644 index 0000000..0e3b5ad --- /dev/null +++ b/pkg/cosesign1/aci-cc-ttl.ttl.json @@ -0,0 +1,91 @@ +{ + "esrp-cts-cp.confidential-ledger.azure.com": { + "keys": [ + { + "kty": "EC", + "crv": "P-384", + "kid": "a7ad3b7729516ca443fa472a0f2faa4a984ee3da7eafd17f98dcffbac4a6a10f", + "x": "m0kQ1A_uqHWuP9fdGSKatSq2brcAJ6-q3aZ5P35wjbgtNnlm2u-NLF1qM-yC4I2n", + "y": "J9cJFrdWvUf6PCMkrWFTgB16uEq4mSMCI4NPVytnwYX6xNnuJ2GTrPtafKYg1VNi" + } + ] + }, + "esrp-cts-db.confidential-ledger.azure.com": { + "keys": [ + { + "kty": "EC", + "crv": "P-384", + "kid": "23d48c280f71abf575c81e89f18a4dc9f3b33d8a3b149b16ad836c8553f95bc0", + "x": "2GIJv9nAhste7hDWrpea1-hd_BAPXg4ZIxLy4C4hAX2eCpqT4siLqohA2KIVJti8", + "y": "aTT6XYHZPBgdI4RLFo2BaP1RVuOG2rFg5JBhYvt871HIwmtzNtwXl3_NBwfcqr8O" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "da7694f16def5a056ca96afb21e89a9450e4cc875e2de351da76d99544a3e849", + "x": "GeQ_qA3ZxYoaan3D0nA7xriMcmiMqQ0UNY1DLs7C5kIEaI_RL_2duRcG1Ii6g-8-", + "y": "uKiRr4UU8aXumcA8wu6LOatH0qL2AjFy3_8iBx3mbt1foS5xNHlXchMMLTSCvRLn" + } + ] + }, + "esrp-cts-dev.confidential-ledger.azure.com": { + "keys": [ + { + "kty": "EC", + "crv": "P-384", + "kid": "46cfd71010b47ff5aed2f9df227c64dd1c9d41ff176b361418485128388e1743", + "x": "bhzry10ABDgGDmQXg93mFEwgSSK-ipreAagJQ_Ndr_sJAqc3boJhkuYYhcZtTC6F", + "y": "1KgEY8QcZK7xSKrIb0uYSunrI-uwxfgaGc0AYu2y3SSShTlpBRFUKKgl0-KMjCkL" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "46de8a67a5c6f7973b08ee68ddb055012260f9ce7dcf3bc68441ca51a23557f9", + "x": "56jLddbvtyM_E5wbxt8fvQ2vWUMcUr7FYnk28ffCZdt9wbaje1-u7BSw6iHlnckm", + "y": "YZzrJKJseIb_-q3IcoVQj4np-KafeObbcpIAfAD1Qcf_djsY5MfYWarR0zDGvPgD" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "9d5188c30b7aecf7b41efc036e319539df4ff3f92b5fe73d7421b7b00797efd6", + "x": "aolMQOA93pZGmpx4PNK606dGj9W1TJA7OiV5OGGXRjZHdvweFQAz8UXrOaL3VHhl", + "y": "7E_zBCvi9uYMChth4-te0GjEjBRMWv6puMa5xaZtUhdDFdEr0aYKNTOIjE5kiUXV" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "c655c18e511fa8e7d79f45f1c27feb4f3fd38764bb04ec485f17bc268062c2b1", + "x": "qh-rYFDD_OkPpOlUVvEPoq7WGVqkIp7ZFZ3bRJRiXlYOy72aDTXrXfsbRqE1kG3c", + "y": "jAHU-p01zOWxLpsoGI6WWxdvV5b8prvg260GoQOOUm0tXeSNwvGHid0eGDC3qH6M" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "c67ec820d26a8244870e3bf4fabfae9fae708dd5fad91058b13aa3d84d0c2cf9", + "x": "hwOsdjy-k2i0IzAdVi_CF6wX_VeqngDrC1_W6IVn2TvUsTrhYZdYS2c3Bg7mWbaS", + "y": "-b0KIKgyaBDUtvy3HhCeDtZs0EVcuq1kuyWNXDgemyyf_5zeqn9IWu178aCtxzsZ" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "c99fc3b42033f4773f36a8daf2daa431783ee385f6ad6405121aed144b4a1b8f", + "x": "YdYn3rv7XzOtafJrGx7n9u30tRwJJ1s7blLTzmVOXgU6wqcckucDFYdwT9R6WW_x", + "y": "beg_TRngn8MHtLDJF0vPc694NQxQhb36qAi3P-FInva76N6-N_JviS9SUw0GS7fE" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "cd73d37679fb39218c7e12d24cb443504d8535e783714d5529ebac335e897e85", + "x": "8lbnXdLxieQHMOFvfxQDTXTO8VY2-lrJO2YMKAKGf6A9kMNXaeR4oZEDN6XF5p-h", + "y": "uoJwrb4zUuAYe9CWyXhVJ5e2Fa-EQOihZHTbqEPtU5__kxq00HVvChxiZ5XZ0p47" + }, + { + "kty": "EC", + "crv": "P-384", + "kid": "dc5e4e671c3acc13fbb1d601ce84531e6a67c7ca003fe89805533471901f04a7", + "x": "JltCvnallmAFxQAaf7_TnPmS8XHgQCn70cOXte8uAZcr3RWtHYvt5iaOCn6q6EL-", + "y": "wQqGKW2g-FmI8bbMk2DBDaskQkKGgmPs_AV7ac5wU1YxyiEb0DOn_krv1U13IsN_" + } + ] + } +} \ No newline at end of file diff --git a/pkg/cosesign1/cosesign1util_test.go b/pkg/cosesign1/cosesign1util_test.go index 0ff8959..372f174 100644 --- a/pkg/cosesign1/cosesign1util_test.go +++ b/pkg/cosesign1/cosesign1util_test.go @@ -245,6 +245,7 @@ const ( graftedEnvelopeFile = "esrp_with_grafted_receipt.cose" graftedEnvelopeJWKSFile = "esrp_cp_ledger_pub_keys.json" signedTTLFile = "aci-cc-ttl.ttl.cose" + signedTTLJSONFile = "aci-cc-ttl.ttl.json" ) // Test_UnpackTransparentHashEnvelope verifies that a CWT-based envelope's @@ -401,7 +402,11 @@ func Test_GraftedReceiptIsRejected(t *testing.T) { } } -// Test_ParseSignedTTLKeySet tests TTL parsing with a pre-made TTL. +// Test_ParseSignedTTLKeySet tests TTL parsing with a pre-made TTL. It unwraps +// the COSE_Sign1 envelope, parses the TTL payload (which exercises +// ParseKeySetAsMap on the COSE_KeySet carried for each ledger), and compares the +// recovered keys against the committed JSON dump (produced by `sign1util +// dump-ttl`). func Test_ParseSignedTTLKeySet(t *testing.T) { raw, err := os.ReadFile(signedTTLFile) if err != nil { @@ -415,20 +420,74 @@ func Test_ParseSignedTTLKeySet(t *testing.T) { if err != nil { t.Fatalf("ParseTTLPayload failed: %s", err) } - if len(keysets) == 0 { - t.Fatalf("ParseTTLPayload returned no ledgers") + + // Load the expected keys from the committed JSON dump. + expectedRaw, err := os.ReadFile(signedTTLJSONFile) + if err != nil { + t.Fatalf("reading %s: %s", signedTTLJSONFile, err) + } + var expected map[string]struct { + Keys []struct { + Kty, Kid, Crv, X, Y string + } } - for issuer, keys := range keysets { - if len(keys) == 0 { - t.Errorf("ledger %q has no keys", issuer) + if err := json.Unmarshal(expectedRaw, &expected); err != nil { + t.Fatalf("parsing %s: %s", signedTTLJSONFile, err) + } + + if len(keysets) != len(expected) { + t.Fatalf("parsed %d ledgers, expected %d", len(keysets), len(expected)) + } + for issuer, ledger := range expected { + keys, ok := keysets[issuer] + if !ok { + t.Errorf("ledger %q present in JSON but missing from parsed TTL", issuer) + continue + } + if len(keys) != len(ledger.Keys) { + t.Errorf("ledger %q has %d keys, expected %d", issuer, len(keys), len(ledger.Keys)) } - for kid, pk := range keys { - if kid == "" { - t.Errorf("ledger %q has a key with an empty kid", issuer) + for _, jwk := range ledger.Keys { + gotPk, ok := keys[jwk.Kid] + if !ok { + t.Errorf("ledger %q missing expected kid %q", issuer, jwk.Kid) + continue } - if pk == nil { - t.Errorf("ledger %q kid %q has a nil public key", issuer, kid) + wantPk := ecdsaPublicKeyFromXY(t, jwk.Crv, jwk.X, jwk.Y) + eq, ok := gotPk.(interface{ Equal(crypto.PublicKey) bool }) + if !ok || !eq.Equal(wantPk) { + t.Errorf("ledger %q kid %q public key does not match expected", issuer, jwk.Kid) } } } } + +// ecdsaPublicKeyFromXY reconstructs an EC public key from its JWK curve name and +// base64url-encoded coordinates. +func ecdsaPublicKeyFromXY(t *testing.T, crv, x, y string) *ecdsa.PublicKey { + t.Helper() + var curve elliptic.Curve + switch crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + t.Fatalf("unsupported crv %q", crv) + } + xb, err := base64.RawURLEncoding.DecodeString(x) + if err != nil { + t.Fatalf("decoding x: %s", err) + } + yb, err := base64.RawURLEncoding.DecodeString(y) + if err != nil { + t.Fatalf("decoding y: %s", err) + } + return &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xb), + Y: new(big.Int).SetBytes(yb), + } +}