Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
88 changes: 66 additions & 22 deletions cmd/sign1util/ccf_keyfetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import (
"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
Expand Down Expand Up @@ -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://<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 +143,81 @@ 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
// 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 +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 {
Expand Down
80 changes: 78 additions & 2 deletions cmd/sign1util/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Comment thread
micromaomao marked this conversation as resolved.
Outdated
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: "",
Expand Down Expand Up @@ -394,17 +427,26 @@ 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"),
didString,
ctx.Bool("verbose"),
checkCoseSign1Options{
RequireReceiptFrom: requireFrom,
LedgerKeysets: ledgerKeysets,
},
)
if err != nil {
Expand Down Expand Up @@ -623,6 +665,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)
Comment thread
micromaomao marked this conversation as resolved.
Outdated
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"
Expand All @@ -649,6 +724,7 @@ func main() {
leafCmd,
didX509Cmd,
chainCmd,
fetchLedgerKeysetCmd,
}

if err := app.Run(os.Args); err != nil {
Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
module github.com/Microsoft/cosesign1go

go 1.20
go 1.21
Comment thread
micromaomao marked this conversation as resolved.

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 (
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
6 changes: 6 additions & 0 deletions pkg/cosesign1/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading