-
Notifications
You must be signed in to change notification settings - Fork 6
Implement handling of COSE_KeySet and TTL payload #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b23f30b
4df2eb3
9cae652
223699d
fd8106a
2769580
8152a02
f5fd225
89d7676
0a6b599
07dbbdd
885d95a
1694c76
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,68 +2,23 @@ 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" | ||
| "strings" | ||
| "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://<issuer>/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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Recent versions of scitt-ccf-ledger implement .well-known/scitt-key, as well as /jwks. It would be good to check if that's rolled out already or when it is, to switch to it, because:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, "", " ") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's unfortunate there is not a go version of EverCBOR, I wonder if it's worth bringing up with Tahina?