-
Notifications
You must be signed in to change notification settings - Fork 6
Handle "new-style" COSE envelop and validate transparent receipts #38
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
Draft
micromaomao
wants to merge
12
commits into
main
Choose a base branch
from
transparant-fragments
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
342cec0
Print RAW cose headers and transparent receipts
micromaomao a18e3df
Give names to constants and define more constants
micromaomao 6e06f37
Add CCF receipt validation
micromaomao 60a70d9
Add --log-level flag to sign1util
micromaomao c231af7
Receipt validation API/code refactor
micromaomao e5b1225
Fix issues from Copilot review
micromaomao 6373384
main.go: tweak output
micromaomao 33ae576
Move key fetching code from main.go to a separate file
micromaomao ca16f8e
sign1util: receipt verification: Print out CCF certificate
micromaomao bbc6ab0
Address review
micromaomao 885b8fc
Add unit tests for new parsing code and transparent receipt validation
micromaomao 4fa4ccf
Add unit test for maliciously grafted receipt
micromaomao File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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://<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. | ||
| 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, | ||
| } | ||
|
micromaomao marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
|
Comment on lines
+101
to
+108
|
||
| 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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.