Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand All @@ -28,6 +28,6 @@ jobs:
run: |
cd pkg/cosesign1
make test-all

- name: Test
run: go test -v ./...
159 changes: 89 additions & 70 deletions cmd/sign1util/ccf_keyfetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Member

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?

"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
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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:

  1. It's going to be the standard endpoint, and so it is more durable long term
  2. It does not require conversion from JWK to COSE_Key

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://esrp-cts-dev.confidential-ledger.azure.com/.well-known/scitt-keys (or scitt-key) still returns 404. I will make this use the scitt-keys endpoint when it's available in a future PR.

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.
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
104 changes: 104 additions & 0 deletions cmd/sign1util/jwks.go
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, "", " ")
}
Loading
Loading