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 ./... diff --git a/cmd/sign1util/ccf_keyfetch.go b/cmd/sign1util/ccf_keyfetch.go index 0392b8c..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" @@ -18,52 +14,11 @@ 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 -// 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 @@ -103,14 +58,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,51 +95,104 @@ 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 data, nil +} + +// encodeTTLPayload encodes a map from issuer to that issuer's fetched keys into +// 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]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] = map[int64]cbor.RawMessage{ + cosesign1.TTL_LedgerEntry_Keys: keySet, + } + } + data, err := cbor.Marshal(payload) + if err != nil { + return nil, errors.Wrap(err, "Failed to encode the TTL payload") } - return out, nil + 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 -// 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 { @@ -190,9 +203,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/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 feaae20..dce090a 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 + // 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) { @@ -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.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) @@ -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.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) @@ -275,6 +279,38 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string return unpacked, err } +// 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 + } + data, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("reading TTL file %q: %w", file, err) + } + // 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) + } + return keysets, nil +} + var createCmd = cli.Command{ Name: "create", Usage: "", @@ -394,10 +430,28 @@ 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.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 keys from the ledger.", + }, }, Action: func(ctx *cli.Context) error { didString := ctx.String("did") requireFrom := ctx.String("require-receipt-from") + 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"), ctx.String("chain"), @@ -405,6 +459,7 @@ var checkCmd = cli.Command{ ctx.Bool("verbose"), checkCoseSign1Options{ RequireReceiptFrom: requireFrom, + TTL: ttl, }, ) if err != nil { @@ -623,6 +678,82 @@ 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", + Flags: []cli.Flag{ + 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 TTL file (required)", + Required: true, + }, + }, + Action: func(ctx *cli.Context) error { + 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 + } + ttl, err := encodeTTLPayload(issuerKeys) + if err != nil { + return fmt.Errorf("encoding TTL: %w", err) + } + 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 TTL with %d ledger(s) to %s\n", len(issuerKeys), ctx.String("out")) + return nil + }, +} + func main() { app := cli.NewApp() app.Name = "sign1util" @@ -649,6 +780,8 @@ func main() { leafCmd, didX509Cmd, chainCmd, + dumpTTLCmd, + ttlFromLedgerCmd, } if err := app.Run(os.Args); err != nil { diff --git a/go.mod b/go.mod index 9cfa76c..96bef9b 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,14 @@ 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/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.1.0 + github.com/veraison/go-cose v1.3.0 ) require ( @@ -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/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= diff --git a/pkg/cosesign1/aci-cc-ttl.ttl.cose b/pkg/cosesign1/aci-cc-ttl.ttl.cose new file mode 100644 index 0000000..5504d7e Binary files /dev/null and b/pkg/cosesign1/aci-cc-ttl.ttl.cose differ 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/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 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/cosesign1util_test.go b/pkg/cosesign1/cosesign1util_test.go index 4a18904..372f174 100644 --- a/pkg/cosesign1/cosesign1util_test.go +++ b/pkg/cosesign1/cosesign1util_test.go @@ -244,6 +244,8 @@ 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" + signedTTLJSONFile = "aci-cc-ttl.ttl.json" ) // Test_UnpackTransparentHashEnvelope verifies that a CWT-based envelope's @@ -399,3 +401,93 @@ 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. 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 { + 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) + } + + // 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 + } + } + 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 _, jwk := range ledger.Keys { + gotPk, ok := keys[jwk.Kid] + if !ok { + t.Errorf("ledger %q missing expected kid %q", issuer, jwk.Kid) + continue + } + 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), + } +} diff --git a/pkg/cosesign1/keyset.go b/pkg/cosesign1/keyset.go new file mode 100644 index 0000000..68b3cb7 --- /dev/null +++ b/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 +}