diff --git a/README.md b/README.md index b7e0d9d..8f39654 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,15 @@ By default, ratsd core listens on port 8895. Use `POST /ratsd/chares` to retriev $ curl -X POST http://localhost:8895/ratsd/chares -H "Content-type: application/vnd.veraison.chares+json" -d '{"nonce": "TUlEQk5IMjhpaW9pc2pQeXh4eHh4eHh4eHh4eHh4eHhNSURCTkgyOGlpb2lzalB5eHh4eHh4eHh4eHh4eHh4eA"}' {"cmw":"eyJfX2Ntd2NfdCI6InRhZzpnaXRodWIuY29tLDIwMjU6dmVyYWlzb24vcmF0c2QvY213IiwibW9jay10c20iOlsiYXBwbGljYXRpb24vdm5kLnZlcmFpc29uLmNvbmZpZ2ZzLXRzbStqc29uIiwiZXlKaGRYaGliRzlpSWpvaVdWaFdORmx0ZUhaWlp5SXNJbTkxZEdKc2IySWlPaUpqU0Vwd1pHMTRiR1J0Vm5OUGFVRjNRMjFzZFZsdGVIWlphbTluVGtkUk1FOVVVVEJPUkVrd1dsUlJORTE2U1hwUFJGazFUbXByTWxwcVdUVk9lazB5V1ZSVmQwNTZhek5QUkdNMFRucG5NMDlFWXpST2VtY3pUMFJqTkU1Nlp6TlBSR00wVG5wbk0wOUVZelJPZW1jelQwUlNhMDVFYXpCT1JGRjVUa2RWTUU5RVRYbE5lbWN5VDFSWk5VNXRXVEpQVkdONlRtMUZNVTFFWXpWT2VtY3pUMFJqTkU1Nlp6TlBSR00wVG5wbk0wOUVZelJPZW1jelQwUmpORTU2WnpOUFJHTTBUbnBuSWl3aWNISnZkbWxrWlhJaU9pSm1ZV3RsWEc0aWZRIl19","eat_nonce":"TUlEQk5IMjhpaW9pc2pQeXh4eHh4eHh4eHh4eHh4eHhNSURCTkgyOGlpb2lzalB5eHh4eHh4eHh4eHh4eHh4eA","eat_profile":"tag:github.com,2024:veraison/ratsd"} ``` + +To request the CBOR token defined as token v2 in `docs/ratsd-token.cddl`, set `"token-version": 2` in the request body. The response body is raw CBOR: +```bash +$ curl -X POST http://localhost:8895/ratsd/chares \ + -H 'Content-type: application/vnd.veraison.chares+json' \ + -H 'Accept: application/cmw+cbor; cmwct="tag:github.com,2026:veraison/ratsd/v2"' \ + -d '{"nonce": "TUlEQk5IMjhpaW9pc2pQeXh4eHh4eHh4eHh4eHh4eHhNSURCTkgyOGlpb2lzalB5eHh4eHh4eHh4eHh4eHh4eA", "token-version": 2}' \ + --output ratsd-token-v2.cbor +``` ## Get available attesters Use endpoint `GET /ratsd/subattesters` to query all available leaf attesters and their available options. The usage can be found in the following ```console diff --git a/api/api.gen.go b/api/api.gen.go index a86172c..2d1a41e 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -45,6 +45,12 @@ const ( ApplicationvndVeraisonConfigfsTsmJson CMWTyp = "application/vnd.veraison.configfs-tsm+json" ) +// Defines values for ChaResRequestTokenVersion. +const ( + N1 ChaResRequestTokenVersion = 1 + N2 ChaResRequestTokenVersion = 2 +) + // Defines values for EATEatProfile. const ( TagGithubCom2024Veraisonratsd EATEatProfile = "tag:github.com,2024:veraison/ratsd" @@ -106,9 +112,13 @@ type CMWTyp string type ChaResRequest struct { AttesterSelection *[]string `json:"attester-selection,omitempty"` Nonce string `json:"nonce"` + TokenVersion *ChaResRequestTokenVersion `json:"token-version,omitempty"` AdditionalProperties map[string]map[string]string `json:"-"` } +// ChaResRequestTokenVersion defines model for ChaResRequest.TokenVersion. +type ChaResRequestTokenVersion int + // EAT defines model for EAT. type EAT struct { EatProfile EATEatProfile `json:"eat_profile"` @@ -203,6 +213,14 @@ func (a *ChaResRequest) UnmarshalJSON(b []byte) error { delete(object, "nonce") } + if raw, found := object["token-version"]; found { + err = json.Unmarshal(raw, &a.TokenVersion) + if err != nil { + return fmt.Errorf("error reading 'token-version': %w", err) + } + delete(object, "token-version") + } + if len(object) != 0 { a.AdditionalProperties = make(map[string]map[string]string) for fieldName, fieldBuf := range object { @@ -234,6 +252,13 @@ func (a ChaResRequest) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("error marshaling 'nonce': %w", err) } + if a.TokenVersion != nil { + object["token-version"], err = json.Marshal(a.TokenVersion) + if err != nil { + return nil, fmt.Errorf("error marshaling 'token-version': %w", err) + } + } + for fieldName, field := range a.AdditionalProperties { object[fieldName], err = json.Marshal(field) if err != nil { @@ -450,24 +475,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/server.go b/api/server.go index 73af9bc..fb820c8 100644 --- a/api/server.go +++ b/api/server.go @@ -10,9 +10,9 @@ import ( "net/http" "github.com/moogar0880/problems" - "github.com/veraison/cmw" "github.com/veraison/ratsd/plugin" "github.com/veraison/ratsd/proto/compositor" + "github.com/veraison/ratsd/tokens" "go.uber.org/zap" ) @@ -23,9 +23,11 @@ const ( ) type Server struct { - logger *zap.SugaredLogger - manager plugin.IManager - options string + logger *zap.SugaredLogger + manager plugin.IManager + options string + certPath string + certKeyPath string } func responseCodeToHTTP(responseCode uint32) int { @@ -48,6 +50,13 @@ func NewServer(logger *zap.SugaredLogger, manager plugin.IManager, options strin } } +func NewServerWithSigner(logger *zap.SugaredLogger, manager plugin.IManager, options, certPath, certKeyPath string) *Server { + s := NewServer(logger, manager, options) + s.certPath = certPath + s.certKeyPath = certKeyPath + return s +} + func (s *Server) reportProblem(w http.ResponseWriter, prob *problems.DefaultProblem) { s.logger.Error(prob.Detail) w.Header().Set("Content-Type", problems.ProblemMediaType) @@ -70,7 +79,18 @@ func (s *Server) RatsdChares(w http.ResponseWriter, r *http.Request, param Ratsd return } - respCt := fmt.Sprintf(`application/eat-ucs+json; eat_profile=%q`, TagGithubCom2024Veraisonratsd) + payload, _ := io.ReadAll(r.Body) + + tokenVersion := tokens.RATSDTokenVersionLegacy + var tokenVersionProbe struct { + TokenVersion *int `json:"token-version,omitempty"` + } + if len(payload) > 0 { + if err := json.Unmarshal(payload, &tokenVersionProbe); err == nil && tokenVersionProbe.TokenVersion != nil { + tokenVersion = *tokenVersionProbe.TokenVersion + } + } + respCt := tokens.ResponseMediaType(tokenVersion) if param.Accept != nil { s.logger.Info("request media type: ", *(param.Accept)) if *(param.Accept) != respCt && *(param.Accept) != "*/*" { @@ -82,7 +102,6 @@ func (s *Server) RatsdChares(w http.ResponseWriter, r *http.Request, param Ratsd } } - payload, _ := io.ReadAll(r.Body) requestFields := make(map[string]json.RawMessage) err := json.Unmarshal(payload, &requestFields) if err != nil { @@ -124,6 +143,33 @@ func (s *Server) RatsdChares(w http.ResponseWriter, r *http.Request, param Ratsd } delete(requestFields, "nonce") + if rawTokenVersion, ok := requestFields["token-version"]; ok { + if err := json.Unmarshal(rawTokenVersion, &tokenVersion); err != nil { + errMsg := "fail to retrieve token-version from the request" + p := &problems.DefaultProblem{ + Type: string(TagGithubCom2024VeraisonratsdErrorInvalidrequest), + Title: string(InvalidRequest), + Detail: errMsg, + Status: http.StatusBadRequest, + } + s.reportProblem(w, p) + return + } + delete(requestFields, "token-version") + } + if tokenVersion != tokens.RATSDTokenVersionLegacy && + tokenVersion != tokens.RATSDTokenVersionV2 { + errMsg := fmt.Sprintf("unsupported token version %d", tokenVersion) + p := &problems.DefaultProblem{ + Type: string(TagGithubCom2024VeraisonratsdErrorInvalidrequest), + Title: string(InvalidRequest), + Detail: errMsg, + Status: http.StatusBadRequest, + } + s.reportProblem(w, p) + return + } + selectedAttesters := []string{} hasSelection := false if rawSelection, ok := requestFields["attester-selection"]; ok { @@ -168,13 +214,34 @@ func (s *Server) RatsdChares(w http.ResponseWriter, r *http.Request, param Ratsd return } s.logger.Info("request nonce: ", requestNonce) - s.logger.Info("request media type: ", *(param.Accept)) - // Use a map until we finalize ratsd output format - eat := make(map[string]interface{}) - collection := cmw.NewCollection("tag:github.com,2025:veraison/ratsd/cmw") - eat["eat_profile"] = TagGithubCom2024Veraisonratsd - eat["eat_nonce"] = requestNonce + evidenceOptions := []tokens.EvidenceOption{} + if tokenVersion == tokens.RATSDTokenVersionV2 { + evidenceOptions = append(evidenceOptions, tokens.WithSignerPaths(s.certPath, s.certKeyPath)) + } + + evidence, err := tokens.NewEvidence(tokenVersion, evidenceOptions...) + if err != nil { + p := &problems.DefaultProblem{ + Type: string(TagGithubCom2024VeraisonratsdErrorInvalidrequest), + Title: string(InvalidRequest), + Detail: err.Error(), + Status: http.StatusBadRequest, + } + s.reportProblem(w, p) + return + } + if err := evidence.AddNonce(nonce); err != nil { + p := &problems.DefaultProblem{ + Type: string(TagGithubCom2024VeraisonratsdErrorInvalidrequest), + Title: string(InvalidRequest), + Detail: err.Error(), + Status: http.StatusBadRequest, + } + s.reportProblem(w, p) + return + } + pl := s.manager.GetPluginList() if len(pl) == 0 { errMsg := "no sub-attester available" @@ -264,8 +331,12 @@ func (s *Server) RatsdChares(w http.ResponseWriter, r *http.Request, param Ratsd return false } - c := cmw.NewMonad(in.ContentType, out.Evidence) - collection.AddCollectionItem(pn, c) + if err := evidence.AddToken(pn, in.ContentType, out.Evidence); err != nil { + errMsg := err.Error() + p := problems.NewDetailedProblem(http.StatusInternalServerError, errMsg) + s.reportProblem(w, p) + return false + } return true } @@ -288,17 +359,25 @@ func (s *Server) RatsdChares(w http.ResponseWriter, r *http.Request, param Ratsd } } - serialized, err := collection.MarshalJSON() + token, err := evidence.Marshal() if err != nil { - errMsg := fmt.Sprintf("failed to serialize CMW collection: %s", err.Error()) + if tokenVersion == tokens.RATSDTokenVersionV2 { + errMsg := fmt.Sprintf("failed to create token version 2: %s", err.Error()) + p := problems.NewDetailedProblem(http.StatusInternalServerError, errMsg) + s.reportProblem(w, p) + return + } + + errMsg := fmt.Sprintf("failed to serialize legacy token: %s", err.Error()) p := problems.NewDetailedProblem(http.StatusInternalServerError, errMsg) s.reportProblem(w, p) return } - eat["cmw"] = serialized w.Header().Set("Content-Type", respCt) w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(eat) + if _, err := w.Write(token); err != nil { + s.logger.Error("failed to write token response: ", err) + } } func (s *Server) RatsdSubattesters(w http.ResponseWriter, r *http.Request) { diff --git a/api/server_test.go b/api/server_test.go index ad15273..2aea665 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -9,9 +9,11 @@ import ( "fmt" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" + "github.com/fxamacker/cbor/v2" "github.com/golang/mock/gomock" "github.com/moogar0880/problems" "github.com/stretchr/testify/assert" @@ -171,6 +173,10 @@ func TestRatsdChares_invalid_body(t *testing.T) { "attester-selection": ["mock-tsm"], "mock-tsm":{"content-type":"invalid"}}`, validNonce), "mock-tsm does not support content type invalid"}, + {"unsupported token version", + fmt.Sprintf(`{"nonce": "%s", + "token-version": 3}`, validNonce), + "unsupported token version 3"}, } for _, tt := range tests { @@ -418,3 +424,116 @@ func TestRatsdChares_valid_request_selected_attesters_in_all_mode(t *testing.T) _, err = collection.GetCollectionItem("other-tsm") assert.Error(t, err) } + +func TestRatsdChares_valid_request_v2(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var params RatsdCharesParams + + param := tokens.RATSDTokenMediaTypeV2 + params.Accept = ¶m + logger := log.Named("test") + + pluginList := []string{"mock-tsm"} + dm := mock_deps.NewMockIManager(ctrl) + dm.EXPECT().GetPluginList().Return(pluginList).AnyTimes() + dm.EXPECT().LookupByName("mock-tsm").Return(mocktsm.GetPlugin(), nil).AnyTimes() + + s := NewServerWithSigner( + logger, + dm, + "all", + filepath.Join("..", "ratsd.crt"), + filepath.Join("..", "ratsd.key"), + ) + realNonce, _ := base64.RawURLEncoding.DecodeString(validNonce) + + w := httptest.NewRecorder() + rb := strings.NewReader(fmt.Sprintf(`{"nonce": "%s", "token-version": 2}`, validNonce)) + r, _ := http.NewRequest(http.MethodPost, "/ratsd/chares", rb) + r.Header.Add("Content-Type", ApplicationvndVeraisonCharesJson) + s.RatsdChares(w, r, params) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, param, w.Result().Header.Get("Content-Type")) + + msg := decodeCOSESign1(t, w.Body.Bytes()) + assert.NotEmpty(t, msg.Signature) + + var protected map[int64]any + err := cbor.Unmarshal(msg.Protected, &protected) + assert.NoError(t, err) + assert.Equal(t, int64(-7), protected[1]) + + x5chain, ok := protected[33].([]byte) + assert.True(t, ok) + assert.NotEmpty(t, x5chain) + + collection := &cmw.CMW{} + err = collection.UnmarshalCBOR(msg.Payload) + assert.NoError(t, err) + assert.Equal(t, cmw.KindCollection, collection.GetKind()) + + collectionType, err := collection.GetCollectionType() + assert.NoError(t, err) + assert.Equal(t, tokens.RATSDCollectionTypeV2, collectionType) + + claimsRecord, err := collection.GetCollectionItem("__ratsd") + assert.NoError(t, err) + assert.Equal(t, tokens.RATSDClaimsMediaTypeV2, claimsRecord.GetMonadType()) + + var claimsTag cbor.Tag + err = cbor.Unmarshal(claimsRecord.GetMonadValue(), &claimsTag) + assert.NoError(t, err) + assert.Equal(t, uint64(601), claimsTag.Number) + + claimsBytes, err := cbor.Marshal(claimsTag.Content) + assert.NoError(t, err) + + var claims map[int]any + err = cbor.Unmarshal(claimsBytes, &claims) + assert.NoError(t, err) + assert.Equal(t, tokens.RATSDV2Profile, claims[265]) + assert.Equal(t, realNonce, claims[10]) + + c, err := collection.GetCollectionItem("mock-tsm") + assert.NoError(t, err) + assert.Equal(t, cmw.KindMonad, c.GetKind()) + assert.Equal(t, c.GetMonadType(), "application/vnd.veraison.configfs-tsm+json") + + tsmout := &tokens.TSMReport{} + err = tsmout.FromJSON(c.GetMonadValue()) + assert.NoError(t, err) + assert.Equal(t, "fake\n", tsmout.Provider) + assert.Equal(t, tokens.BinaryString("auxblob"), tsmout.AuxBlob) + + expectedOutblob := fmt.Sprintf("privlevel: %d\ninblob: %s", 0, hex.EncodeToString([]byte(realNonce))) + assert.Equal(t, tokens.BinaryString(expectedOutblob), tsmout.OutBlob) +} + +type coseSign1Message struct { + _ struct{} `cbor:",toarray"` + Protected []byte + Unprotected map[any]any + Payload []byte + Signature []byte +} + +func decodeCOSESign1(t *testing.T, data []byte) *coseSign1Message { + t.Helper() + + var tag cbor.Tag + err := cbor.Unmarshal(data, &tag) + assert.NoError(t, err) + assert.Equal(t, uint64(18), tag.Number) + + content, err := cbor.Marshal(tag.Content) + assert.NoError(t, err) + + msg := &coseSign1Message{} + err = cbor.Unmarshal(content, msg) + assert.NoError(t, err) + + return msg +} diff --git a/cmd/main.go b/cmd/main.go index fbce685..f866ac4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,13 +18,13 @@ var ( ) type cfg struct { - ListenAddr string `mapstructure:"listen-addr" valid:"dialstring"` - Protocol string `mapstructure:"protocol" valid:"in(http|https)"` - Cert string `mapstructure:"cert" config:"zerodefault"` - CertKey string `mapstructure:"cert-key" config:"zerodefault"` - PluginDir string `mapstructure:"plugin-dir" config:"zerodefault"` - ListOptions string `mapstructure:"list-options" valid:"in(all|selected)"` - SecureLoader bool `mapstructure:"secure-loader" config:"zerodefault"` + ListenAddr string `mapstructure:"listen-addr" valid:"dialstring"` + Protocol string `mapstructure:"protocol" valid:"in(http|https)"` + Cert string `mapstructure:"cert" config:"zerodefault"` + CertKey string `mapstructure:"cert-key" config:"zerodefault"` + PluginDir string `mapstructure:"plugin-dir" config:"zerodefault"` + ListOptions string `mapstructure:"list-options" valid:"in(all|selected)"` + SecureLoader bool `mapstructure:"secure-loader" config:"zerodefault"` } func (o cfg) Validate() error { @@ -100,7 +100,13 @@ func main() { log.Info("Loaded sub-attesters:", pluginManager.GetPluginList()) - svr := api.NewServer(log.Named("api"), pluginManager, cfg.ListOptions) + svr := api.NewServerWithSigner( + log.Named("api"), + pluginManager, + cfg.ListOptions, + cfg.Cert, + cfg.CertKey, + ) r := http.NewServeMux() options := api.StdHTTPServerOptions{ BaseRouter: r, diff --git a/docs/api/ratsd.yaml b/docs/api/ratsd.yaml index 6ef532c..b4549c0 100644 --- a/docs/api/ratsd.yaml +++ b/docs/api/ratsd.yaml @@ -14,9 +14,13 @@ paths: '200': description: The request has succeeded. content: - application/eat+jwt; eat_profile="tag:github.com,2024:veraison/ratsd": + application/eat-ucs+json; eat_profile="tag:github.com,2024:veraison/ratsd": schema: $ref: '#/components/schemas/EAT' + application/cmw+cbor; cmwct="tag:github.com,2026:veraison/ratsd/v2": + schema: + type: string + format: binary '400': description: The server could not understand the request due to invalid syntax. content: @@ -103,6 +107,12 @@ components: nonce: type: string format: base64url + token-version: + type: integer + enum: + - 1 + - 2 + default: 1 attester-selection: type: array items: diff --git a/go.mod b/go.mod index 912d223..e7a4e81 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/spf13/viper v1.13.0 github.com/stretchr/testify v1.9.0 github.com/veraison/cmw v0.1.2-0.20250109140511-d907dcce0c61 + github.com/veraison/go-cose v1.3.0 github.com/veraison/services v0.0.2501 go.uber.org/zap v1.23.0 golang.org/x/crypto v0.46.0 diff --git a/go.sum b/go.sum index 327abb4..96e6069 100644 --- a/go.sum +++ b/go.sum @@ -336,6 +336,8 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/veraison/cmw v0.1.2-0.20250109140511-d907dcce0c61 h1:0m/q63xDHuFmfode99GCC7F9CHAREpS3QSa6uqayGFU= github.com/veraison/cmw v0.1.2-0.20250109140511-d907dcce0c61/go.mod h1:OiYKk1t6/Fmmg30ZpSMzi4nKr5kt3374sNTkgxC5BDs= +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/veraison/services v0.0.2501 h1:tK4a92RvceURIlT0pEkz3fU0TpaZ9jbcWqkPRkMsZXY= github.com/veraison/services v0.0.2501/go.mod h1:snBdw93xJ4j9sSrYueT3QlAB8oWGePwkq7H+kYf/lLc= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= diff --git a/ratsd-token/evidence.go b/ratsd-token/evidence.go new file mode 100644 index 0000000..3255f3a --- /dev/null +++ b/ratsd-token/evidence.go @@ -0,0 +1,650 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package tokens + +import ( + "bytes" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/fxamacker/cbor/v2" + "github.com/veraison/cmw" + cose "github.com/veraison/go-cose" +) + +const claimsKeyRATSD = "__ratsd" + +// Evidence builds and inspects RATSD attestation tokens independently of the HTTP API. +type Evidence struct { + tokenVersion int + claims map[int]any + collection *cmw.CMW + certPath string + certKeyPath string + + SigningCert *x509.Certificate + IntermediateCerts []*x509.Certificate + message *cose.Sign1Message +} + +// EvidenceOption customizes Evidence construction. +type EvidenceOption func(*Evidence) + +// WithSignerPaths configures the certificate and private key used for token v2. +func WithSignerPaths(certPath, certKeyPath string) EvidenceOption { + return func(e *Evidence) { + e.certPath = certPath + e.certKeyPath = certKeyPath + } +} + +// NewEvidence creates an attestation token builder for the requested token version. +func NewEvidence(tokenVersion int, options ...EvidenceOption) (*Evidence, error) { + if tokenVersion != RATSDTokenVersionLegacy && tokenVersion != RATSDTokenVersionV2 { + return nil, fmt.Errorf("unsupported token version %d", tokenVersion) + } + + collectionType := RATSDCollectionTypeLegacy + claims := make(map[int]any) + if tokenVersion == RATSDTokenVersionV2 { + collectionType = RATSDCollectionTypeV2 + claims[eatClaimProfile] = RATSDV2Profile + } + + evidence := &Evidence{ + tokenVersion: tokenVersion, + claims: claims, + collection: cmw.NewCollection(collectionType), + } + + for _, option := range options { + option(evidence) + } + + return evidence, nil +} + +// MediaType returns the serialized evidence media type for the configured token version. +func (e *Evidence) MediaType() string { + return ResponseMediaType(e.tokenVersion) +} + +// Collection exposes the underlying CMW collection for inspection. +func (e *Evidence) Collection() *cmw.CMW { + return e.collection +} + +// Claims returns a copy of the evidence claims map. +func (e *Evidence) Claims() map[int]any { + claims := make(map[int]any, len(e.claims)) + for key, value := range e.claims { + switch typed := value.(type) { + case []byte: + claims[key] = append([]byte(nil), typed...) + default: + claims[key] = typed + } + } + return claims +} + +// SetClaim sets a claim that will be embedded in the evidence. +func (e *Evidence) SetClaim(key int, value any) { + if e.claims == nil { + e.claims = make(map[int]any) + } + switch typed := value.(type) { + case []byte: + e.claims[key] = append([]byte(nil), typed...) + default: + e.claims[key] = value + } +} + +// AddNonce stores the nonce that will be embedded in the serialized evidence. +func (e *Evidence) AddNonce(nonce []byte) error { + if len(nonce) == 0 { + return errors.New("missing nonce") + } + if e.tokenVersion == RATSDTokenVersionV2 && (len(nonce) < 8 || len(nonce) > 64) { + return fmt.Errorf("nonce size must be between 8 and 64 bytes for token version 2") + } + + e.SetClaim(eatClaimNonce, nonce) + return nil +} + +// AddToken inserts a sub-attester token into the CMW collection. +func (e *Evidence) AddToken(key, mediaType string, token []byte) error { + if e.collection == nil { + return errors.New("missing CMW collection") + } + if err := e.collection.AddCollectionItem(key, cmw.NewMonad(mediaType, token)); err != nil { + return fmt.Errorf("failed to add CMW item for %s: %w", key, err) + } + + return nil +} + +// AddSigningCert adds a DER-encoded X.509 certificate to the COSE x5chain header. +func (e *Evidence) AddSigningCert(der []byte) error { + if len(der) == 0 { + return errors.New("nil signing cert") + } + + cert, err := x509.ParseCertificate(der) + if err != nil { + return fmt.Errorf("invalid signing certificate: %w", err) + } + + e.SigningCert = cert + return nil +} + +// AddIntermediateCerts adds concatenated DER-encoded X.509 certificates to the COSE x5chain header. +func (e *Evidence) AddIntermediateCerts(der []byte) error { + if len(der) == 0 { + return errors.New("nil or empty intermediate certs") + } + + certs, err := x509.ParseCertificates(der) + if err != nil { + return fmt.Errorf("invalid intermediate certificates: %w", err) + } + if len(certs) == 0 { + return errors.New("no certificates found in intermediate cert data") + } + + e.IntermediateCerts = certs + return nil +} + +// Valid checks whether the builder has enough information to serialize an evidence payload. +func (e *Evidence) Valid() error { + if e == nil { + return errors.New("nil evidence") + } + if e.collection == nil { + return errors.New("missing CMW collection") + } + if e.collection.GetKind() != cmw.KindCollection { + return fmt.Errorf("want collection, got %q", e.collection.GetKind()) + } + if _, err := e.collection.MarshalCBOR(); err != nil { + return fmt.Errorf("invalid CMW collection: %w", err) + } + + nonce, err := e.nonce() + if err != nil { + return err + } + if e.tokenVersion == RATSDTokenVersionV2 { + profile, ok := e.claims[eatClaimProfile].(string) + if !ok || profile != RATSDV2Profile { + return errors.New("missing or invalid token version 2 profile claim") + } + if len(nonce) < 8 || len(nonce) > 64 { + return fmt.Errorf("nonce size must be between 8 and 64 bytes for token version 2") + } + } + + return nil +} + +// MarshalCollection serializes the unsigned evidence collection as CBOR. +func (e *Evidence) MarshalCollection() ([]byte, error) { + if err := e.Valid(); err != nil { + return nil, err + } + + if e.tokenVersion == RATSDTokenVersionV2 { + collection, err := e.buildSignedCollection() + if err != nil { + return nil, err + } + return collection.MarshalCBOR() + } + + return e.collection.MarshalCBOR() +} + +// Sign signs the evidence collection as a COSE_Sign1 token using a go-cose signer. +func (e *Evidence) Sign(signer cose.Signer) ([]byte, error) { + if e.tokenVersion != RATSDTokenVersionV2 { + return nil, errors.New("sign is only supported for token version 2") + } + if signer == nil { + return nil, errors.New("nil COSE signer") + } + if err := e.ensureCertificateChainLoaded(); err != nil { + return nil, err + } + + collection, err := e.buildSignedCollection() + if err != nil { + return nil, err + } + + token, err := signCollectionV2(collection, signer, e.SigningCert, e.IntermediateCerts) + if err != nil { + return nil, err + } + + msg := cose.NewSign1Message() + if err := msg.UnmarshalCBOR(token); err != nil { + return nil, fmt.Errorf("failed to decode generated COSE_Sign1 token: %w", err) + } + e.message = msg + + return token, nil +} + +// Marshal serializes the evidence token in the configured wire format. +func (e *Evidence) Marshal() ([]byte, error) { + if err := e.Valid(); err != nil { + return nil, err + } + + switch e.tokenVersion { + case RATSDTokenVersionV2: + if e.certPath == "" || e.certKeyPath == "" { + return nil, errors.New("token version 2 requires Sign(signer) or signer path configuration") + } + signer, signingCert, intermediateCerts, err := loadSignerFromPaths(e.certPath, e.certKeyPath) + if err != nil { + return nil, err + } + if e.SigningCert == nil { + e.SigningCert = signingCert + e.IntermediateCerts = intermediateCerts + } + return e.Sign(signer) + case RATSDTokenVersionLegacy: + return e.marshalLegacy() + default: + return nil, fmt.Errorf("unsupported token version %d", e.tokenVersion) + } +} + +// Unmarshal decodes a serialized evidence token into the Evidence structure. +func (e *Evidence) Unmarshal(data []byte) error { + if e == nil { + return errors.New("nil evidence") + } + + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return errors.New("empty evidence token") + } + + if trimmed[0] == '{' { + return e.unmarshalLegacy(trimmed) + } + + return e.unmarshalV2(trimmed) +} + +// Verify verifies the signed token against the current CMW collection state. +func (e *Evidence) Verify(verifier cose.Verifier) error { + if e == nil { + return errors.New("nil evidence") + } + if e.tokenVersion != RATSDTokenVersionV2 { + return errors.New("verify is only supported for token version 2") + } + if e.message == nil { + return errors.New("missing signed token") + } + + if verifier == nil { + if e.SigningCert == nil { + return errors.New("missing verifier and embedded signing certificate") + } + alg, err := e.message.Headers.Protected.Algorithm() + if err != nil { + return fmt.Errorf("failed to determine COSE algorithm: %w", err) + } + verifier, err = cose.NewVerifier(alg, e.SigningCert.PublicKey) + if err != nil { + return fmt.Errorf("failed to create verifier from embedded signing certificate: %w", err) + } + } + + return e.message.Verify(nil, verifier) +} + +// ResponseMediaType returns the media type for the requested token version. +func ResponseMediaType(tokenVersion int) string { + switch tokenVersion { + case RATSDTokenVersionV2: + return RATSDTokenMediaTypeV2 + default: + return fmt.Sprintf(`application/eat-ucs+json; eat_profile=%q`, RATSDLegacyProfile) + } +} + +func (e *Evidence) marshalLegacy() ([]byte, error) { + nonce, err := e.nonce() + if err != nil { + return nil, err + } + + serialized, err := e.collection.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("failed to serialize CMW collection: %w", err) + } + + token, err := json.Marshal(map[string]any{ + "eat_profile": RATSDLegacyProfile, + "eat_nonce": base64.RawURLEncoding.EncodeToString(nonce), + "cmw": serialized, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize legacy token: %w", err) + } + + return token, nil +} + +func (e *Evidence) buildSignedCollection() (*cmw.CMW, error) { + collection, err := cloneCollection(e.collection) + if err != nil { + return nil, err + } + + claimsRecord, err := e.marshalClaimsRecordV2() + if err != nil { + return nil, err + } + + if err := collection.AddCollectionItem(claimsKeyRATSD, cmw.NewMonad(RATSDClaimsMediaTypeV2, claimsRecord)); err != nil { + return nil, fmt.Errorf("failed to add ratsd claims: %w", err) + } + + return collection, nil +} + +func (e *Evidence) marshalClaimsRecordV2() ([]byte, error) { + if e.tokenVersion != RATSDTokenVersionV2 { + return nil, errors.New("claims record is only supported for token version 2") + } + + claims := e.Claims() + profile, ok := claims[eatClaimProfile].(string) + if !ok || profile != RATSDV2Profile { + return nil, errors.New("missing or invalid token version 2 profile claim") + } + + nonceValue, ok := claims[eatClaimNonce] + if !ok { + return nil, errors.New("missing nonce") + } + nonce, ok := nonceValue.([]byte) + if !ok { + return nil, fmt.Errorf("nonce claim must be []byte, got %T", nonceValue) + } + if len(nonce) < 8 || len(nonce) > 64 { + return nil, fmt.Errorf("nonce size must be between 8 and 64 bytes for token version 2") + } + + claims[eatClaimNonce] = append([]byte(nil), nonce...) + record, err := cbor.Marshal(cbor.Tag{ + Number: cborTagEATClaims, + Content: claims, + }) + if err != nil { + return nil, fmt.Errorf("failed to serialize ratsd claims: %w", err) + } + + return record, nil +} + +func (e *Evidence) ensureCertificateChainLoaded() error { + if e.SigningCert != nil { + return nil + } + if len(e.IntermediateCerts) > 0 { + return errors.New("intermediate certificates supplied but no signing certificate") + } + if e.certPath == "" { + return nil + } + + chain, err := loadCertificateChain(e.certPath) + if err != nil { + return err + } + e.SigningCert = chain[0] + e.IntermediateCerts = chain[1:] + return nil +} + +func (e *Evidence) nonce() ([]byte, error) { + value, ok := e.claims[eatClaimNonce] + if !ok { + return nil, errors.New("missing nonce") + } + + nonce, ok := value.([]byte) + if !ok { + return nil, fmt.Errorf("nonce claim must be []byte, got %T", value) + } + + return append([]byte(nil), nonce...), nil +} + +func (e *Evidence) unmarshalLegacy(data []byte) error { + var wire struct { + Profile string `json:"eat_profile"` + Nonce string `json:"eat_nonce"` + CMW string `json:"cmw"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return fmt.Errorf("failed to decode legacy token: %w", err) + } + + nonce, err := base64.RawURLEncoding.DecodeString(wire.Nonce) + if err != nil { + return fmt.Errorf("failed to decode legacy nonce: %w", err) + } + collectionJSON, err := base64.StdEncoding.DecodeString(wire.CMW) + if err != nil { + return fmt.Errorf("failed to decode legacy CMW payload: %w", err) + } + + collection := &cmw.CMW{} + if err := collection.UnmarshalJSON(collectionJSON); err != nil { + return fmt.Errorf("failed to decode legacy CMW payload: %w", err) + } + + e.tokenVersion = RATSDTokenVersionLegacy + e.claims = map[int]any{ + eatClaimNonce: nonce, + } + e.collection = collection + e.message = nil + e.SigningCert = nil + e.IntermediateCerts = nil + + return nil +} + +func (e *Evidence) unmarshalV2(data []byte) error { + msg := cose.NewSign1Message() + if err := msg.UnmarshalCBOR(data); err != nil { + return fmt.Errorf("failed to decode COSE_Sign1 token: %w", err) + } + + collection := &cmw.CMW{} + if err := collection.UnmarshalCBOR(msg.Payload); err != nil { + return fmt.Errorf("failed to decode signed CMW collection: %w", err) + } + + claims, err := extractClaimsFromCollection(collection) + if err != nil { + return err + } + stripped, err := stripClaimsItem(collection) + if err != nil { + return err + } + + signingCert, intermediateCerts, err := parseCertificateChainHeader(msg.Headers.Protected[cose.HeaderLabelX5Chain]) + if err != nil { + return err + } + + e.tokenVersion = RATSDTokenVersionV2 + e.claims = claims + e.collection = stripped + e.message = msg + e.SigningCert = signingCert + e.IntermediateCerts = intermediateCerts + + return nil +} + +func extractClaimsFromCollection(collection *cmw.CMW) (map[int]any, error) { + claimsRecord, err := collection.GetCollectionItem(claimsKeyRATSD) + if err != nil { + return nil, fmt.Errorf("failed to retrieve ratsd claims: %w", err) + } + if claimsRecord.GetMonadType() != RATSDClaimsMediaTypeV2 { + return nil, fmt.Errorf("unexpected ratsd claims media type %q", claimsRecord.GetMonadType()) + } + + var claimsTag cbor.Tag + if err := cbor.Unmarshal(claimsRecord.GetMonadValue(), &claimsTag); err != nil { + return nil, fmt.Errorf("failed to decode ratsd claims: %w", err) + } + if claimsTag.Number != cborTagEATClaims { + return nil, fmt.Errorf("unexpected ratsd claims CBOR tag %d", claimsTag.Number) + } + + claimsBytes, err := cbor.Marshal(claimsTag.Content) + if err != nil { + return nil, fmt.Errorf("failed to normalize ratsd claims: %w", err) + } + + var claims map[int]any + if err := cbor.Unmarshal(claimsBytes, &claims); err != nil { + return nil, fmt.Errorf("failed to decode ratsd claims map: %w", err) + } + + return claims, nil +} + +func stripClaimsItem(collection *cmw.CMW) (*cmw.CMW, error) { + collectionType, err := collection.GetCollectionType() + if err != nil { + return nil, fmt.Errorf("failed to determine collection type: %w", err) + } + + stripped := cmw.NewCollection(collectionType) + if stripped == nil { + return nil, errors.New("failed to initialize CMW collection") + } + + meta, err := collection.GetCollectionMeta() + if err != nil { + return nil, fmt.Errorf("failed to enumerate collection items: %w", err) + } + + for _, entry := range meta { + if key, ok := entry.Key.(string); ok && key == claimsKeyRATSD { + continue + } + + node, err := collection.GetCollectionItem(entry.Key) + if err != nil { + return nil, fmt.Errorf("failed to retrieve collection item %v: %w", entry.Key, err) + } + cloned, err := cloneNode(node) + if err != nil { + return nil, err + } + if err := stripped.AddCollectionItem(entry.Key, cloned); err != nil { + return nil, fmt.Errorf("failed to clone collection item %v: %w", entry.Key, err) + } + } + + return stripped, nil +} + +func cloneNode(node *cmw.CMW) (*cmw.CMW, error) { + serialized, err := node.MarshalCBOR() + if err != nil { + return nil, fmt.Errorf("failed to serialize CMW node: %w", err) + } + + cloned := &cmw.CMW{} + if err := cloned.UnmarshalCBOR(serialized); err != nil { + return nil, fmt.Errorf("failed to clone CMW node: %w", err) + } + + return cloned, nil +} + +func parseCertificateChainHeader(value any) (*x509.Certificate, []*x509.Certificate, error) { + if value == nil { + return nil, nil, nil + } + + switch typed := value.(type) { + case []byte: + cert, err := x509.ParseCertificate(typed) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse x5chain leaf certificate: %w", err) + } + return cert, nil, nil + case [][]byte: + return parseRawCertificateChain(typed) + case []any: + rawChain := make([][]byte, 0, len(typed)) + for _, item := range typed { + raw, ok := item.([]byte) + if !ok { + return nil, nil, fmt.Errorf("unexpected x5chain element type %T", item) + } + rawChain = append(rawChain, raw) + } + return parseRawCertificateChain(rawChain) + default: + return nil, nil, fmt.Errorf("unexpected x5chain header type %T", value) + } +} + +func parseRawCertificateChain(rawChain [][]byte) (*x509.Certificate, []*x509.Certificate, error) { + if len(rawChain) == 0 { + return nil, nil, errors.New("empty x5chain header") + } + + parsed := make([]*x509.Certificate, 0, len(rawChain)) + for _, raw := range rawChain { + cert, err := x509.ParseCertificate(raw) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse x5chain certificate: %w", err) + } + parsed = append(parsed, cert) + } + + return parsed[0], parsed[1:], nil +} + +func cloneCollection(collection *cmw.CMW) (*cmw.CMW, error) { + serialized, err := collection.MarshalCBOR() + if err != nil { + return nil, fmt.Errorf("failed to serialize CMW collection as CBOR: %w", err) + } + + cloned := &cmw.CMW{} + if err := cloned.UnmarshalCBOR(serialized); err != nil { + return nil, fmt.Errorf("failed to clone CMW collection: %w", err) + } + + return cloned, nil +} diff --git a/ratsd-token/evidence_test.go b/ratsd-token/evidence_test.go new file mode 100644 index 0000000..ab92806 --- /dev/null +++ b/ratsd-token/evidence_test.go @@ -0,0 +1,195 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package tokens + +import ( + "encoding/base64" + "encoding/json" + "path/filepath" + "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/stretchr/testify/assert" + "github.com/veraison/cmw" + cose "github.com/veraison/go-cose" +) + +const ( + testCertPath = "../ratsd.crt" + testKeyPath = "../ratsd.key" +) + +func TestNewEvidence_rejectsUnsupportedTokenVersion(t *testing.T) { + _, err := NewEvidence(3) + assert.EqualError(t, err, "unsupported token version 3") +} + +func TestEvidenceMarshalLegacy(t *testing.T) { + evidence, err := NewEvidence(RATSDTokenVersionLegacy) + assert.NoError(t, err) + assert.Equal(t, ResponseMediaType(RATSDTokenVersionLegacy), evidence.MediaType()) + + nonce := []byte("12345678") + err = evidence.AddNonce(nonce) + assert.NoError(t, err) + + err = evidence.AddToken("test-attester", "application/test", []byte("payload")) + assert.NoError(t, err) + + token, err := evidence.Marshal() + assert.NoError(t, err) + + var out map[string]string + err = json.Unmarshal(token, &out) + assert.NoError(t, err) + assert.Equal(t, RATSDLegacyProfile, out["eat_profile"]) + assert.Equal(t, base64.RawURLEncoding.EncodeToString(nonce), out["eat_nonce"]) + + collectionJSON, err := base64.StdEncoding.DecodeString(out["cmw"]) + assert.NoError(t, err) + + collection := &cmw.CMW{} + err = collection.UnmarshalJSON(collectionJSON) + assert.NoError(t, err) + + item, err := collection.GetCollectionItem("test-attester") + assert.NoError(t, err) + assert.Equal(t, "application/test", item.GetMonadType()) + assert.Equal(t, []byte("payload"), item.GetMonadValue()) +} + +func TestEvidenceMarshalCollectionV2(t *testing.T) { + evidence, err := NewEvidence(RATSDTokenVersionV2) + assert.NoError(t, err) + + nonce := []byte("12345678") + err = evidence.AddNonce(nonce) + assert.NoError(t, err) + evidence.SetClaim(1000, "custom-claim") + + err = evidence.AddToken("test-attester", "application/test", []byte("payload")) + assert.NoError(t, err) + + payload, err := evidence.MarshalCollection() + assert.NoError(t, err) + + collection := &cmw.CMW{} + err = collection.UnmarshalCBOR(payload) + assert.NoError(t, err) + + item, err := collection.GetCollectionItem("test-attester") + assert.NoError(t, err) + assert.Equal(t, "application/test", item.GetMonadType()) + assert.Equal(t, []byte("payload"), item.GetMonadValue()) + + claimsRecord, err := collection.GetCollectionItem(claimsKeyRATSD) + assert.NoError(t, err) + assert.Equal(t, RATSDClaimsMediaTypeV2, claimsRecord.GetMonadType()) + + var claimsTag cbor.Tag + err = cbor.Unmarshal(claimsRecord.GetMonadValue(), &claimsTag) + assert.NoError(t, err) + assert.Equal(t, uint64(601), claimsTag.Number) + + claimsBytes, err := cbor.Marshal(claimsTag.Content) + assert.NoError(t, err) + + var claims map[int]any + err = cbor.Unmarshal(claimsBytes, &claims) + assert.NoError(t, err) + assert.Equal(t, RATSDV2Profile, claims[265]) + assert.Equal(t, nonce, claims[10]) + assert.Equal(t, "custom-claim", claims[1000]) +} + +func TestEvidenceSignUnmarshalAndVerifyV2(t *testing.T) { + evidence, err := NewEvidence( + RATSDTokenVersionV2, + WithSignerPaths(filepath.Join(testCertPath), filepath.Join(testKeyPath)), + ) + assert.NoError(t, err) + + nonce := []byte("12345678") + err = evidence.AddNonce(nonce) + assert.NoError(t, err) + evidence.SetClaim(1000, "custom-claim") + + err = evidence.AddToken("test-attester", "application/test", []byte("payload")) + assert.NoError(t, err) + + signer, _, _, err := loadSignerFromPaths(filepath.Join(testCertPath), filepath.Join(testKeyPath)) + assert.NoError(t, err) + + token, err := evidence.Sign(signer) + assert.NoError(t, err) + + msg := decodeTokenV2(t, token) + assert.NotEmpty(t, msg.Signature) + + parsed := &Evidence{} + err = parsed.Unmarshal(token) + assert.NoError(t, err) + assert.Equal(t, RATSDTokenVersionV2, parsed.tokenVersion) + assert.Equal(t, ResponseMediaType(RATSDTokenVersionV2), parsed.MediaType()) + assert.Equal(t, RATSDV2Profile, parsed.Claims()[265]) + assert.Equal(t, nonce, parsed.Claims()[10]) + assert.Equal(t, "custom-claim", parsed.Claims()[1000]) + assert.NotNil(t, parsed.SigningCert) + + item, err := parsed.Collection().GetCollectionItem("test-attester") + assert.NoError(t, err) + assert.Equal(t, "application/test", item.GetMonadType()) + assert.Equal(t, []byte("payload"), item.GetMonadValue()) + + err = parsed.Verify(nil) + assert.NoError(t, err) + + verifier, err := cose.NewVerifier(cose.AlgorithmES256, parsed.SigningCert.PublicKey) + assert.NoError(t, err) + err = parsed.Verify(verifier) + assert.NoError(t, err) +} + +func TestEvidenceMarshalV2UsesSignerPaths(t *testing.T) { + evidence, err := NewEvidence( + RATSDTokenVersionV2, + WithSignerPaths(filepath.Join(testCertPath), filepath.Join(testKeyPath)), + ) + assert.NoError(t, err) + + err = evidence.AddNonce([]byte("12345678")) + assert.NoError(t, err) + err = evidence.AddToken("test-attester", "application/test", []byte("payload")) + assert.NoError(t, err) + + token, err := evidence.Marshal() + assert.NoError(t, err) + + verifyTokenV2Signature(t, token, filepath.Join(testCertPath)) +} + +func TestEvidenceVerifyFailsWhenSignedPayloadIsTampered(t *testing.T) { + evidence, err := NewEvidence( + RATSDTokenVersionV2, + WithSignerPaths(filepath.Join(testCertPath), filepath.Join(testKeyPath)), + ) + assert.NoError(t, err) + + err = evidence.AddNonce([]byte("12345678")) + assert.NoError(t, err) + err = evidence.AddToken("test-attester", "application/test", []byte("payload")) + assert.NoError(t, err) + + token, err := evidence.Marshal() + assert.NoError(t, err) + + parsed := &Evidence{} + err = parsed.Unmarshal(token) + assert.NoError(t, err) + + parsed.message.Payload = []byte("tampered") + + err = parsed.Verify(nil) + assert.ErrorIs(t, err, cose.ErrVerification) +} diff --git a/ratsd-token/ratsd.go b/ratsd-token/ratsd.go new file mode 100644 index 0000000..d94628b --- /dev/null +++ b/ratsd-token/ratsd.go @@ -0,0 +1,277 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package token + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + + "github.com/fxamacker/cbor/v2" + "github.com/veraison/cmw" + cose "github.com/veraison/go-cose" +) + +const ( + RATSDTokenVersionLegacy = 1 + RATSDTokenVersionV2 = 2 + + RATSDLegacyProfile = "tag:github.com,2024:veraison/ratsd" + RATSDV2Profile = "tag:github.com,2026:veraison/ratsd/v2" + + RATSDCollectionTypeLegacy = "tag:github.com,2025:veraison/ratsd/cmw" + RATSDCollectionTypeV2 = "tag:github.com,2025:veraison/ratsd/cmw/v2" + + RATSDTokenMediaTypeV2 = "application/cmw+cbor; cmwct=\"" + RATSDV2Profile + "\"" + RATSDClaimsMediaTypeV2 = "application/eat-ucs+cbor; eat_profile=\"" + RATSDV2Profile + "\"" +) + +const ( + cborTagEATClaims uint64 = 601 + eatClaimNonce int = 10 + eatClaimProfile int = 265 +) + +// AddClaimsToCollectionV2 inserts the ratsd claims record required by token v2. +func AddClaimsToCollectionV2(collection *cmw.CMW, nonce []byte) error { + if collection == nil { + return errors.New("nil CMW collection") + } + if len(nonce) < 8 || len(nonce) > 64 { + return fmt.Errorf("nonce size must be between 8 and 64 bytes for token version 2") + } + + claims, err := cbor.Marshal(cbor.Tag{ + Number: cborTagEATClaims, + Content: map[int]any{ + eatClaimProfile: RATSDV2Profile, + eatClaimNonce: nonce, + }, + }) + if err != nil { + return fmt.Errorf("failed to serialize ratsd claims: %w", err) + } + + if err := collection.AddCollectionItem("__ratsd", cmw.NewMonad(RATSDClaimsMediaTypeV2, claims)); err != nil { + return fmt.Errorf("failed to add ratsd claims: %w", err) + } + + return nil +} + +// CreateTokenV2 signs the supplied v2 CMW collection as a COSE_Sign1 token. +func CreateTokenV2(collection *cmw.CMW, certPath, keyPath string) ([]byte, error) { + if collection == nil { + return nil, errors.New("nil CMW collection") + } + if certPath == "" || keyPath == "" { + return nil, errors.New("token version 2 requires cert and cert-key configuration") + } + + signer, leafCert, intermediateCerts, err := loadSignerFromPaths(certPath, keyPath) + if err != nil { + return nil, err + } + + return signCollectionV2(collection, signer, leafCert, intermediateCerts) +} + +func loadCertificateChain(path string) ([]*x509.Certificate, error) { + pemData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read certificate %q: %w", path, err) + } + + var chain []*x509.Certificate + rest := pemData + for { + block, remainder := pem.Decode(rest) + if block == nil { + if len(bytes.TrimSpace(rest)) != 0 { + return nil, fmt.Errorf("failed to parse certificate %q", path) + } + break + } + rest = remainder + if block.Type == "CERTIFICATE" { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate from %q: %w", path, err) + } + chain = append(chain, cert) + } + } + + if len(chain) == 0 { + return nil, fmt.Errorf("no certificate found in %q", path) + } + + return chain, nil +} + +func loadPrivateKey(path string) (crypto.Signer, error) { + pemData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read private key %q: %w", path, err) + } + + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("failed to parse private key %q", path) + } + + switch block.Type { + case "EC PRIVATE KEY": + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse EC private key %q: %w", path, err) + } + return key, nil + case "RSA PRIVATE KEY": + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse RSA private key %q: %w", path, err) + } + return key, nil + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS#8 private key %q: %w", path, err) + } + signer, ok := key.(crypto.Signer) + if !ok { + return nil, fmt.Errorf("unsupported PKCS#8 private key type %T in %q", key, path) + } + return signer, nil + default: + return nil, fmt.Errorf("unsupported private key PEM block %q in %q", block.Type, path) + } +} + +func ensureKeyMatchesCertificate(key crypto.Signer, cert *x509.Certificate) error { + if cert == nil { + return errors.New("missing signing certificate") + } + + keyPub, err := x509.MarshalPKIXPublicKey(key.Public()) + if err != nil { + return fmt.Errorf("failed to marshal signing public key: %w", err) + } + certPub, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err != nil { + return fmt.Errorf("failed to marshal certificate public key: %w", err) + } + if !bytes.Equal(keyPub, certPub) { + return errors.New("certificate and private key do not match") + } + + return nil +} + +func selectSigningAlgorithm(key crypto.Signer) (cose.Algorithm, error) { + switch k := key.(type) { + case *ecdsa.PrivateKey: + switch k.Curve.Params().BitSize { + case 256: + return cose.AlgorithmES256, nil + case 384: + return cose.AlgorithmES384, nil + case 521: + return cose.AlgorithmES512, nil + default: + return 0, fmt.Errorf("unsupported ECDSA curve size %d", k.Curve.Params().BitSize) + } + case *rsa.PrivateKey: + switch { + case k.N.BitLen() >= 4096: + return cose.AlgorithmPS512, nil + case k.N.BitLen() >= 3072: + return cose.AlgorithmPS384, nil + case k.N.BitLen() >= 2048: + return cose.AlgorithmPS256, nil + default: + return 0, fmt.Errorf("RSA key must be at least 2048 bits, got %d", k.N.BitLen()) + } + default: + return 0, fmt.Errorf("unsupported private key type %T", key) + } +} + +func loadSignerFromPaths(certPath, keyPath string) (cose.Signer, *x509.Certificate, []*x509.Certificate, error) { + chain, err := loadCertificateChain(certPath) + if err != nil { + return nil, nil, nil, err + } + + key, err := loadPrivateKey(keyPath) + if err != nil { + return nil, nil, nil, err + } + if err := ensureKeyMatchesCertificate(key, chain[0]); err != nil { + return nil, nil, nil, err + } + + alg, err := selectSigningAlgorithm(key) + if err != nil { + return nil, nil, nil, err + } + + signer, err := cose.NewSigner(alg, key) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create COSE signer: %w", err) + } + + return signer, chain[0], chain[1:], nil +} + +func signCollectionV2(collection *cmw.CMW, signer cose.Signer, signingCert *x509.Certificate, intermediateCerts []*x509.Certificate) ([]byte, error) { + if collection == nil { + return nil, errors.New("nil CMW collection") + } + if signer == nil { + return nil, errors.New("nil COSE signer") + } + + payload, err := collection.MarshalCBOR() + if err != nil { + return nil, fmt.Errorf("failed to serialize CMW collection as CBOR: %w", err) + } + + msg := cose.NewSign1Message() + msg.Payload = payload + msg.Headers.Protected.SetAlgorithm(signer.Algorithm()) + + if signingCert != nil { + if len(intermediateCerts) == 0 { + msg.Headers.Protected[cose.HeaderLabelX5Chain] = signingCert.Raw + } else { + chain := make([][]byte, 0, len(intermediateCerts)+1) + chain = append(chain, signingCert.Raw) + for _, cert := range intermediateCerts { + chain = append(chain, cert.Raw) + } + msg.Headers.Protected[cose.HeaderLabelX5Chain] = chain + } + } else if len(intermediateCerts) > 0 { + return nil, errors.New("intermediate certificates supplied but no signing certificate") + } + + if err := msg.Sign(rand.Reader, nil, signer); err != nil { + return nil, fmt.Errorf("failed to sign CMW collection: %w", err) + } + + token, err := msg.MarshalCBOR() + if err != nil { + return nil, fmt.Errorf("failed to serialize COSE_Sign1 token: %w", err) + } + + return token, nil +} diff --git a/ratsd-token/ratsd_test.go b/ratsd-token/ratsd_test.go new file mode 100644 index 0000000..321e4ce --- /dev/null +++ b/ratsd-token/ratsd_test.go @@ -0,0 +1,169 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package tokens + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/stretchr/testify/assert" + "github.com/veraison/cmw" +) + +type testCOSESign1Message struct { + _ struct{} `cbor:",toarray"` + Protected []byte + Unprotected map[any]any + Payload []byte + Signature []byte +} + +func TestAddClaimsToCollectionV2_rejectsInvalidNonce(t *testing.T) { + collection := cmw.NewCollection(RATSDCollectionTypeV2) + err := AddClaimsToCollectionV2(collection, []byte("short")) + assert.EqualError(t, err, "nonce size must be between 8 and 64 bytes for token version 2") +} + +func TestCreateTokenV2(t *testing.T) { + collection := cmw.NewCollection(RATSDCollectionTypeV2) + err := collection.AddCollectionItem("test-attester", cmw.NewMonad("application/test", []byte("payload"))) + assert.NoError(t, err) + + nonce := []byte("12345678") + err = AddClaimsToCollectionV2(collection, nonce) + assert.NoError(t, err) + + token, err := CreateTokenV2( + collection, + filepath.Join("..", "ratsd.crt"), + filepath.Join("..", "ratsd.key"), + ) + assert.NoError(t, err) + + msg := decodeTokenV2(t, token) + assert.NotEmpty(t, msg.Signature) + assert.Empty(t, msg.Unprotected) + + var protected map[int64]any + err = cbor.Unmarshal(msg.Protected, &protected) + assert.NoError(t, err) + assert.Equal(t, int64(-7), protected[1]) + + x5chain, ok := protected[33].([]byte) + assert.True(t, ok) + assert.NotEmpty(t, x5chain) + + decoded := &cmw.CMW{} + err = decoded.UnmarshalCBOR(msg.Payload) + assert.NoError(t, err) + + claimsRecord, err := decoded.GetCollectionItem("__ratsd") + assert.NoError(t, err) + assert.Equal(t, RATSDClaimsMediaTypeV2, claimsRecord.GetMonadType()) + + var claimsTag cbor.Tag + err = cbor.Unmarshal(claimsRecord.GetMonadValue(), &claimsTag) + assert.NoError(t, err) + assert.Equal(t, uint64(601), claimsTag.Number) + + claimsBytes, err := cbor.Marshal(claimsTag.Content) + assert.NoError(t, err) + + var claims map[int]any + err = cbor.Unmarshal(claimsBytes, &claims) + assert.NoError(t, err) + assert.Equal(t, RATSDV2Profile, claims[265]) + assert.Equal(t, nonce, claims[10]) + + verifyTokenV2Signature(t, token, filepath.Join("..", "ratsd.crt")) +} + +func decodeTokenV2(t *testing.T, data []byte) *testCOSESign1Message { + t.Helper() + + var tag cbor.Tag + err := cbor.Unmarshal(data, &tag) + assert.NoError(t, err) + assert.Equal(t, uint64(18), tag.Number) + + content, err := cbor.Marshal(tag.Content) + assert.NoError(t, err) + + msg := &testCOSESign1Message{} + err = cbor.Unmarshal(content, msg) + assert.NoError(t, err) + + return msg +} + +func verifyTokenV2Signature(t *testing.T, data []byte, certPath string) { + t.Helper() + + msg := decodeTokenV2(t, data) + + var protected map[int64]any + err := cbor.Unmarshal(msg.Protected, &protected) + assert.NoError(t, err) + + toBeSigned, err := cbor.Marshal([]any{ + "Signature1", + msg.Protected, + []byte{}, + msg.Payload, + }) + assert.NoError(t, err) + + cert := loadLeafCertificate(t, certPath) + + switch pub := cert.PublicKey.(type) { + case *ecdsa.PublicKey: + hash := crypto.SHA256 + hasher := hash.New() + _, err = hasher.Write(toBeSigned) + assert.NoError(t, err) + digest := hasher.Sum(nil) + + size := len(msg.Signature) / 2 + r := new(big.Int).SetBytes(msg.Signature[:size]) + s := new(big.Int).SetBytes(msg.Signature[size:]) + assert.True(t, ecdsa.Verify(pub, digest, r, s)) + case *rsa.PublicKey: + hash := crypto.SHA256 + hasher := hash.New() + _, err = hasher.Write(toBeSigned) + assert.NoError(t, err) + digest := hasher.Sum(nil) + err = rsa.VerifyPSS(pub, hash, digest, msg.Signature, &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthEqualsHash, + Hash: hash, + }) + assert.NoError(t, err) + default: + t.Fatalf("unexpected certificate key type %T", cert.PublicKey) + } +} + +func loadLeafCertificate(t *testing.T, path string) *x509.Certificate { + t.Helper() + + pemData, err := os.ReadFile(path) + assert.NoError(t, err) + + block, _ := pem.Decode(pemData) + if block == nil { + t.Fatalf("failed to parse certificate %s", path) + } + + cert, err := x509.ParseCertificate(block.Bytes) + assert.NoError(t, err) + return cert +} diff --git a/tokens/tsm-report.go b/tokens/tsm-report.go index 059f11d..9cf564b 100644 --- a/tokens/tsm-report.go +++ b/tokens/tsm-report.go @@ -1,3 +1,6 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + package tokens import ( diff --git a/tokens/tsm-report_test.go b/tokens/tsm-report_test.go index 582de18..43a9c05 100644 --- a/tokens/tsm-report_test.go +++ b/tokens/tsm-report_test.go @@ -1,5 +1,6 @@ -// Copyright 2025 Contributors to the Veraison project. +// Copyright 2026 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 + package tokens import (