diff --git a/cmd/attach_receipt/main.go b/cmd/attach_receipt/main.go new file mode 100644 index 0000000..bb119c0 --- /dev/null +++ b/cmd/attach_receipt/main.go @@ -0,0 +1,130 @@ +// attach_receipt rewrites a COSE_Sign1 envelope's unprotected `receipts` +// header (label 394), replacing it with either a single raw receipt blob or +// with the receipts copied from a donor COSE_Sign1 envelope. The protected +// header, payload, and signature of the input envelope are preserved +// byte-for-byte by editing the outer CBOR array in place. +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/fxamacker/cbor/v2" + + "github.com/Microsoft/cosesign1go/pkg/cosesign1" +) + +func main() { + in := flag.String("in", "", "input COSE_Sign1 envelope file") + out := flag.String("out", "", "output COSE_Sign1 envelope file") + donor := flag.String("donor", "", "donor COSE_Sign1 envelope to steal receipts from") + receipt := flag.String("receipt", "", "raw COSE_Sign1 receipt blob to attach (alternative to --donor)") + flag.Parse() + + if *in == "" || *out == "" || (*donor == "") == (*receipt == "") { + fmt.Fprintln(os.Stderr, "usage: attach_receipt -in IN.cose -out OUT.cose (-donor DONOR.cose | -receipt R.bin)") + os.Exit(2) + } + + inBytes, err := os.ReadFile(*in) + check(err) + + var receipts []interface{} + if *donor != "" { + dBytes, err := os.ReadFile(*donor) + check(err) + receipts, err = extractReceipts(dBytes) + check(err) + if len(receipts) == 0 { + die("donor envelope has no receipts in unprotected header") + } + } else { + rBytes, err := os.ReadFile(*receipt) + check(err) + receipts = []interface{}{rBytes} + } + + patched, err := replaceReceipts(inBytes, receipts) + check(err) + check(os.WriteFile(*out, patched, 0o644)) +} + +// rawSign1 is a COSE_Sign1 represented as a 4-element CBOR array, keeping +// each element as a RawMessage so the protected bstr, payload, and signature +// can be preserved verbatim across re-encoding. +type rawSign1 struct { + _ struct{} `cbor:",toarray"` + Protected cbor.RawMessage + Unprotected map[interface{}]interface{} + Payload cbor.RawMessage + Signature cbor.RawMessage +} + +// decodeSign1 parses a COSE_Sign1, stripping the optional CBOR tag (18). +func decodeSign1(data []byte) (rawSign1, bool, error) { + // COSE_Sign1 tag is encoded as 0xd2 (major type 6, value 18). + hadTag := len(data) > 0 && data[0] == 0xd2 + var msg rawSign1 + if err := cbor.Unmarshal(data, &msg); err != nil { + return rawSign1{}, false, fmt.Errorf("decoding COSE_Sign1: %w", err) + } + return msg, hadTag, nil +} + +func extractReceipts(data []byte) ([]interface{}, error) { + msg, _, err := decodeSign1(data) + if err != nil { + return nil, err + } + val, ok := msg.Unprotected[int64(cosesign1.COSE_Header_Receipts)] + if !ok { + // fxamacker/cbor may decode small int keys as uint64. + val, ok = msg.Unprotected[uint64(cosesign1.COSE_Header_Receipts)] + } + if !ok { + return nil, nil + } + arr, ok := val.([]interface{}) + if !ok { + return nil, fmt.Errorf("receipts header is not an array (got %T)", val) + } + return arr, nil +} + +func replaceReceipts(data []byte, receipts []interface{}) ([]byte, error) { + msg, hadTag, err := decodeSign1(data) + if err != nil { + return nil, err + } + // Delete any existing receipts entry (under either int key type) then set. + delete(msg.Unprotected, int64(cosesign1.COSE_Header_Receipts)) + delete(msg.Unprotected, uint64(cosesign1.COSE_Header_Receipts)) + msg.Unprotected[int64(cosesign1.COSE_Header_Receipts)] = receipts + + encOpts := cbor.CoreDetEncOptions() + encOpts.Sort = cbor.SortNone + em, err := encOpts.EncMode() + if err != nil { + return nil, err + } + body, err := em.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("encoding patched COSE_Sign1: %w", err) + } + if hadTag { + return em.Marshal(cbor.Tag{Number: 18, Content: cbor.RawMessage(body)}) + } + return body, nil +} + +func check(err error) { + if err != nil { + die(err.Error()) + } +} + +func die(msg string) { + fmt.Fprintln(os.Stderr, "attach_receipt:", msg) + os.Exit(1) +} diff --git a/cmd/sign1util/ccf_keyfetch.go b/cmd/sign1util/ccf_keyfetch.go new file mode 100644 index 0000000..5b93f28 --- /dev/null +++ b/cmd/sign1util/ccf_keyfetch.go @@ -0,0 +1,154 @@ +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "time" + + "github.com/Microsoft/cosesign1go/pkg/cosesign1" +) + +// 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 +} + +// CertVerifier is invoked with the leaf certificate presented by a CCF node +// during TLS handshake. Returning nil accepts the certificate; returning an +// error aborts the connection. +type CertVerifier func(issuer string, cert *x509.Certificate) error + +// 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. +func fetchIssuerJWKS(issuer string, verifyCert CertVerifier) (map[string]crypto.PublicKey, error) { + url := "https://" + issuer + "/jwks" + 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. + if verifyCert != nil { + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return fmt.Errorf("server presented no certificate") + } + cert, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return fmt.Errorf("parsing server certificate: %w", err) + } + return verifyCert(issuer, cert) + } + } + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + Timeout: 10 * time.Second, + } + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: status %d", url, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", url, err) + } + var set jwkSet + if err := json.Unmarshal(body, &set); err != nil { + return nil, fmt.Errorf("parsing %s: %w", url, err) + } + out := make(map[string]crypto.PublicKey, 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) + } + if existingKey, exists := out[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, url) + } + continue + } + out[k.Kid] = pub + } + return out, nil +} + +// fetchCCFReceiptKeys returns a kid->PublicKey map by fetching the JWKS for +// each unique receipt issuer. If not nil, verifyCert is invoked with the leaf +// certificate presented by each issuer. +func fetchCCFReceiptKeys(receipts []cosesign1.ParsedCOSEReceipt, verifyCert CertVerifier) (map[string]crypto.PublicKey, error) { + seen := map[string]bool{} + keys := map[string]crypto.PublicKey{} + for _, r := range receipts { + if r.Issuer == "" { + return nil, fmt.Errorf("receipt has no issuer; cannot fetch JWKS") + } + if seen[r.Issuer] { + continue + } + seen[r.Issuer] = true + issuerKeys, err := fetchIssuerJWKS(r.Issuer, verifyCert) + if err != nil { + return nil, err + } + for kid, k := range issuerKeys { + if _, exists := keys[kid]; exists { + return nil, fmt.Errorf("Issuer %s JWKS contains kid %s which is already present from another issuer", r.Issuer, kid) + } + keys[kid] = k + } + } + return keys, nil +} diff --git a/cmd/sign1util/main.go b/cmd/sign1util/main.go index efbf5a4..e3a55b1 100644 --- a/cmd/sign1util/main.go +++ b/cmd/sign1util/main.go @@ -1,15 +1,63 @@ package main import ( + "crypto" + "crypto/sha256" + "crypto/x509" + "encoding/hex" "fmt" "os" "strings" "github.com/Microsoft/cosesign1go/pkg/cosesign1" didx509resolver "github.com/Microsoft/didx509go/pkg/did-x509-resolver" + "github.com/sirupsen/logrus" "github.com/urfave/cli" ) +// formatValue formats a CBOR-decoded value in a human-readable way that +// preserves integer keys (unlike JSON). +func formatValue(v interface{}) string { + switch v := v.(type) { + case map[interface{}]interface{}: + parts := make([]string, 0, len(v)) + for key, val := range v { + parts = append(parts, fmt.Sprintf("%s: %s", formatValue(key), formatValue(val))) + } + return "{" + strings.Join(parts, ", ") + "}" + case []interface{}: + parts := make([]string, 0, len(v)) + for _, val := range v { + parts = append(parts, formatValue(val)) + } + return "[" + strings.Join(parts, ", ") + "]" + case []byte: + return fmt.Sprintf("0x%x", v) + case string: + return fmt.Sprintf("%q", v) + case nil: + return "null" + default: + return fmt.Sprintf("%v", v) + } +} + +func printKeyValue(indent string, k, v interface{}) { + fmt.Fprintf(os.Stdout, "%s%v: %s\n", indent, k, formatValue(v)) +} + +// CCF nodes serve TLS using a self-signed certificate whose authenticity is +// backed by attestation rather than a public CA. Since we have no way to +// validate the CCF's attestation evidence here, we simply print summary details +// of a CCF node's TLS certificate and unconditionally accepts it, which is +// acceptable for this tool. +func acceptAndPrintCert(issuer string, cert *x509.Certificate) error { + fp := sha256.Sum256(cert.Raw) + fmt.Fprintf(os.Stdout, "%s: accepting TLS certificate subject=%q issuer=%q notAfter=%s sha256=%s\n", + issuer, cert.Subject, cert.Issuer, cert.NotAfter.Format("2006-01-02"), hex.EncodeToString(fp[:])) + return nil +} + func checkCoseSign1(inputFilename string, chainFilename string, didString string, verbose bool) (*cosesign1.UnpackedCoseSign1, error) { coseBlob, err := os.ReadFile(inputFilename) if err != nil { @@ -33,13 +81,57 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string } fmt.Fprint(os.Stdout, "checkCoseSign1 passed\n") + + // If the envelope carries COSE Receipts, validate each against the CCF + // ledger profile. + var receiptKeys map[string]crypto.PublicKey + if len(unpacked.Receipts) > 0 { + receiptKeys, err = fetchCCFReceiptKeys(unpacked.Receipts, 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) + } + for i, r := range unpacked.Receipts { + if err := r.Validate(receiptKeys); err != nil { + fmt.Fprintf(os.Stdout, "CCF receipt %d from %s validation failed - %s\n", i, r.Issuer, err) + return nil, fmt.Errorf("CCF receipt %d from %s validation failed: %w", i, r.Issuer, err) + } + fmt.Fprintf(os.Stdout, "CCF receipt %d from %s validation passed\n", i, r.Issuer) + } + } if verbose { fmt.Fprintf(os.Stdout, "iss: %s\n", unpacked.Issuer) fmt.Fprintf(os.Stdout, "feed: %s\n", unpacked.Feed) fmt.Fprintf(os.Stdout, "cty: %s\n", unpacked.ContentType) fmt.Fprintf(os.Stdout, "pubkey: %s\n", unpacked.Pubkey) fmt.Fprintf(os.Stdout, "pubcert: %s\n", unpacked.Pubcert) - fmt.Fprintf(os.Stdout, "payload:\n%s\n", string(unpacked.Payload[:])) + fmt.Fprintf(os.Stdout, "all protected headers:\n") + isHashEnvelope := false + for k, v := range unpacked.Protected { + if k, ok := k.(int64); ok && (k == cosesign1.COSE_Header_x5chain || k == cosesign1.COSE_Header_x5t) { + fmt.Fprintf(os.Stdout, " %d: ...\n", k) + continue + } + if k, ok := k.(int64); ok && k == cosesign1.COSE_Header_PreimageContentType { + isHashEnvelope = true + } + printKeyValue(" ", k, v) + } + fmt.Fprintf(os.Stdout, "all unprotected headers:\n") + for k, v := range unpacked.Unprotected { + if k, ok := k.(int64); ok && k == cosesign1.COSE_Header_Receipts { + fmt.Fprintf(os.Stdout, " %d: ...\n", k) + continue + } + printKeyValue(" ", k, v) + } + fmt.Fprintf(os.Stdout, "payload:\n") + if isHashEnvelope { + fmt.Fprintf(os.Stdout, "%x", unpacked.Payload[:]) + } else { + fmt.Fprintf(os.Stdout, "%s", string(unpacked.Payload)) + } + fmt.Fprintf(os.Stdout, "\n") } if len(didString) > 0 { if len(chainPEMString) == 0 { @@ -53,6 +145,55 @@ func checkCoseSign1(inputFilename string, chainFilename string, didString string fmt.Fprintf(os.Stdout, "DID resolvers failed: err: %s\n", err.Error()) } } + + for i, receipt := range unpacked.Receipts { + if !verbose { + continue + } + msg := receipt.Message + fmt.Fprintf(os.Stdout, "receipt %d:\n", i) + fmt.Fprintf(os.Stdout, " protected headers:\n") + for k, v := range msg.Headers.Protected { + if k, ok := k.(int64); ok && k == cosesign1.COSE_Header_kid { + switch v := v.(type) { + case []byte: + fmt.Fprintf(os.Stdout, " %d: %q\n", k, v) + case string: + fmt.Fprintf(os.Stdout, " %d: string(%q) (invalid type for kid)\n", k, v) + default: + fmt.Fprintf(os.Stdout, " %d: ... (invalid type for kid)\n", k) + } + continue + } + printKeyValue(" ", k, v) + } + fmt.Fprintf(os.Stdout, " unprotected headers:\n") + for k, v := range msg.Headers.Unprotected { + if k, ok := k.(int64); ok && k == cosesign1.COSE_Header_vdp { + m, ok := v.(map[interface{}]interface{}) + if !ok { + fmt.Fprintf(os.Stdout, " %d: ... (invalid type for vdp)\n", k) + continue + } + fmt.Fprintf(os.Stdout, " %d:\n", k) + for k, v := range m { + if k, ok := k.(int64); ok && k == cosesign1.COSE_ProofInclusion { + fmt.Fprintf(os.Stdout, " %d (inclusion): ...\n", k) + continue + } + if k, ok := k.(int64); ok && k == cosesign1.COSE_ProofConsistency { + fmt.Fprintf(os.Stdout, " %d (consistency): ...\n", k) + continue + } + printKeyValue(" ", k, v) + } + continue + } + printKeyValue(" ", k, v) + } + fmt.Fprintf(os.Stdout, " payload: %q\n", msg.Payload) + } + return unpacked, err } @@ -367,6 +508,22 @@ var chainCmd = cli.Command{ func main() { app := cli.NewApp() app.Name = "sign1util" + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "log-level", + Usage: "logrus log level [trace|debug|info|warn|error]", + EnvVar: "LOG_LEVEL", + Value: "info", + }, + } + app.Before = func(ctx *cli.Context) error { + lvl, err := logrus.ParseLevel(ctx.GlobalString("log-level")) + if err != nil { + return fmt.Errorf("invalid --log-level: %w", err) + } + logrus.SetLevel(lvl) + return nil + } app.Commands = []cli.Command{ createCmd, checkCmd, diff --git a/pkg/cosesign1/check.go b/pkg/cosesign1/check.go index 7366c37..d00b459 100644 --- a/pkg/cosesign1/check.go +++ b/pkg/cosesign1/check.go @@ -1,11 +1,15 @@ package cosesign1 import ( + "crypto" + "crypto/sha256" "crypto/x509" "fmt" + "math" didx509resolver "github.com/Microsoft/didx509go/pkg/did-x509-resolver" + "github.com/fxamacker/cbor/v2" "github.com/sirupsen/logrus" "github.com/veraison/go-cose" @@ -63,6 +67,69 @@ type UnpackedCoseSign1 struct { ChainPem string Payload []byte CertChain []*x509.Certificate + Protected cose.ProtectedHeader + Unprotected cose.UnprotectedHeader + // Receipts contains the parsed COSE Receipts attached to the unprotected + // `receipts` header (label 394), if any. Receipts are parsed but not + // validated; use (ParsedCOSEReceipt).Validate to validate each. + Receipts []ParsedCOSEReceipt +} + +// ParsedCOSEReceipt is a parsed COSE Receipt attached to a COSE Sign1 +// envelope. It carries the original CBOR-encoded blob alongside the decoded +// COSE_Sign1 message and a few convenience fields extracted from its +// protected header. +type ParsedCOSEReceipt struct { + // Raw is the original CBOR-encoded COSE_Sign1 receipt blob. + Raw []byte + // Message is the decoded COSE_Sign1 receipt. + Message cose.Sign1Message + // Issuer is the value of CWT claim `iss` (1) from the receipt's + // protected CWT Claims header (label 15), if present. + Issuer string + // Kid is the value of the receipt's protected `kid` header (label 4), + // interpreted as a string (CCF transparency services use ASCII hex), if + // present. + Kid string +} + +// parseCOSEReceipts decodes the unprotected `receipts` header (label 394) +// into []ParsedCOSEReceipt. It does not validate the receipts. +func parseCOSEReceipts(unprotected cose.UnprotectedHeader) ([]ParsedCOSEReceipt, error) { + rcptsVal, ok := unprotected[COSE_Header_Receipts] + if !ok { + return nil, nil + } + rcptsArr, ok := rcptsVal.([]interface{}) + if !ok { + return nil, fmt.Errorf("receipts header is not an array (got %T)", rcptsVal) + } + out := make([]ParsedCOSEReceipt, 0, len(rcptsArr)) + for i, r := range rcptsArr { + rb, ok := r.([]byte) + if !ok { + return nil, fmt.Errorf("receipt %d is not a byte string (got %T)", i, r) + } + var msg cose.Sign1Message + if err := msg.UnmarshalCBOR(rb); err != nil { + return nil, fmt.Errorf("receipt %d: parsing COSE_Sign1: %w", i, err) + } + rcpt := ParsedCOSEReceipt{Raw: rb, Message: msg} + if kidVal, ok := msg.Headers.Protected[COSE_Header_kid]; ok { + if kidBytes, ok := kidVal.([]byte); ok { + rcpt.Kid = string(kidBytes) + } + } + if cwtVal, ok := msg.Headers.Protected[COSE_Header_CWTClaims]; ok { + if cwt, ok := cwtVal.(map[interface{}]interface{}); ok { + if iss, ok := cwt[CWT_Issuer].(string); ok { + rcpt.Issuer = iss + } + } + } + out = append(out, rcpt) + } + return out, nil } // This function is rather unpleasant in that it both decodes the COSE Sign1 document and its various @@ -177,10 +244,27 @@ func UnpackAndValidateCOSE1CertChain(raw []byte) (*UnpackedCoseSign1, error) { return nil, err } - issuer := getStringValue(protected, "iss") - feed := getStringValue(protected, "feed") + cwt, hasCwt := protected[COSE_Header_CWTClaims] + var issuer, feed string + if hasCwt { + cwt, ok := cwt.(map[interface{}]interface{}) + if !ok { + return nil, fmt.Errorf("expected CWTClaims header to be a map[any]any, got %T", cwt) + } + issuer = getStringValue(cwt, CWT_Issuer) + feed = getStringValue(cwt, CWT_Subject) + } else { + issuer = getStringValue(protected, "iss") + feed = getStringValue(protected, "feed") + } + contenttype := getStringValue(protected, cose.HeaderLabelContentType) + receipts, err := parseCOSEReceipts(msg.Headers.Unprotected) + if err != nil { + return nil, fmt.Errorf("parsing receipts: %w", err) + } + return &UnpackedCoseSign1{ Pubcert: leafCertBase64, Feed: feed, @@ -190,5 +274,252 @@ func UnpackAndValidateCOSE1CertChain(raw []byte) (*UnpackedCoseSign1, error) { ContentType: contenttype, Payload: msg.Payload, CertChain: chain, + Protected: protected, + Unprotected: msg.Headers.Unprotected, + Receipts: receipts, }, nil } + +// asInt64 coerces a CBOR-decoded integer value (which may be returned as +// int64, uint64 or int by different decoders) to an int64. +func asInt64(v interface{}) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case int: + return int64(n), true + case uint64: + if n > math.MaxInt64 { + logrus.Errorf("Unable to convert %v to int64 due to overflow", n) + return 0, false + } + return int64(n), true + case uint: + // uint is 64bit on 64bit platforms, so can overflow int64 + if n > math.MaxInt64 { + logrus.Errorf("Unable to convert %v to int64 due to overflow", n) + return 0, false + } + return int64(n), true + } + return 0, false +} + +// Validate validates the COSE Receipt's structure and signature. See +// https://www.ietf.org/archive/id/draft-ietf-cose-merkle-tree-proofs-18.html +// for details about COSE Receipts. +// +// It checks that: +// - the protected header carries a vds (label 395), +// - the payload is detached, +// - the unprotected `vdp` header (label 396) contains at least one +// inclusion proof (key -1) encoded as a byte string, +// - the Merkle root recomputed from each inclusion proof verifies the +// receipt's COSE_Sign1 signature, using the public key in `keys` indexed by +// r.Kid. +func (r ParsedCOSEReceipt) Validate(keys map[string]crypto.PublicKey) error { + msg := r.Message + + vdsVal, ok := msg.Headers.Protected[COSE_Header_vds] + if !ok { + return fmt.Errorf("missing vds (label %d) in protected header", COSE_Header_vds) + } + vds, ok := asInt64(vdsVal) + if !ok { + return fmt.Errorf("vds has wrong type: %T", vdsVal) + } + + if msg.Payload != nil { + return fmt.Errorf("payload must be detached but has %d bytes", len(msg.Payload)) + } + + algoVal, ok := msg.Headers.Protected[cose.HeaderLabelAlgorithm] + if !ok { + return fmt.Errorf("missing algorithm in protected header") + } + algo, ok := algoVal.(cose.Algorithm) + if !ok { + return fmt.Errorf("algorithm has wrong type: %T", algoVal) + } + + kidVal, ok := msg.Headers.Protected[COSE_Header_kid] + if !ok { + return fmt.Errorf("missing kid (label %d) in protected header", COSE_Header_kid) + } + kidBytes, ok := kidVal.([]byte) + if !ok { + return fmt.Errorf("kid has wrong type: %T", kidVal) + } + kid := string(kidBytes) + pubKey, ok := keys[kid] + if !ok { + return fmt.Errorf("no key for kid %s", kid) + } + + vdpVal, ok := msg.Headers.Unprotected[COSE_Header_vdp] + if !ok { + return fmt.Errorf("missing vdp (label %d) in unprotected header", COSE_Header_vdp) + } + vdpMap, ok := vdpVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("vdp has wrong type: %T", vdpVal) + } + inclVal, ok := vdpMap[COSE_ProofInclusion] + if !ok { + return fmt.Errorf("no inclusion proofs (key %d) in vdp", COSE_ProofInclusion) + } + inclArr, ok := inclVal.([]interface{}) + if !ok { + return fmt.Errorf("inclusion proofs has wrong type: %T", inclVal) + } + if len(inclArr) == 0 { + return fmt.Errorf("inclusion proofs array is empty") + } + + verifier, err := cose.NewVerifier(algo, pubKey) + if err != nil { + return fmt.Errorf("cose.NewVerifier (algo %d): %w", algo, err) + } + + for i, p := range inclArr { + pb, ok := p.([]byte) + if !ok { + return fmt.Errorf("inclusion proof %d is not a byte string (got %T)", i, p) + } + var root []byte + switch vds { + case COSE_vds_CCF_LEDGER_SHA256: + root, err = CCF_ComputeRoot(pb) + default: + return fmt.Errorf("only receipts with CCF profile supported (got vds %d)", vds) + } + if err != nil { + return fmt.Errorf("inclusion proof %d: %w", i, err) + } + logrus.Debugf("receipt inclusion proof %d recomputed root: %x", i, root) + // Verify the receipt's COSE_Sign1 signature using the recomputed + // Merkle root as the detached payload. + msg.Payload = root + if err := msg.Verify(nil, verifier); err != nil { + return fmt.Errorf("inclusion proof %d: signature verification failed (recomputed root=%x, kid=%s, alg=%d): %w", i, root, kid, algo, err) + } + msg.Payload = nil + } + return nil +} + +// Decodes a CCF inclusion proof (the bstr-wrapped CBOR `ccf-inclusion-proof` +// structure) and recomputes the Merkle root using the algorithm described in +// section 3.2 of https://www.ietf.org/archive/id/draft-birkholz-cose-receipts-ccf-profile-05.html. +func CCF_ComputeRoot(proofBytes []byte) ([]byte, error) { + var proof map[int64]interface{} + if err := cbor.Unmarshal(proofBytes, &proof); err != nil { + return nil, fmt.Errorf("decoding inclusion proof: %w", err) + } + // ccf-inclusion-proof = bstr .cbor { + // &(leaf: 1) => ccf-leaf + // &(path: 2) => [+ ccf-proof-element] + // } + leafVal, ok := proof[1] + if !ok { + return nil, fmt.Errorf("missing leaf (key 1)") + } + pathVal, ok := proof[2] + if !ok { + return nil, fmt.Errorf("missing path (key 2)") + } + + // ccf-leaf = [ + // ; Byte string of size HASH_SIZE(32) + // internal-transaction-hash: bstr .size 32 + // + // ; Text string of at most 1024 bytes + // internal-evidence: tstr .size (1..1024) + // + // ; Byte string of size HASH_SIZE(32) + // data-hash: bstr .size 32 + // ] + leafArr, ok := leafVal.([]interface{}) + if !ok || len(leafArr) != 3 { + return nil, fmt.Errorf("leaf must be a 3-element array, got %T len %d", leafVal, lenOf(leafVal)) + } + internalTxHash, ok := leafArr[0].([]byte) + if !ok || len(internalTxHash) != 32 { + return nil, fmt.Errorf("leaf.internal-transaction-hash must be a 32-byte bstr, got %T", leafArr[0]) + } + internalEvidenceStr, ok := leafArr[1].(string) + if !ok { + return nil, fmt.Errorf("leaf.internal-evidence must be a text tstr, got %T", leafArr[1]) + } + internalEvidence := []byte(internalEvidenceStr) + if len(internalEvidence) < 1 || len(internalEvidence) > 1024 { + return nil, fmt.Errorf("leaf.internal-evidence has invalid length %d", len(internalEvidence)) + } + dataHash, ok := leafArr[2].([]byte) + if !ok || len(dataHash) != 32 { + return nil, fmt.Errorf("leaf.data-hash must be a 32-byte bstr, got %T", leafArr[2]) + } + + // Leaf hash: + // h := HASH(internal-transaction-hash || HASH(internal-evidence) || data-hash) + evidenceHash := sha256.Sum256(internalEvidence) + leafConcat := make([]byte, 0, 32+32+32) + leafConcat = append(leafConcat, internalTxHash...) + leafConcat = append(leafConcat, evidenceHash[:]...) + leafConcat = append(leafConcat, dataHash...) + leafHash := sha256.Sum256(leafConcat) + h := leafHash[:] + logrus.Debugf("CCF leaf: internal-tx-hash=%x evidence=%q (hash=%x) data-hash=%x -> leaf=%x", internalTxHash, internalEvidence, evidenceHash[:], dataHash, h) + + pathArr, ok := pathVal.([]interface{}) + if !ok { + return nil, fmt.Errorf("path must be an array") + } + if len(pathArr) == 0 { + return nil, fmt.Errorf("path must contain at least one element") + } + + for i, el := range pathArr { + // ccf-proof-element = [ + // ; Position of the element + // left: bool + // + // ; Hash of the proof element: byte string of size HASH_SIZE(32) + // hash: bstr .size 32 + // ] + elArr, ok := el.([]interface{}) + if !ok || len(elArr) != 2 { + return nil, fmt.Errorf("path element %d must be a 2-element array", i) + } + left, ok := elArr[0].(bool) + if !ok { + return nil, fmt.Errorf("path element %d left flag must be a bool", i) + } + hash, ok := elArr[1].([]byte) + if !ok { + return nil, fmt.Errorf("path element %d hash must be a 32-byte bstr, got %T", i, elArr[1]) + } + if len(hash) != 32 { + return nil, fmt.Errorf("path element %d hash must be 32 bytes, got %d bytes", i, len(hash)) + } + var concat []byte + if left { + concat = append(concat, hash...) + concat = append(concat, h...) + } else { + concat = append(concat, h...) + concat = append(concat, hash...) + } + sum := sha256.Sum256(concat) + h = sum[:] + logrus.Debugf("CCF path step %d: left=%v sibling=%x -> h=%x", i, left, hash, h) + } + return h, nil +} + +func lenOf(v interface{}) int { + if a, ok := v.([]interface{}); ok { + return len(a) + } + return -1 +} diff --git a/pkg/cosesign1/constants.go b/pkg/cosesign1/constants.go new file mode 100644 index 0000000..20bf995 --- /dev/null +++ b/pkg/cosesign1/constants.go @@ -0,0 +1,39 @@ +package cosesign1 + +// COSE Header Parameters +// https://www.iana.org/assignments/cose/cose.xhtml +const ( + COSE_Header_kid = int64(4) + COSE_Header_CWTClaims = int64(15) + COSE_Header_x5chain = int64(33) + COSE_Header_x5t = int64(34) + COSE_Header_PayloadHashAlg = int64(258) + COSE_Header_PreimageContentType = int64(259) + COSE_Header_PayloadLocation = int64(260) + COSE_Header_Receipts = int64(394) + COSE_Header_vds = int64(395) + COSE_Header_vdp = int64(396) +) + +// COSE Verifiable Data Structure Algorithms +// (Values for COSE_HeaderLabelvds) +const ( + COSE_vds_RFC9162_SHA256 = int64(1) + + // TBD_1 in https://www.ietf.org/archive/id/draft-birkholz-cose-receipts-ccf-profile-05.html + COSE_vds_CCF_LEDGER_SHA256 = int64(2) +) + +// COSE Verifiable Data Structure Proofs +// (These are the map keys inside a COSE_HeaderLabelReceipts header). +const ( + COSE_ProofInclusion = int64(-1) + COSE_ProofConsistency = int64(-2) +) + +// CWT Claims +// https://www.iana.org/assignments/cwt/cwt.xhtml +const ( + CWT_Issuer = int64(1) + CWT_Subject = int64(2) +) diff --git a/pkg/cosesign1/cosesign1util_test.go b/pkg/cosesign1/cosesign1util_test.go index 1d217d4..4a18904 100644 --- a/pkg/cosesign1/cosesign1util_test.go +++ b/pkg/cosesign1/cosesign1util_test.go @@ -5,7 +5,13 @@ package cosesign1 import ( "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "encoding/json" "fmt" + "math/big" "os" "os/exec" "testing" @@ -178,3 +184,218 @@ func Test_DidX509(t *testing.T) { t.Fatalf("did:x509 creation failed: %s", err) } } + +// loadJWKSFile parses a minimal EC JWKS JSON file into a kid->PublicKey map. +func loadJWKSFile(t *testing.T, filename string) map[string]crypto.PublicKey { + t.Helper() + raw, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("reading %s: %s", filename, err) + } + var set struct { + Keys []struct { + Kty, Crv, Kid, X, Y string + } `json:"keys"` + } + if err := json.Unmarshal(raw, &set); err != nil { + t.Fatalf("parsing %s: %s", filename, err) + } + out := map[string]crypto.PublicKey{} + for i, k := range set.Keys { + if k.Kty != "EC" { + t.Fatalf("key %d: unsupported kty %q", i, 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: + t.Fatalf("key %d: unsupported crv %q", i, k.Crv) + } + xb, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { + t.Fatalf("key %d: decoding x: %s", i, err) + } + yb, err := base64.RawURLEncoding.DecodeString(k.Y) + if err != nil { + t.Fatalf("key %d: decoding y: %s", i, err) + } + out[k.Kid] = &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xb), + Y: new(big.Int).SetBytes(yb), + } + } + return out +} + +const ( + envelopeFile = "esrp_transparent_hash_envelop.cose" + envelopeJWKSFile = "esrp_db_ledger_pub_keys.json" + envelopeExpectedIssuer = "did:x509:0:sha256:I__iuL25oXEVFdTP_aBLx_eT1RPHbCQ_ECBQfYZpt9s::eku:1.3.6.1.4.1.311.76.59.1.2" + envelopeExpectedFeed = "ContainerPlat-AMD-UVM" + envelopeReceiptIssuer = "esrp-cts-db.confidential-ledger.azure.com" + envelopeReceiptKid = "da7694f16def5a056ca96afb21e89a9450e4cc875e2de351da76d99544a3e849" + envelopePreimageCType = "application/octet-stream" + envelopeReceiptCWTSubject = "scitt.ccf.signature.v1" + graftedEnvelopeFile = "esrp_with_grafted_receipt.cose" + graftedEnvelopeJWKSFile = "esrp_cp_ledger_pub_keys.json" +) + +// Test_UnpackTransparentHashEnvelope verifies that a CWT-based envelope's +// issuer/feed are parsed correctly, the headers correctly stored in the +// resulting struct, and that the attached transparent receipt is parsed. +func Test_UnpackTransparentHashEnvelope(t *testing.T) { + raw, err := os.ReadFile(envelopeFile) + if err != nil { + t.Fatalf("reading %s: %s", envelopeFile, err) + } + unpacked, err := UnpackAndValidateCOSE1CertChain(raw) + if err != nil { + t.Fatalf("UnpackAndValidateCOSE1CertChain failed: %s", err) + } + + if unpacked.Issuer != envelopeExpectedIssuer { + t.Errorf("Issuer = %q, want %q", unpacked.Issuer, envelopeExpectedIssuer) + } + if unpacked.Feed != envelopeExpectedFeed { + t.Errorf("Feed = %q, want %q", unpacked.Feed, envelopeExpectedFeed) + } + + // Hash envelope: payload hash alg (258) and preimage content type (259) + // MUST be present in the protected header. + hashAlg, ok := unpacked.Protected[COSE_Header_PayloadHashAlg] + if !ok { + t.Errorf("missing payload hash alg (label %d) in protected header", COSE_Header_PayloadHashAlg) + } else if n, ok := asInt64(hashAlg); !ok || n != -43 { + t.Errorf("payload hash alg = %v (%T), want -43", hashAlg, hashAlg) + } + preimage, ok := unpacked.Protected[COSE_Header_PreimageContentType] + if !ok { + t.Errorf("missing preimage content type (label %d) in protected header", COSE_Header_PreimageContentType) + } else if s, ok := preimage.(string); !ok || s != envelopePreimageCType { + t.Errorf("preimage content type = %v (%T), want %q", preimage, preimage, envelopePreimageCType) + } + + // CWT Claims should carry the issuer (1) and subject (2). + cwtVal, ok := unpacked.Protected[COSE_Header_CWTClaims] + if !ok { + t.Fatalf("missing CWTClaims (label %d) in protected header", COSE_Header_CWTClaims) + } + cwt, ok := cwtVal.(map[interface{}]interface{}) + if !ok { + t.Fatalf("CWTClaims has wrong type: %T", cwtVal) + } + if iss, _ := cwt[CWT_Issuer].(string); iss != envelopeExpectedIssuer { + t.Errorf("CWT iss = %q, want %q", iss, envelopeExpectedIssuer) + } + if sub, _ := cwt[CWT_Subject].(string); sub != envelopeExpectedFeed { + t.Errorf("CWT sub = %q, want %q", sub, envelopeExpectedFeed) + } + + // Receipts should be parsed (but not validated). + if len(unpacked.Receipts) != 1 { + t.Fatalf("len(Receipts) = %d, want 1", len(unpacked.Receipts)) + } + r := unpacked.Receipts[0] + if r.Issuer != envelopeReceiptIssuer { + t.Errorf("receipt Issuer = %q, want %q", r.Issuer, envelopeReceiptIssuer) + } + if r.Kid != envelopeReceiptKid { + t.Errorf("receipt Kid = %q, want %q", r.Kid, envelopeReceiptKid) + } + if len(r.Raw) == 0 { + t.Errorf("receipt Raw is empty") + } + // Receipt should carry vds=CCF_LEDGER_SHA256. + vds, ok := r.Message.Headers.Protected[COSE_Header_vds] + if !ok { + t.Fatalf("receipt missing vds (label %d)", COSE_Header_vds) + } + if n, ok := asInt64(vds); !ok || n != COSE_vds_CCF_LEDGER_SHA256 { + t.Errorf("receipt vds = %v (%T), want %d", vds, vds, COSE_vds_CCF_LEDGER_SHA256) + } + // Receipt CWT should mention sub=scitt.ccf.signature.v1. + rcwtVal, ok := r.Message.Headers.Protected[COSE_Header_CWTClaims] + if !ok { + t.Fatalf("receipt missing CWTClaims") + } + rcwt, ok := rcwtVal.(map[interface{}]interface{}) + if !ok { + t.Fatalf("receipt CWTClaims has wrong type: %T", rcwtVal) + } + if sub, _ := rcwt[CWT_Subject].(string); sub != envelopeReceiptCWTSubject { + t.Errorf("receipt CWT sub = %q, want %q", sub, envelopeReceiptCWTSubject) + } +} + +// Test_ValidateTransparentReceipt validates the CCF inclusion receipt +// attached to esrp_transparent_hash_envelop.cose using the bundled JWKS. +func Test_ValidateTransparentReceipt(t *testing.T) { + raw, err := os.ReadFile(envelopeFile) + if err != nil { + t.Fatalf("reading %s: %s", envelopeFile, err) + } + unpacked, err := UnpackAndValidateCOSE1CertChain(raw) + if err != nil { + t.Fatalf("UnpackAndValidateCOSE1CertChain failed: %s", err) + } + if len(unpacked.Receipts) == 0 { + t.Fatalf("no receipts attached to envelope") + } + keys := loadJWKSFile(t, envelopeJWKSFile) + for i, r := range unpacked.Receipts { + if err := r.Validate(keys); err != nil { + t.Errorf("receipt %d Validate: %s", i, err) + } + } +} + +// Test_ValidateTransparentReceiptMissingKey ensures validation fails cleanly +// when no key matches the receipt's kid. +func Test_ValidateTransparentReceiptMissingKey(t *testing.T) { + raw, err := os.ReadFile(envelopeFile) + if err != nil { + t.Fatalf("reading %s: %s", envelopeFile, err) + } + unpacked, err := UnpackAndValidateCOSE1CertChain(raw) + if err != nil { + t.Fatalf("UnpackAndValidateCOSE1CertChain failed: %s", err) + } + if len(unpacked.Receipts) == 0 { + t.Fatalf("no receipts attached to envelope") + } + err = unpacked.Receipts[0].Validate(map[string]crypto.PublicKey{}) + if err == nil { + t.Fatal("Validate with empty keys unexpectedly succeeded") + } +} + +// Test_GraftedReceiptIsRejected loads esrp_with_grafted_receipt.cose, an envelope +// constructed by attaching a receipt for a different (unrelated) signed +// statement, and asserts that validation detects the mismatch. The receipt +// itself is valid, only the matching is wrong. +func Test_GraftedReceiptIsRejected(t *testing.T) { + raw, err := os.ReadFile(graftedEnvelopeFile) + if err != nil { + t.Fatalf("reading %s: %s", graftedEnvelopeFile, err) + } + unpacked, err := UnpackAndValidateCOSE1CertChain(raw) + if err != nil { + t.Fatalf("UnpackAndValidateCOSE1CertChain failed: %s", err) + } + if len(unpacked.Receipts) == 0 { + t.Fatalf("no receipts attached to envelope") + } + // Provide keys for the ledger that issued the donor receipt so that the + // receipt's own signature verifies; the only remaining defence is the + // missing binding check. + keys := loadJWKSFile(t, graftedEnvelopeJWKSFile) + if err := unpacked.Receipts[0].Validate(keys); err == nil { + t.Errorf("grafted receipt unexpectedly passed validation: envelope/receipt mismatch is not checked") + } +} diff --git a/pkg/cosesign1/esrp_cp_ledger_pub_keys.json b/pkg/cosesign1/esrp_cp_ledger_pub_keys.json new file mode 100644 index 0000000..5f95073 --- /dev/null +++ b/pkg/cosesign1/esrp_cp_ledger_pub_keys.json @@ -0,0 +1,11 @@ +{ + "keys": [ + { + "crv": "P-384", + "kid": "a7ad3b7729516ca443fa472a0f2faa4a984ee3da7eafd17f98dcffbac4a6a10f", + "kty": "EC", + "x": "m0kQ1A_uqHWuP9fdGSKatSq2brcAJ6-q3aZ5P35wjbgtNnlm2u-NLF1qM-yC4I2n", + "y": "J9cJFrdWvUf6PCMkrWFTgB16uEq4mSMCI4NPVytnwYX6xNnuJ2GTrPtafKYg1VNi" + } + ] +} \ No newline at end of file diff --git a/pkg/cosesign1/esrp_db_ledger_pub_keys.json b/pkg/cosesign1/esrp_db_ledger_pub_keys.json new file mode 100644 index 0000000..c1355ba --- /dev/null +++ b/pkg/cosesign1/esrp_db_ledger_pub_keys.json @@ -0,0 +1,18 @@ +{ + "keys": [ + { + "crv": "P-384", + "kid": "23d48c280f71abf575c81e89f18a4dc9f3b33d8a3b149b16ad836c8553f95bc0", + "kty": "EC", + "x": "2GIJv9nAhste7hDWrpea1-hd_BAPXg4ZIxLy4C4hAX2eCpqT4siLqohA2KIVJti8", + "y": "aTT6XYHZPBgdI4RLFo2BaP1RVuOG2rFg5JBhYvt871HIwmtzNtwXl3_NBwfcqr8O" + }, + { + "crv": "P-384", + "kid": "da7694f16def5a056ca96afb21e89a9450e4cc875e2de351da76d99544a3e849", + "kty": "EC", + "x": "GeQ_qA3ZxYoaan3D0nA7xriMcmiMqQ0UNY1DLs7C5kIEaI_RL_2duRcG1Ii6g-8-", + "y": "uKiRr4UU8aXumcA8wu6LOatH0qL2AjFy3_8iBx3mbt1foS5xNHlXchMMLTSCvRLn" + } + ] +} diff --git a/pkg/cosesign1/esrp_transparent_hash_envelop.cose b/pkg/cosesign1/esrp_transparent_hash_envelop.cose new file mode 100644 index 0000000..8822caa Binary files /dev/null and b/pkg/cosesign1/esrp_transparent_hash_envelop.cose differ diff --git a/pkg/cosesign1/esrp_with_grafted_receipt.cose b/pkg/cosesign1/esrp_with_grafted_receipt.cose new file mode 100644 index 0000000..66f1be6 Binary files /dev/null and b/pkg/cosesign1/esrp_with_grafted_receipt.cose differ