diff --git a/appraisal.go b/appraisal.go index 3314f76..0a6e6e5 100644 --- a/appraisal.go +++ b/appraisal.go @@ -16,14 +16,14 @@ import ( // other metadata that are relevant to establish the appraisal context - the // evidence itself, the appraisal policy used, the time of appraisal. type Appraisal struct { - Profile *eat.Profile `json:"eat_profile,omitempty"` - Status TrustTier `json:"ear_status"` - TrustVector *TrustVector `json:"ear_trustworthiness_vector,omitzero"` - AppraisalPolicyIDs []string `json:"ear_appraisal_policy_ids,omitempty"` - Nonce *eat.Nonce `json:"eat_nonce,omitempty"` - AttesterClaims map[eat.IntOrString]any `json:"ear_attester_claims,omitempty"` - VerifierClaims map[eat.IntOrString]any `json:"ear_verifier_claims,omitempty"` - Extensions *Extensions `json:"-"` + Profile *eat.Profile `cbor:"265,keyasint,omitempty" json:"eat_profile,omitempty"` + Status TrustTier `cbor:"1000,keyasint" json:"ear_status"` + TrustVector *TrustVector `cbor:"1001,keyasint,omitempty" json:"ear_trustworthiness_vector,omitzero"` + AppraisalPolicyIDs []string `cbor:"1003,keyasint,omitempty" json:"ear_appraisal_policy_ids,omitempty"` + Nonce *eat.Nonce `cbor:"10,keyasint,omitempty" json:"eat_nonce,omitempty"` + AttesterClaims map[eat.IntOrString]any `cbor:"1005,keyasint,omitempty" json:"ear_attester_claims,omitempty"` + VerifierClaims map[eat.IntOrString]any `cbor:"1006,keyasint,omitempty" json:"ear_verifier_claims,omitempty"` + Extensions *Extensions `cbor:"-" json:"-"` } // NewAppraisal returns a new Appraisal with its status set to TrustTierNone @@ -58,7 +58,7 @@ func AppraisalFromAny(input any) (*Appraisal, error) { for key, value := range claims { switch key { - case eat.IntOrStringFromString("eat_profile"): + case eat.IntOrStringFromString("eat_profile"), eat.IntOrStringFromInt(265): profileText, ok := value.(string) if !ok { return nil, fmt.Errorf("eat_profile: expected a string, got %T", value) @@ -70,21 +70,21 @@ func AppraisalFromAny(input any) (*Appraisal, error) { } appraisal.Profile = profile - case eat.IntOrStringFromString("ear_status"): + case eat.IntOrStringFromString("ear_status"), eat.IntOrStringFromInt(1000): tier, err := TrustTierFromAny(value) if err != nil { return nil, fmt.Errorf("ear_status: %w", err) } appraisal.Status = tier - case eat.IntOrStringFromString("ear_trustworthiness_vector"): + case eat.IntOrStringFromString("ear_trustworthiness_vector"), eat.IntOrStringFromInt(1001): vector, err := TrustVectorFromAny(value) if err != nil { return nil, fmt.Errorf("ear_trustworthiness_vector: %w", err) } appraisal.TrustVector = vector - case eat.IntOrStringFromString("ear_appraisal_policy_ids"): + case eat.IntOrStringFromString("ear_appraisal_policy_ids"), eat.IntOrStringFromInt(1003): rawIds, ok := value.([]any) if !ok { return nil, fmt.Errorf( @@ -105,21 +105,21 @@ func AppraisalFromAny(input any) (*Appraisal, error) { } appraisal.AppraisalPolicyIDs = ids - case eat.IntOrStringFromString("eat_nonce"): + case eat.IntOrStringFromString("eat_nonce"), eat.IntOrStringFromInt(10): nonce, err := eat.NonceFromAny(value) if err != nil { return nil, fmt.Errorf("eat_nonce: %w", err) } appraisal.Nonce = nonce - case eat.IntOrStringFromString("ear_attester_claims"): + case eat.IntOrStringFromString("ear_attester_claims"), eat.IntOrStringFromInt(1005): claims, err := eat.IntOrStringMapFromAny(value) if err != nil { return nil, fmt.Errorf("ear_attester_claims: %w", err) } appraisal.AttesterClaims = claims - case eat.IntOrStringFromString("ear_verifier_claims"): + case eat.IntOrStringFromString("ear_verifier_claims"), eat.IntOrStringFromInt(1006): claims, err := eat.IntOrStringMapFromAny(value) if err != nil { return nil, fmt.Errorf("ear_verifier_claims: %w", err) @@ -226,3 +226,54 @@ func (o *Appraisal) UnmarshalJSON(data []byte) error { // nolint:dupl return nil } + +func (o *Appraisal) MarshalCBOR() ([]byte, error) { + if err := o.Validate(); err != nil { + return nil, err + } + + claims, err := structWithExtensionsToMap(o, "cbor") + if err != nil { + return nil, err + } + + return em.Marshal(claims) +} + +func (o *Appraisal) UnmarshalCBOR(data []byte) error { // nolint:dupl + type appraisalUnmarshalHelper Appraisal + + if err := dm.Unmarshal(data, (*appraisalUnmarshalHelper)(o)); err != nil { + return err + } + + var claims map[eat.IntOrString]any + if err := dm.Unmarshal(data, &claims); err != nil { + return err + } + + var extensions Extensions + seenStatus := false + knownLabels := getTagLabels(o, "cbor") + for label, value := range claims { + if label == eat.IntOrStringFromInt(1000) { + seenStatus = true + } + + if _, isKnown := knownLabels[label]; !isKnown { + extensions.Set(label, value) + } + } + + if !seenStatus { + return errors.New(`missing mandatory claim 1000 (ear_status)`) + } + + o.Extensions = &extensions + + if err := o.Validate(); err != nil { + return err + } + + return nil +} diff --git a/appraisal_test.go b/appraisal_test.go index f1f44b0..393397c 100644 --- a/appraisal_test.go +++ b/appraisal_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/veraison/eat" ) @@ -104,6 +105,7 @@ func TestAppraisal_round_trip(t *testing.T) { title string appraisal Appraisal expectedJSON string + expectedCBOR []byte }{ { title: "ok minimal", @@ -112,6 +114,11 @@ func TestAppraisal_round_trip(t *testing.T) { Extensions: NewExtensions(), }, expectedJSON: `{"ear_status": "affirming"}`, + expectedCBOR: []byte{ + 0xa1, // map(1) + 0x19, 0x03, 0xe8, // . key: 1000 [ear_status] + 0x02, // . value: 2 [affirming] + }, }, { title: "ok with extensions", @@ -120,6 +127,14 @@ func TestAppraisal_round_trip(t *testing.T) { Extensions: NewExtensions().StringSet("foo", true), }, expectedJSON: `{"ear_status": "affirming", "foo": true}`, + expectedCBOR: []byte{ + 0xa2, // map(2) + 0x19, 0x03, 0xe8, // . key: 1000 [ear_status] + 0x02, // . value: 2 [affirming] + 0x63, // . key: tstr(3) + 0x66, 0x6f, 0x6f, // . . "foo" + 0xf5, // . value: true + }, }, { title: "ok full", @@ -147,6 +162,45 @@ func TestAppraisal_round_trip(t *testing.T) { ), }, expectedJSON: `{"ear_appraisal_policy_ids":["test-policy"],"ear_attester_claims":{"ac-1":"foo"},"ear_status":"affirming","ear_trustworthiness_vector":{"instance-identity":2},"ear_verifier_claims":{"1":"foo"},"eat_nonce":"3q2-796tvu8","eat_profile":"http://test-profile","foo":{"bar":true}}`, + expectedCBOR: []byte{ + 0xa8, // map(8) + 0x0a, // . key: 10 [eat_nonce] + 0x48, // . value: bstr(8) + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, + 0x19, 0x01, 0x09, // . key: 265 [eat_profile] + 0x73, // . value: tstr(19) + 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x74, // . . "http://t" + 0x65, 0x73, 0x74, 0x2d, 0x70, 0x72, 0x6f, 0x66, // . . "est-prof" + 0x69, 0x6c, 0x65, // . . "ile" + 0x19, 0x03, 0xe8, // . key: 1000 [ear_status] + 0x02, // . value: 2 [affirming] + 0x19, 0x03, 0xe9, // . key: 1001 [ear_trustworthiness_vector] + 0xa1, // . value: map(1) [TrustVector] + 0x00, // . . key: 0 [instance-identity] + 0x02, // . . value: 2 [recognized_instance] + 0x19, 0x03, 0xeb, // . key: 1003 [ear_appraisal_policy_ids] + 0x81, // . value: array(1) + 0x6b, // . . [0]tstr(11) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x70, 0x6f, 0x6c, // . . . "test-pol" + 0x69, 0x63, 0x79, // . . . "icy" + 0x19, 0x03, 0xed, // . key: 1005 [ear_attester_claims] + 0xa1, // . value: map(1) + 0x64, // . . key: tstr(4) + 0x61, 0x63, 0x2d, 0x31, // . . . "ac-1" + 0x63, // . . value: tstr(3) + 0x66, 0x6f, 0x6f, // . . . "foo" + 0x19, 0x03, 0xee, // . key: 1006 [ear_verifier_claims] + 0xa1, // . value: map(1) + 0x01, // . . key: 1 + 0x63, // . . value: tstr(3) + 0x66, 0x6f, 0x6f, // . . . "foo" + 0x63, // . key: tstr(3) [extension] + 0x66, 0x6f, 0x6f, // . . "foo" + 0xa1, // . value: map(1) + 0x63, // . . key: tstr(3) + 0x62, 0x61, 0x72, // . . . "bar" + 0xf5, // . . value: true + }, }, } @@ -160,6 +214,31 @@ func TestAppraisal_round_trip(t *testing.T) { err = json.Unmarshal(encodedJSON, &decoded) assert.NoError(t, err) assert.EqualValues(t, tc.appraisal, decoded) + + encodedCBOR, err := tc.appraisal.MarshalCBOR() + assert.NoError(t, err) + assert.Equal(t, tc.expectedCBOR, encodedCBOR) + + err = decoded.UnmarshalCBOR(encodedCBOR) + assert.NoError(t, err) + + if tc.title == "ok full" { + // the map inside extension value gets + // unmarshaled as map[any]any in CBOR (but as + // map[string]any in JSON). So we need to + // manually adjust that here. + extMap, err := decoded.Extensions.GetMap("foo") + require.NoError(t, err) + + adjusted := make(map[string]any) + for key, value := range extMap { + adjusted[key.(string)] = value + } + + decoded.Extensions.StringSet("foo", adjusted) + } + + assert.EqualValues(t, tc.appraisal, decoded) }) } } diff --git a/arc/cmd/create.go b/arc/cmd/create.go index 41e3bdd..4bb41b4 100644 --- a/arc/cmd/create.go +++ b/arc/cmd/create.go @@ -1,4 +1,4 @@ -// Copyright 2022 Contributors to the Veraison project. +// Copyright 2022-2026 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -18,19 +18,21 @@ var ( createSKey string createAlg string createOutput string + createCWT bool ) var createCmd = NewCreateCmd() func NewCreateCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "create [flags] ", - Short: "Read the EAR claims-set from a JSON file, sign it and save the resulting JWT to jwt-file", - Long: `Read the EAR claims-set from a JSON file, sign it and save the resulting JWT to jwt-file - + Use: "create [flags] ", + Short: "Read the EAR claims-set from a JSON file, sign it and save the resulting JWT (or CWT) to outfile", + Long: `Read the EAR claims-set from a JSON file, sign it and save the resulting JWT (or CWT) to outfile Create an EAR from the default claims-set file "ear-claims.json". Sign it with the key in the default key file "skey.json", and save the result to "my-ear.jwt". +Note: for CWT, only ES256, ES384, and ES512 signature algorithms are supported. + arc create my-ear.jwt `, RunE: func(cmd *cobra.Command, args []string) error { @@ -65,12 +67,23 @@ the key in the default key file "skey.json", and save the result to "my-ear.jwt" return fmt.Errorf("parsing signing key from %q: %w", createSKey, err) } - if alg, err = jwa.KeyAlgorithmFrom(createAlg); err != nil { - return fmt.Errorf("parsing algorithm from %q: %w", createAlg, err) - } - - if arBytes, err = ar.SignJWT(alg, sigK); err != nil { - return fmt.Errorf("signing EAR: %w", err) + if createCWT { + signerAlg, signer, err := ear.SignerFromJWK(sigK) + if err != nil { + return fmt.Errorf("obtaining signer from key: %w", err) + } + + if arBytes, err = ar.SignCWT(signerAlg, signer, nil); err != nil { + return fmt.Errorf("signing EAR: %w", err) + } + } else { + if alg, err = jwa.KeyAlgorithmFrom(createAlg); err != nil { + return fmt.Errorf("parsing algorithm from %q: %w", createAlg, err) + } + + if arBytes, err = ar.SignJWT(alg, sigK); err != nil { + return fmt.Errorf("signing EAR: %w", err) + } } // save to createOutput @@ -93,7 +106,11 @@ the key in the default key file "skey.json", and save the result to "my-ear.jwt" ) cmd.Flags().StringVarP( - &createAlg, "alg", "a", "ES256", "signing algorithm ("+algList()+")", + &createAlg, "alg", "a", "ES256", "JWT signing algorithm ("+algList()+"); (this is not used for CWT)", + ) + + cmd.Flags().BoolVarP( + &createCWT, "cwt", "C", false, "Create a CWT (JWT is created by default)", ) return cmd diff --git a/arc/cmd/create_test.go b/arc/cmd/create_test.go index 9d83cd6..2616353 100644 --- a/arc/cmd/create_test.go +++ b/arc/cmd/create_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Contributors to the Veraison project. +// Copyright 2022-2026 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -183,3 +183,28 @@ func Test_CreateCmd_ok(t *testing.T) { _, err = fs.Stat("ear.jwt") assert.NoError(t, err) } + +func Test_CreateCmd_CWT_ok(t *testing.T) { + cmd := NewCreateCmd() + + files := []fileEntry{ + {"skey.json", testSKey}, + {"ear-claims.json", testMiniClaimsSet}, + } + makeFS(t, files) + + args := []string{ + "--skey=skey.json", + "--claims=ear-claims.json", + "--alg=ES256", + "--cwt", + "ear.cwt", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.NoError(t, err) + + _, err = fs.Stat("ear.cwt") + assert.NoError(t, err) +} diff --git a/arc/cmd/root_test.go b/arc/cmd/root_test.go new file mode 100644 index 0000000..beb9e7a --- /dev/null +++ b/arc/cmd/root_test.go @@ -0,0 +1,23 @@ +// Copyright 2026 Contributors to the Veraison project. + +// SPDX-License-Identifier: Apache-2.0 +package cmd + +import ( + "testing" +) + +func Test_RootCmd_config(t *testing.T) { + files := []fileEntry{ + {"test.yaml", []byte("")}, + } + makeFS(t, files) + + args := []string{ + "--config=test.yaml", + "-h", + } + rootCmd.SetArgs(args) + + Execute() +} diff --git a/arc/cmd/test_vars.go b/arc/cmd/test_vars.go index 3cd9832..8d48020 100644 --- a/arc/cmd/test_vars.go +++ b/arc/cmd/test_vars.go @@ -38,4 +38,34 @@ var ( testJWT_JWK = []byte(`eyJhbGciOiJFUzI1NiIsImp3ayI6eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6InVzV3hISzJQbWZuSEt3WFBTNTRtMGtUY0dKOTBVaWdsV2lHYWh0YWdudjgiLCJ5IjoiSUJPTC1DM0J0dFZpdmctbFNyZUFTanBrdHRjc3otMXJiN2J0S0x2OEVYNCJ9LCJ0eXAiOiJKV1QifQ.eyJlYXJfdmVyaWZpZXJfaWQiOnsiYnVpbGQiOiJycnRyYXAtdjEuMC4wIiwiZGV2ZWxvcGVyIjoiQWNtZSBJbmMuIn0sImVhdF9wcm9maWxlIjoidGFnOmlldGYub3JnLDIwMjY6cmF0cy9lYXIjMDQiLCJpYXQiOjE2NjYwOTEzNzMsInN1Ym1vZHMiOnsidGVzdCI6eyJlYXJfYXBwcmFpc2FsX3BvbGljeV9pZHMiOlsiaHR0cHM6Ly92ZXJhaXNvbi5leGFtcGxlL3BvbGljeS8xLzYwYTAwNjhkIl0sImVhcl9zdGF0dXMiOiJhZmZpcm1pbmciLCJlYXJfdHJ1c3R3b3J0aGluZXNzX3ZlY3RvciI6eyJjb25maWd1cmF0aW9uIjoyLCJleGVjdXRhYmxlcyI6MywiZmlsZS1zeXN0ZW0iOjIsImhhcmR3YXJlIjoyLCJpbnN0YW5jZS1pZGVudGl0eSI6MiwicnVudGltZS1vcGFxdWUiOjIsInNvdXJjZWQtZGF0YSI6Miwic3RvcmFnZS1vcGFxdWUiOjJ9fX19.N9337vwpNtgqW-FOMYMqc-9dKsMiaJuXir_xv3Gu2Gcxcv99HijJ5_O_p0VtepncNZSAczcS7hkYITZqqbAXRw`) // nolint: lll // Trustee realm expired token testRealmJWT = []byte(`eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiS1J1Z0YwMk9CUjdKME9wM1JhZHJ5dWtlUGhLQ1FuQVZXZ2FqUGJOV0tqRSIsInkiOiJ1dDJZT2NlUDN5cTVqdkNGNk5iZVZpQkNlQjUwUUVPUzNZWXJZTHlDeHJFIn19.eyJlYXRfcHJvZmlsZSI6InRhZzpnaXRodWIuY29tLDIwMjQ6Y29uZmlkZW50aWFsLWNvbnRhaW5lcnMvVHJ1c3RlZSIsImlhdCI6MTc1MjA1OTEzOSwiZWFyLnZlcmlmaWVyLWlkIjp7ImRldmVsb3BlciI6Imh0dHBzOi8vY29uZmlkZW50aWFsY29udGFpbmVycy5vcmciLCJidWlsZCI6ImF0dGVzdGF0aW9uLXNlcnZpY2UgMC4xLjAifSwic3VibW9kcyI6eyJjcHUwIjp7ImVhci5zdGF0dXMiOiJhZmZpcm1pbmciLCJlYXIudHJ1c3R3b3J0aGluZXNzLXZlY3RvciI6eyJjb25maWd1cmF0aW9uIjozLCJleGVjdXRhYmxlcyI6MywiaGFyZHdhcmUiOjJ9LCJlYXIuYXBwcmFpc2FsLXBvbGljeS1pZCI6ImRlZmF1bHQiLCJlYXIudmVyYWlzb24uYW5ub3RhdGVkLWV2aWRlbmNlIjp7ImNjYSI6eyJwbGF0Zm9ybSI6eyJjY2EtcGxhdGZvcm0taW1wbGVtZW50YXRpb24taWQiOiJmMFZNUmdJQkFRQUFBQUFBQUFBQUFBTUFQZ0FCQUFBQVVGZ0FBQUFBQUFBPSIsImNjYS1wbGF0Zm9ybS1pbnN0YW5jZS1pZCI6IkFRY0dCUVFEQWdFQUR3NE5EQXNLQ1FnWEZoVVVFeElSRUI4ZUhSd2JHaGtZIn0sInJlYWxtIjp7ImNjYS1yZWFsbS1jaGFsbGVuZ2UiOiJSUGs4dy9HZnRmaEg4R2U3aWFFNmFEcXdHbzNZM2RJMURnajl2NWY3dmRuR0Zld3prQWdEZEprZmJGNGZZM0l2QUFBQUFBQUFBQUFBQUFBQUFBQUFBQT09IiwiY2NhLXJlYWxtLWV4dGVuc2libGUtbWVhc3VyZW1lbnRzIjpbIkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE9IiwiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT0iLCJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBPSIsIkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE9Il0sImNjYS1yZWFsbS1oYXNoLWFsZ28taWQiOiJzaGEtMjU2IiwiY2NhLXJlYWxtLWluaXRpYWwtbWVhc3VyZW1lbnQiOiIzcS9WUU15T3Jyakc5b1pya3JUc0VuT21TWG1JaWc1MlY4amQycVBiRkRzPSIsImNjYS1yZWFsbS1wZXJzb25hbGl6YXRpb24tdmFsdWUiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT09In19LCJpbml0X2RhdGEiOiIwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImluaXRfZGF0YV9jbGFpbXMiOm51bGwsInJlcG9ydF9kYXRhIjoiNDRmOTNjYzNmMTlmYjVmODQ3ZjA2N2JiODlhMTNhNjgzYWIwMWE4ZGQ4ZGRkMjM1MGUwOGZkYmY5N2ZiYmRkOWM2MTVlYzMzOTAwODAzNzQ5OTFmNmM1ZTFmNjM3MjJmMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJydW50aW1lX2RhdGFfY2xhaW1zIjp7ImFkZGl0aW9uYWwtZXZpZGVuY2UiOiIiLCJub25jZSI6IkhoNDU5WWI4cDNzY2VtcHRrekM3T2Q1RUJpMTZoYk5VUFRaektSb0ZIL0U9IiwidGVlLXB1YmtleSI6eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlIjoiQVFBQiIsImt0eSI6IlJTQSIsIm4iOiJ5Tk51N2huV1pWbWNWR0xqNGptQWxxX2JYc0Q5WGdTdXR3MUhiVG05cFpNeV9tSTMyNWJhOFJHdGc2X1R4ZTFWMGRVX1Q2ejBGcHE0cDRCQUVfd3laRGxCcG4xVUZMOUNaR09rNkZtR2NWLVJYR3VMOXI0Y2hzNFp1akpKXzBWRUNCM0VkNGVBOFlCY1dyVkhtbXZNVjZoeC1qWWRyeGdOWGtHaE5qR0Jyc0UifX19fX0sImV4cCI6MTc1MjA1OTQzOX0.c_9ljXdd3B6aYns5Xu-h4QjYSWhe94mSna3rvahgR3UV5mwYHlPbO0fMrDjQ9-k-KwCjhWufS5-kuGG9jepo3g`) // nolint: lll + + testCWT = []byte{ + 0xd8, 0x3d, 0xd2, 0x84, 0x43, 0xa1, 0x01, 0x26, + 0xa0, 0x58, 0x85, 0xa4, 0x06, 0x1a, 0x63, 0x4e, + 0x89, 0x6d, 0x19, 0x01, 0x09, 0x78, 0x1d, 0x74, + 0x61, 0x67, 0x3a, 0x69, 0x65, 0x74, 0x66, 0x2e, + 0x6f, 0x72, 0x67, 0x2c, 0x32, 0x30, 0x32, 0x36, + 0x3a, 0x72, 0x61, 0x74, 0x73, 0x2f, 0x65, 0x61, + 0x72, 0x23, 0x30, 0x34, 0x19, 0x01, 0x0a, 0xa1, + 0x64, 0x74, 0x65, 0x73, 0x74, 0xa2, 0x19, 0x03, + 0xe8, 0x02, 0x19, 0x03, 0xeb, 0x81, 0x78, 0x2a, + 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, + 0x76, 0x65, 0x72, 0x61, 0x69, 0x73, 0x6f, 0x6e, + 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x2f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x2f, + 0x31, 0x2f, 0x36, 0x30, 0x61, 0x30, 0x30, 0x36, + 0x38, 0x64, 0x19, 0x03, 0xec, 0xa2, 0x00, 0x69, + 0x41, 0x63, 0x6d, 0x65, 0x20, 0x49, 0x6e, 0x63, + 0x2e, 0x01, 0x6d, 0x72, 0x72, 0x74, 0x72, 0x61, + 0x70, 0x2d, 0x76, 0x31, 0x2e, 0x30, 0x2e, 0x30, + 0x58, 0x40, 0x86, 0x65, 0x9a, 0xe4, 0x50, 0x4f, + 0xf9, 0xc6, 0x2e, 0xfd, 0x54, 0x31, 0x44, 0x5b, + 0x65, 0x1e, 0x71, 0x21, 0x08, 0xc5, 0x15, 0x76, + 0xf4, 0xef, 0x06, 0xa5, 0x1b, 0xf3, 0xfe, 0x17, + 0x69, 0x30, 0xe2, 0xfb, 0x26, 0x17, 0x50, 0xe6, + 0x3b, 0xe1, 0x39, 0x9a, 0x55, 0x87, 0x51, 0x46, + 0x2f, 0x98, 0x8d, 0xc0, 0x02, 0x47, 0xe4, 0x34, + 0xd0, 0xcd, 0xca, 0x95, 0x23, 0x4f, 0xcf, 0x99, + 0xf2, 0x95, + } ) diff --git a/arc/cmd/verify.go b/arc/cmd/verify.go index 1ecf7aa..8dd20ae 100644 --- a/arc/cmd/verify.go +++ b/arc/cmd/verify.go @@ -3,6 +3,8 @@ package cmd import ( + "bytes" + "crypto/ecdsa" "errors" "fmt" @@ -12,6 +14,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/veraison/ear" + "github.com/veraison/go-cose" ) // The default value for pkey parameter @@ -29,16 +32,18 @@ var verifyCmd = NewVerifyCmd() func NewVerifyCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "verify [flags] ", - Short: "Read a signed EAR from jwt-file, verify it and pretty-print its content", - Long: `Read a signed EAR from jwt-file, verify it and pretty-print its content + Use: "verify [flags] ", + Short: "Read a signed EAR (JWT or CWT) from ear-file, verify it and pretty-print its content", + Long: `Read a signed EAR (JWT or CWT) from ear-file, verify it and pretty-print its content -Verify the signed EAR in "my-ear.jwt" using the public key from a key file. -If the default key file name "pkey.json" is used and file is missing then -use the public key from JWT header. +Verify the signed EAR in (must be a JWT or CWT) using the public key +from a key file. If the default key file name "pkey.json" is used and file is +missing then use the public key from JWT header or raise an error for CWT. If cryptographic verification is successful, print the embedded EAR claims-set and present a report of the trustworthiness vector. +Note: for CWT, only ES256, ES384, and ES512 signature algorithms are supported. + arc verify my-ear.jwt `, RunE: func(cmd *cobra.Command, args []string) error { @@ -61,11 +66,22 @@ embedded EAR claims-set and present a report of the trustworthiness vector. return fmt.Errorf("loading signed EAR from %q: %w", verifyInput, err) } + if len(arBytes) < 2 { + return errors.New("truncated input") + } + + isCWT := arBytes[0] == 0xd2 || bytes.Equal([]byte{0xd8, 0x3d}, arBytes[:2]) + // read the verification key from verifyPKey if pKey, err := afero.ReadFile(fs, verifyPKey); err != nil { if verifyPKey != defaultPKey { return fmt.Errorf("loading verification key from %q: %w", verifyPKey, err) } + + if isCWT { + return fmt.Errorf("please provide %s or specify an alternative with --pkey", defaultPKey) + } + fmt.Println("Using JWK key from JWT header") msg, err := jws.Parse(arBytes) if err != nil { @@ -90,8 +106,33 @@ embedded EAR claims-set and present a report of the trustworthiness vector. } } - if err = ar.FromVerifiedJWT(arBytes, vfyAlg, vfyK); err != nil { - return fmt.Errorf("verifying signed EAR from %q using %q key: %w", verifyInput, verifyPKey, err) + if isCWT { + var coseAlg cose.Algorithm + switch vfyAlg { + case jwa.ES256(): + coseAlg = cose.AlgorithmES256 + case jwa.ES384(): + coseAlg = cose.AlgorithmES384 + case jwa.ES512(): + coseAlg = cose.AlgorithmES512 + default: + return fmt.Errorf("unsupported CWT algorithm: %v", vfyAlg) + } + + key, err := jwk.Export[*ecdsa.PublicKey](vfyK) + if err != nil { + return errors.New( + "not an ECDSA public key (CWT verification only supports ECDSA)", + ) + } + + if err = ar.FromVerifiedCWT(arBytes, coseAlg, key); err != nil { + return fmt.Errorf("verifying signed EAR from %q using %q key: %w", verifyInput, verifyPKey, err) + } + } else { + if err = ar.FromVerifiedJWT(arBytes, vfyAlg, vfyK); err != nil { + return fmt.Errorf("verifying signed EAR from %q using %q key: %w", verifyInput, verifyPKey, err) + } } fmt.Printf(">> %q signature successfully verified using %q key\n", verifyInput, verifyPKey) diff --git a/arc/cmd/verify_test.go b/arc/cmd/verify_test.go index c560a2f..82b15c1 100644 --- a/arc/cmd/verify_test.go +++ b/arc/cmd/verify_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Contributors to the Veraison project. +// Copyright 2022-2026 Contributors to the Veraison project. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -107,7 +107,7 @@ func Test_VerifyCmd_input_file_bad_format(t *testing.T) { } cmd.SetArgs(args) - expectedErr := `verifying signed EAR from "ear.jwt" using "pkey.json" key: jwt: jwt.Parse: failed to parse token` + expectedErr := `truncated input` err := cmd.Execute() assert.ErrorContains(t, err, expectedErr) @@ -246,3 +246,64 @@ func Test_VerifyCmd_ok(t *testing.T) { err := cmd.Execute() assert.NoError(t, err) } + +func Test_VerifyCmd_CWT_ok(t *testing.T) { + cmd := NewVerifyCmd() + + files := []fileEntry{ + {"pkey.json", testPKey}, + {"ear.cwt", testCWT}, + } + makeFS(t, files) + + args := []string{ + "--pkey=pkey.json", + "--alg=ES256", + "ear.cwt", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.NoError(t, err) +} + +func Test_VerifyCmd_CWT_no_key(t *testing.T) { + cmd := NewVerifyCmd() + + files := []fileEntry{ + {"ear.cwt", testCWT}, + } + makeFS(t, files) + + args := []string{ + "--alg=ES256", + "ear.cwt", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.ErrorContains(t, err, "please provide pkey.json or specify an alternative with --pkey") +} + +func Test_VerifyCmd_CWT_bad_token(t *testing.T) { + cmd := NewVerifyCmd() + + files := []fileEntry{ + {"pkey.json", testPKey}, + {"ear.cwt", []byte{ + 0xd2, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, + }}, + } + makeFS(t, files) + + args := []string{ + "--pkey=pkey.json", + "--alg=ES256", + "ear.cwt", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.ErrorContains(t, err, `verifying signed EAR from "ear.cwt" using "pkey.json" key: cbor: invalid additional information 30 for type tag`) +} diff --git a/attestation_result.go b/attestation_result.go index 70bc8ef..e3199e2 100644 --- a/attestation_result.go +++ b/attestation_result.go @@ -4,6 +4,7 @@ package ear import ( + "crypto" "encoding/json" "errors" "fmt" @@ -15,6 +16,7 @@ import ( "github.com/lestrrat-go/jwx/v4/jwt" "github.com/veraison/cmw" "github.com/veraison/eat" + "github.com/veraison/go-cose" ) var ( @@ -35,20 +37,28 @@ type JWTKeyEntry struct { Key any } +// CWTKeyEntry contains a key used to verify CWT signatures. +type CWTKeyEntry struct { + // Alg is the algorithm the Key is intended to be used with. + Alg cose.Algorithm + // Key is the key used to verify CWT signatures. + Key crypto.PublicKey +} + // AttestationResult represents the result of one or more evidence Appraisals -// by the verifier. It is serialized to JSON and signed by the verifier using -// JWT. +// by the verifier. It may be serialized and signed as either JSON/JWT or +// CBOR/CWT. type AttestationResult struct { - Profile *eat.Profile `json:"eat_profile"` - Status *TrustTier `json:"ear_status,omitempty"` - IssuedAt int64 `json:"iat"` - Expiry *int64 `json:"exp,omitempty"` - VerifierID *VerifierIdentity `json:"ear_verifier_id"` - RawEvidence *cmw.CMW `json:"ear_raw_evidence,omitempty"` - Submods Submods `json:"submods"` - Nonce *eat.Nonce `json:"eat_nonce,omitempty"` - DeviceTopology DeviceTopology `json:"ear_device_topology,omitempty"` - Extensions *Extensions `json:"-"` + Profile *eat.Profile `cbor:"265,keyasint" json:"eat_profile"` + Status *TrustTier `cbor:"1000,keyasint,omitempty" json:"ear_status,omitempty"` + IssuedAt int64 `cbor:"6,keyasint" json:"iat"` + Expiry *int64 `cbor:"4,keyasint,omitempty" json:"exp,omitempty"` + VerifierID *VerifierIdentity `cbor:"1004,keyasint" json:"ear_verifier_id"` + RawEvidence *cmw.CMW `cbor:"1002,keyasint,omitempty" json:"ear_raw_evidence,omitempty"` + Submods Submods `cbor:"266,keyasint" json:"submods"` + Nonce *eat.Nonce `cbor:"10,keyasint,omitempty" json:"eat_nonce,omitempty"` + DeviceTopology DeviceTopology `cbor:"1007,keyasint,omitempty" json:"ear_device_topology,omitempty"` + Extensions *Extensions `cbor:"-" json:"-"` } // NewAttestationResult returns a pointer to a new AttestationResult. @@ -106,6 +116,32 @@ func AttestationResultFromUnverifiedJWT(data []byte) (*AttestationResult, error) return &ret, nil } +// AttestationResultFromVerifiedCWT returns a pointer to an AttestationResult +// extracted from the provided CWT after verifying its signature using the +// provided key and algorithm. +func AttestationResultFromVerifiedCWT( + data []byte, + keyEntry *CWTKeyEntry, +) (*AttestationResult, error) { + var ret AttestationResult + if err := ret.FromVerifiedCWT(data, keyEntry.Alg, keyEntry.Key); err != nil { + return nil, err + } + + return &ret, nil +} + +// AttestationResultFromUnverifiedCWT returns a pointer to an AttestationResult +// extracted from the provided CWT without verifying its signature. +func AttestationResultFromUnverifiedCWT(data []byte) (*AttestationResult, error) { + var ret AttestationResult + if err := ret.FromUnverifiedCWT(data); err != nil { + return nil, err + } + + return &ret, nil +} + // AttestationResultFromNestedToken returns a pointer to an AttestationResult // extracted from the provided NestedToken. An error is returned if the // NestedToken does not contain a valid AttestationResult. No signature @@ -115,8 +151,7 @@ func AttestationResultFromNestedToken(token *eat.NestedToken) (*AttestationResul case eat.NestedTokenBundle, eat.NestedTokenDigest, eat.NestedTokenInvalid: return nil, fmt.Errorf("cannot create AttestationResult from %s", token.Type.String()) case eat.NestedTokenCBOR: - // TODO(setrofim): this requires CBOR/CWT support - return nil, fmt.Errorf("CBOR encoding not supported") + return AttestationResultFromUnverifiedCWT(token.Data) case eat.NestedTokenJWT: var encodedToken string if err := json.Unmarshal(token.Data, &encodedToken); err != nil { @@ -156,7 +191,7 @@ func AttestationResultFromAny(input any) (*AttestationResult, error) { for key, value := range claims { switch key { - case eat.IntOrStringFromString("eat_profile"): + case eat.IntOrStringFromString("eat_profile"), eat.IntOrStringFromInt(265): profileText, ok := value.(string) if !ok { return nil, fmt.Errorf("eat_profile: expected a string, got %T", value) @@ -168,56 +203,56 @@ func AttestationResultFromAny(input any) (*AttestationResult, error) { } result.Profile = profile - case eat.IntOrStringFromString("ear_status"): + case eat.IntOrStringFromString("ear_status"), eat.IntOrStringFromInt(1000): tier, err := TrustTierFromAny(value) if err != nil { return nil, fmt.Errorf("ear_status: %w", err) } result.Status = &tier - case eat.IntOrStringFromString("iat"): + case eat.IntOrStringFromString("iat"), eat.IntOrStringFromInt(6): iat, err := Int64FromAny(value) if err != nil { return nil, fmt.Errorf("iat: %w", err) } result.IssuedAt = iat - case eat.IntOrStringFromString("exp"): + case eat.IntOrStringFromString("exp"), eat.IntOrStringFromInt(4): exp, err := Int64FromAny(value) if err != nil { return nil, fmt.Errorf("exp: %w", err) } result.Expiry = &exp - case eat.IntOrStringFromString("ear_verifier_id"): + case eat.IntOrStringFromString("ear_verifier_id"), eat.IntOrStringFromInt(1004): verifierID, err := VerifierIdentityFromAny(value) if err != nil { return nil, fmt.Errorf("ear_verifier_id: %w", err) } result.VerifierID = verifierID - case eat.IntOrStringFromString("ear_raw_evidence"): + case eat.IntOrStringFromString("ear_raw_evidence"), eat.IntOrStringFromInt(1002): evidence, err := CMWFromAny(value) if err != nil { return nil, fmt.Errorf("ear_raw_evidence: %w", err) } result.RawEvidence = evidence - case eat.IntOrStringFromString("submods"): + case eat.IntOrStringFromString("submods"), eat.IntOrStringFromInt(266): submods, err := SubmodsFromAny(value) if err != nil { return nil, fmt.Errorf("submods: %w", err) } result.Submods = submods - case eat.IntOrStringFromString("eat_nonce"): + case eat.IntOrStringFromString("eat_nonce"), eat.IntOrStringFromInt(10): nonce, err := eat.NonceFromAny(value) if err != nil { return nil, fmt.Errorf("eat_nonce: %w", err) } result.Nonce = nonce - case eat.IntOrStringFromString("ear_device_topology"): + case eat.IntOrStringFromString("ear_device_topology"), eat.IntOrStringFromInt(1007): topology, err := DeviceTopologyFromAny(value) if err != nil { return nil, fmt.Errorf("ear_device_topology: %w", err) @@ -255,8 +290,11 @@ func (o *AttestationResult) Map() map[eat.IntOrString]any { // trustworthy if had been manually set that way). If submods contain nested // tokens, an attempt is made to verify its signature using provided keys. If // no keys were provided, or if verification fails using all provided keys, -// status is set to TrustTierIncomplete. -func (o *AttestationResult) UpdateStatusFromSubmods(keys ...*JWTKeyEntry) { +// status is set to TrustTierIncomplete. Keys must be specified as +// JWTKeyEntry's for JWTs and CWTKeyEntry's for CWTs (pointers to key entries +// are also acceptable in both cases). Any unrecognised key entries will be +// ignored. +func (o *AttestationResult) UpdateStatusFromSubmods(keys ...any) { status := TrustTierNone if o.Status != nil { status = *o.Status @@ -279,34 +317,79 @@ func (o *AttestationResult) UpdateStatusFromSubmods(keys ...*JWTKeyEntry) { status = *t.Status } case *eat.NestedToken: - if t.Type != eat.NestedTokenJWT { - status = TrustTierIncomplete - continue - } + switch t.Type { + case eat.NestedTokenJWT: + var jwtText string + if err := json.Unmarshal(t.Data, &jwtText); err != nil { + status = TrustTierIncomplete + continue + } - var jwtText string - if err := json.Unmarshal(t.Data, &jwtText); err != nil { - status = TrustTierIncomplete - continue - } + keyEntries := make([]*JWTKeyEntry, 0, len(keys)) + for _, key := range keys { + switch kt := key.(type) { + case JWTKeyEntry: + keyEntries = append(keyEntries, &kt) + case *JWTKeyEntry: + keyEntries = append(keyEntries, kt) + } + } - verified := false - for _, keyEntry := range keys { - result, err := AttestationResultFromVerifiedJWT([]byte(jwtText), keyEntry) - if err != nil { - continue + verified := false + for _, keyEntry := range keyEntries { + result, err := AttestationResultFromVerifiedJWT([]byte(jwtText), keyEntry) + if err != nil { + continue + } + + verified = true + + result.UpdateStatusFromSubmods(keys...) + if result.Status.WorseThan(status) { + status = *result.Status + } + + break } - verified = true + if !verified { + status = TrustTierIncomplete + } + case eat.NestedTokenCBOR: + keyEntries := make([]*CWTKeyEntry, 0, len(keys)) + for _, key := range keys { + switch kt := key.(type) { + case CWTKeyEntry: + keyEntries = append(keyEntries, &kt) + case *CWTKeyEntry: + keyEntries = append(keyEntries, kt) + } + } + + verified := false + for _, keyEntry := range keyEntries { + result, err := AttestationResultFromVerifiedCWT(t.Data, keyEntry) + if err != nil { + continue + } - result.UpdateStatusFromSubmods(keys...) - if result.Status.WorseThan(status) { - status = *result.Status + verified = true + + result.UpdateStatusFromSubmods(keys...) + if result.Status.WorseThan(status) { + status = *result.Status + } + + break } - } - if !verified { + if !verified { + status = TrustTierIncomplete + } + default: + // unsupported nested token type status = TrustTierIncomplete + continue } default: // cases are exhaustive @@ -424,6 +507,53 @@ func (o *AttestationResult) FromUnverifiedJWT(data []byte) error { return o.fromJWTToken(token) } +// SignCWT validates the AttestationResult object, encodes it to CBOR and wraps it +// in a CWT using the supplied private key for signing. The key must be +// compatible with the requested signing algorithm. Optionally, COSE headers +// may also be provided, in which case it is the caller's responsibility to +// ensure that the correct algorithm is set therein. On success, the complete +// CWT token is returned. +func (o *AttestationResult) SignCWT( + alg cose.Algorithm, + key crypto.Signer, + headers *cose.Headers, +) ([]byte, error) { + if err := o.Validate(); err != nil { + return nil, err + } + + payload, err := o.MarshalCBOR() + if err != nil { + return nil, err + } + + return signCWT(payload, alg, key, headers) +} + +// FromVerifiedCWT cryptographically verifies the CWT data using the supplied key and +// algorithm. The payload is then parsed and validated. On success, the target +// AttestationResult object is populated with the decoded claims (possibly +// including the Trustworthiness vector). +func (o *AttestationResult) FromVerifiedCWT(data []byte, alg cose.Algorithm, key crypto.PublicKey) error { + payload, err := verifyAndUnwrapCWT(data, alg, key) + if err != nil { + return err + } + + return o.UnmarshalCBOR(payload) +} + +// FromUnverifiedCWT populates the AttestationResult using claims parsed from +// the provided CWT. No signature verification is performed. +func (o *AttestationResult) FromUnverifiedCWT(data []byte) error { + payload, err := unwrapCWT(data) + if err != nil { + return err + } + + return o.UnmarshalCBOR(payload) +} + // MarshalJSON validates and serializes to JSON an AttestationResult object func (o *AttestationResult) MarshalJSON() ([]byte, error) { if err := o.Validate(); err != nil { @@ -455,11 +585,11 @@ func (o *AttestationResult) MarshalJSONIndent(prefix, indent string) ([]byte, er return json.MarshalIndent(claims, prefix, indent) } -type resultUnmarshalHelper AttestationResult - // UnmarshalJSON de-serializes an AttestationResult object from its JSON // representation and validates it. func (o *AttestationResult) UnmarshalJSON(data []byte) error { // nolint:dupl + type resultUnmarshalHelper AttestationResult + if err := json.Unmarshal(data, (*resultUnmarshalHelper)(o)); err != nil { return err } @@ -486,6 +616,48 @@ func (o *AttestationResult) UnmarshalJSON(data []byte) error { // nolint:dupl return nil } +func (o *AttestationResult) MarshalCBOR() ([]byte, error) { + if err := o.Validate(); err != nil { + return nil, err + } + + claims, err := structWithExtensionsToMap(o, "cbor") + if err != nil { + return nil, err + } + + return em.Marshal(claims) +} + +func (o *AttestationResult) UnmarshalCBOR(data []byte) error { // nolint:dupl + type resultUnmarshalHelper AttestationResult + + if err := dm.Unmarshal(data, (*resultUnmarshalHelper)(o)); err != nil { + return err + } + + var claims map[eat.IntOrString]any + if err := dm.Unmarshal(data, &claims); err != nil { + return err + } + + var extensions Extensions + knownLabels := getTagLabels(o, "cbor") + for label, value := range claims { + if _, isKnown := knownLabels[label]; !isKnown { + extensions.Set(label, value) + } + } + + o.Extensions = &extensions + + if err := o.Validate(); err != nil { + return err + } + + return nil +} + func (o *AttestationResult) fromJWTToken(token jwt.Token) error { claims := maps.Collect(token.Claims()) if iat, ok := token.IssuedAt(); ok { diff --git a/attestation_result_test.go b/attestation_result_test.go index c4ecb3c..ce18e95 100644 --- a/attestation_result_test.go +++ b/attestation_result_test.go @@ -4,6 +4,7 @@ package ear import ( + "crypto/ecdsa" "fmt" "testing" @@ -13,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/veraison/cmw" "github.com/veraison/eat" + "github.com/veraison/go-cose" ) var ( @@ -31,6 +33,69 @@ var ( "d": "V8kgd2ZBRuh2dgyVINBUqpPDr7BOMGcF22CQMIUHtNM" }` + testECDSAPublicKeyOther = `{ +"kty": "EC", +"crv": "P-256", +"x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", +"y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", +"kid": "1" +}` + testCWT = []byte{ + 0xd8, 0x3d, // tag(61) [CWT] + 0xd2, // . tag(18) [cose-sign1] + 0x84, // . . array(4) [COSE_Sign1] + 0x43, // . . . [0]bstr(3) [protected-header] + 0xa1, // . . . . map(1) [header_map] + 0x01, // . . . . . key: 1 [alg] + 0x26, // . . . . . value: -7 [ES256] + 0xa0, // . . . [1]map(0) [unprotected-header] + 0x58, 0x85, // . . . [2]bstr(133) [payload] + 0xa4, // . . . . map(4) [AttestationResult] + 0x06, // . . . . . key: 6 [iat] + 0x1a, 0x63, 0x4e, 0x89, 0x6d, // . . . . . value: 1666091373 + 0x19, 0x01, 0x09, // // . . . . . key: 265 [eat_profile] + 0x78, 0x1d, // // . . . . . value: tstr(29) + 0x74, 0x61, 0x67, 0x3a, 0x69, 0x65, 0x74, 0x66, // . . . . . . "tag:ietf" + 0x2e, 0x6f, 0x72, 0x67, 0x2c, 0x32, 0x30, 0x32, // . . . . . . ".org,202" + 0x36, 0x3a, 0x72, 0x61, 0x74, 0x73, 0x2f, 0x65, // . . . . . . "6:rats/e" + 0x61, 0x72, 0x23, 0x30, 0x34, // . . . . . . "ar#04" + 0x19, 0x01, 0x0a, // . . . . . key: 266 [submods] + 0xa1, // . . . . . value: map(1) [Submods] + 0x64, // . . . . . . key: tstr(4) + 0x74, 0x65, 0x73, 0x74, // . . . . . . . "test" + 0xa2, // . . . . . . value: map(2) [Appraisal] + 0x19, 0x03, 0xe8, // . . . . . . . key: 1000 [ear_status] + 0x02, // . . . . . . . value: 2 [affirming] + 0x19, 0x03, 0xeb, // . . . . . . . key: 1003 [ear_appraisal_policy] + 0x81, // . . . . . . . value: array(1) + 0x78, 0x2a, // . . . . . . . . [0]tstr(42) + 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, // . . . . . . . . . "https://" + 0x76, 0x65, 0x72, 0x61, 0x69, 0x73, 0x6f, 0x6e, // . . . . . . . . . "veraison" + 0x2e, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, // . . . . . . . . . ".example" + 0x2f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x2f, // . . . . . . . . . "/policy/" + 0x31, 0x2f, 0x36, 0x30, 0x61, 0x30, 0x30, 0x36, // . . . . . . . . . "1/60a006" + 0x38, 0x64, // . . . . . . . . . "8d" + 0x19, 0x03, 0xec, // . . . . . key: 1004 [ear_verifier_id] + 0xa2, // . . . . . value: map(2) [VerifierIdentity] + 0x00, // . . . . . . key: 0 [developer] + 0x69, // . . . . . . value: tstr(9) + 0x41, 0x63, 0x6d, 0x65, 0x20, 0x49, 0x6e, 0x63, // . . . . . . . "Acme Inc" + 0x2e, // . . . . . . . "." + 0x01, // . . . . . . key: 1 [build] + 0x6d, // . . . . . . value: tstr(13) + 0x72, 0x72, 0x74, 0x72, 0x61, 0x70, 0x2d, 0x76, // . . . . . . . "rrtrap-v" + 0x31, 0x2e, 0x30, 0x2e, 0x30, // // . . . . . . . "1.0.0" + 0x58, 0x40, // . . . [3]bstr(64) [signature] + 0x86, 0x65, 0x9a, 0xe4, 0x50, 0x4f, 0xf9, 0xc6, + 0x2e, 0xfd, 0x54, 0x31, 0x44, 0x5b, 0x65, 0x1e, + 0x71, 0x21, 0x08, 0xc5, 0x15, 0x76, 0xf4, 0xef, + 0x06, 0xa5, 0x1b, 0xf3, 0xfe, 0x17, 0x69, 0x30, + 0xe2, 0xfb, 0x26, 0x17, 0x50, 0xe6, 0x3b, 0xe1, + 0x39, 0x9a, 0x55, 0x87, 0x51, 0x46, 0x2f, 0x98, + 0x8d, 0xc0, 0x02, 0x47, 0xe4, 0x34, 0xd0, 0xcd, + 0xca, 0x95, 0x23, 0x4f, 0xcf, 0x99, 0xf2, 0x95, + } + testVidBuild = "rrtrap-v1.0.0" testVidDeveloper = "Acme Inc." @@ -70,6 +135,7 @@ func TestAttestationResult_round_trip(t *testing.T) { title string result AttestationResult expectedJSON string + expectedCBOR []byte }{ { title: "ok minimal", @@ -87,6 +153,35 @@ func TestAttestationResult_round_trip(t *testing.T) { Extensions: NewExtensions(), }, expectedJSON: `{"ear_verifier_id":{"build":"test-build","developer":"test-developer"},"eat_profile":"tag:ietf.org,2026:rats/ear#04","iat":1666091373,"submods":{"test-appraisal":{"ear_status":"affirming"}}}`, + expectedCBOR: []byte{ + 0xa4, // map(4) + 0x06, // . key: 6 [iat] + 0x1a, 0x63, 0x4e, 0x89, 0x6d, // . value: 1666091373 + 0x19, 0x01, 0x09, // // . key: 265 [eat_profile] + 0x78, 0x1d, // // . value: tstr(29) + 0x74, 0x61, 0x67, 0x3a, 0x69, 0x65, 0x74, 0x66, // . . "tag:ietf" + 0x2e, 0x6f, 0x72, 0x67, 0x2c, 0x32, 0x30, 0x32, // . . ".org,202" + 0x36, 0x3a, 0x72, 0x61, 0x74, 0x73, 0x2f, 0x65, // . . "6:rats/e" + 0x61, 0x72, 0x23, 0x30, 0x34, // . . "ar#04" + 0x19, 0x01, 0x0a, // . key: 266 [submods] + 0xa1, // . value: map(1) [Submods] + 0x6e, // . . key: tstr(15) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x61, 0x70, 0x70, // . . . "test-app" + 0x72, 0x61, 0x69, 0x73, 0x61, 0x6c, // . . . "raisal" + 0xa1, // . . value: map(1) [Appraisal] + 0x19, 0x03, 0xe8, // . . . key: 1000 [ear_status] + 0x02, // . . . value: 2 [affirming] + 0x19, 0x03, 0xec, // . . key: 1004 [ear_verifier_id] + 0xa2, // . . value: map(2) [VerifierIdentity] + 0x00, // . . key: 0 [developer] + 0x6e, // . . value: tstr(14) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x64, 0x65, 0x76, // . . . "test-dev" + 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, // . . . "eloper" + 0x01, // . . key: 1 [build] + 0x6a, // . . value: tstr(10) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x62, 0x75, 0x69, // . . . "test-bui" + 0x6c, 0x64, // . . . "ld" + }, }, { title: "ok complete", @@ -118,6 +213,53 @@ func TestAttestationResult_round_trip(t *testing.T) { Extensions: NewExtensions(), }, expectedJSON: `{"ear_device_topology":{"test-appraisal":["foo","bar"]},"ear_raw_evidence":["application/x-test","3q2-7w"],"ear_status":"affirming","ear_verifier_id":{"build":"test-build","developer":"test-developer"},"eat_nonce":"3q2-796tvu8","eat_profile":"tag:ietf.org,2026:rats/ear#04","exp":9999999999,"iat":1666091373,"submods":{"test-appraisal":{"ear_status":"affirming"}}}`, + expectedCBOR: []byte{ + 0xa8, // map(4) + 0x04, // . key: 4 [exp] + 0x1b, // . value: uint(8) + 0x00, 0x00, 0x00, 0x02, 0x54, 0x0b, 0xe3, 0xff, // . . 9999999999 + 0x06, // . key: 6 [iat] + 0x1a, 0x63, 0x4e, 0x89, 0x6d, // . value: 1666091373 + 0x0a, // . key: 10 [nonce] + 0x48, // . value: bstr(8) + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, + 0x19, 0x01, 0x09, // // . key: 265 [eat_profile] + 0x78, 0x1d, // // . value: tstr(29) + 0x74, 0x61, 0x67, 0x3a, 0x69, 0x65, 0x74, 0x66, // . . "tag:ietf" + 0x2e, 0x6f, 0x72, 0x67, 0x2c, 0x32, 0x30, 0x32, // . . ".org,202" + 0x36, 0x3a, 0x72, 0x61, 0x74, 0x73, 0x2f, 0x65, // . . "6:rats/e" + 0x61, 0x72, 0x23, 0x30, 0x34, // . . "ar#04" + 0x19, 0x01, 0x0a, // . key: 266 [submods] + 0xa1, // . value: map(1) [Submods] + 0x6e, // . . key: tstr(15) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x61, 0x70, 0x70, // . . . "test-app" + 0x72, 0x61, 0x69, 0x73, 0x61, 0x6c, // . . . "raisal" + 0xa1, // . . value: map(1) [Appraisal] + 0x19, 0x03, 0xe8, // . . . key: 1000 [ear_status] + 0x02, // . . . value: 2 [affirming] + 0x19, 0x03, 0xe8, // . . key: 1000 [ear_status] + 0x02, // . . value: 2 [affirming] + 0x19, 0x03, 0xec, // . . key: 1004 [ear_verifier_id] + 0xa2, // . . value: map(2) [VerifierIdentity] + 0x00, // . . key: 0 [developer] + 0x6e, // . . value: tstr(14) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x64, 0x65, 0x76, // . . . "test-dev" + 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, // . . . "eloper" + 0x01, // . . key: 1 [build] + 0x6a, // . . value: tstr(10) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x62, 0x75, 0x69, // . . . "test-bui" + 0x6c, 0x64, // . . . "ld" + 0x19, 0x03, 0xef, // . . key: 1007 [ear_device_topology] + 0xa1, // . . value: map(1) [DeviceTopology] + 0x6e, // . . . key: tstr(14) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x61, 0x70, 0x70, // . . . . "test-app" + 0x72, 0x61, 0x69, 0x73, 0x61, 0x6c, // . . . . "raisal" + 0x82, // . . . value: array(2) + 0x63, // . . . . [0]tstr(3) + 0x66, 0x6f, 0x6f, // . . . . . "foo" + 0x63, // . . . . [1]tstr(3) + 0x62, 0x61, 0x72, // . . . . . "bar" + }, }, } @@ -152,6 +294,28 @@ func TestAttestationResult_round_trip(t *testing.T) { } assert.EqualValues(t, tc.result, decoded) + + encodedCBOR, err := tc.result.MarshalCBOR() + assert.NoError(t, err) + assert.Equal(t, tc.expectedCBOR, encodedCBOR) + + err = decoded.UnmarshalCBOR(encodedCBOR) + assert.NoError(t, err) + + if tc.result.RawEvidence != nil { + expectedType, _ := tc.result.RawEvidence.GetMonadType() + actualType, _ := decoded.RawEvidence.GetMonadType() + assert.Equal(t, expectedType, actualType) + + expectedValue, _ := tc.result.RawEvidence.GetMonadValue() + actualValue, _ := decoded.RawEvidence.GetMonadValue() + assert.Equal(t, expectedValue, actualValue) + + tc.result.RawEvidence = nil + decoded.RawEvidence = nil + } + + assert.EqualValues(t, tc.result, decoded) }) } } @@ -517,7 +681,20 @@ func TestUpdateStatusFromTrustVector(t *testing.T) { require.NoError(t, err) ar.Status = nil - ar.UpdateStatusFromSubmods(&JWTKeyEntry{jwa.ES256(), k}) + ar.UpdateStatusFromSubmods(JWTKeyEntry{jwa.ES256(), k}) + assert.Equal(t, TrustTierAffirming, *ar.Status) + + ar = NewAttestationResult("test", "test") + ar.Submods.AddCWT("test", testCWT) + + ar.UpdateStatusFromSubmods() + assert.Equal(t, TrustTierIncomplete, *ar.Status) + + pubKey, err := jwk.Export[*ecdsa.PublicKey](k) + require.NoError(t, err) + + ar.Status = nil + ar.UpdateStatusFromSubmods(CWTKeyEntry{cose.AlgorithmES256, pubKey}) assert.Equal(t, TrustTierAffirming, *ar.Status) } @@ -583,6 +760,26 @@ func TestAttestationResultFromNestedToken(t *testing.T) { Extensions: NewExtensions(), }, }, + { + title: "ok CBOR", + token: eat.NestedToken{ + Type: eat.NestedTokenCBOR, + Data: testCWT, + }, + expected: AttestationResult{ + IssuedAt: testIAT, + VerifierID: &testVerifierID, + Profile: testProfile, + Submods: NewSubmods().AddAppraisal("test", &Appraisal{ + Status: testStatus, + AppraisalPolicyIDs: []string{ + "https://veraison.example/policy/1/60a0068d", + }, + Extensions: NewExtensions(), + }), + Extensions: NewExtensions(), + }, + }, { title: "err bad JWT", token: eat.NestedToken{ @@ -599,14 +796,6 @@ func TestAttestationResultFromNestedToken(t *testing.T) { }, err: "cannot create AttestationResult from DIGEST", }, - { - title: "err CBOR", - token: eat.NestedToken{ - Type: eat.NestedTokenCBOR, - Data: []byte{0xde, 0xad, 0xbe, 0xef}, - }, - err: "CBOR encoding not supported", - }, } for _, tc := range testCases { @@ -931,3 +1120,73 @@ func TestAttestationResult_bad_topology(t *testing.T) { err := ar.Validate() assert.ErrorContains(t, err, "ear_device_topology entries not in submods: nihil") } + +func TestAttestationResult_CWT(t *testing.T) { + key, err := jwk.ParseKey([]byte(testECDSAPrivateKey)) + require.NoError(t, err) + + alg, signer, err := SignerFromJWK(key) + require.NoError(t, err) + + jwkPubKey, err := key.PublicKey() + require.NoError(t, err) + + pubKey, err := jwk.Export[*ecdsa.PublicKey](jwkPubKey) + require.NoError(t, err) + + result := AttestationResult{ + Profile: EatProfile, + IssuedAt: 1666091373, + VerifierID: NewVerifierIdentity("test-build", "test-developer"), + Submods: NewSubmods().AddAppraisal( + "test-appraisal", + &Appraisal{ + Status: TrustTierAffirming, + Extensions: NewExtensions(), + }, + ), + Extensions: NewExtensions(), + } + + signedCWT, err := result.SignCWT(alg, signer, nil) + assert.NoError(t, err) + + decoded, err := AttestationResultFromVerifiedCWT(signedCWT, &CWTKeyEntry{alg, pubKey}) + assert.NoError(t, err) + assert.Equal(t, &result, decoded) + + decoded, err = AttestationResultFromUnverifiedCWT(signedCWT) + assert.NoError(t, err) + assert.Equal(t, &result, decoded) + + _, err = (&AttestationResult{}).SignCWT(alg, signer, nil) + assert.ErrorContains(t, err, "missing mandatory 'eat_profile', 'iat', 'ear_verifier_id', 'submods'") + + cwtBytes := []byte{ + 0xd8, 0x3d, // tag(61) [CWT] + 0xd2, // . tag(18) [cose-sign1] + 0x84, // . . array(4) [COSE_Sign1] + 0x43, // . . . [0]bstr(3) [protected-header] + 0xa1, // . . . . map(1) [header_map] + 0x01, // . . . . . key: 1 [alg] + 0x26, // . . . . . value: -7 [ES256] + 0xa0, // . . . [1]map(0) [unprotected-header] + 0x48, // . . . [2]bstr(8) [payload] + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, + 0x58, 0x40, // . . . [3]bstr(64) [signature] + 0x2d, 0xb5, 0x63, 0xa7, 0x44, 0xa6, 0x15, 0x4f, + 0xd9, 0x8a, 0x3d, 0x73, 0x90, 0xc4, 0x85, 0x95, + 0x0a, 0x35, 0x64, 0x2e, 0xcd, 0xa6, 0xd0, 0xbe, + 0x76, 0xe3, 0x96, 0x71, 0x75, 0x07, 0x7d, 0xea, + 0x4e, 0x6d, 0x0b, 0x28, 0x28, 0x1c, 0x8c, 0xbd, + 0x6e, 0xb7, 0xef, 0xdf, 0xc0, 0x5b, 0xe1, 0x97, + 0x19, 0xf4, 0x65, 0xa7, 0xa2, 0x3d, 0x5f, 0x31, + 0x6c, 0x81, 0xc6, 0xc8, 0x73, 0x76, 0x01, 0x1d, + } + + _, err = AttestationResultFromVerifiedCWT(cwtBytes, &CWTKeyEntry{alg, pubKey}) + assert.ErrorContains(t, err, "invalid additional information 30 for type tag") + + _, err = AttestationResultFromUnverifiedCWT(cwtBytes) + assert.ErrorContains(t, err, "invalid additional information 30 for type tag") +} diff --git a/cbor.go b/cbor.go new file mode 100644 index 0000000..1263e8a --- /dev/null +++ b/cbor.go @@ -0,0 +1,188 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package ear + +import ( + "crypto" + "crypto/rand" + "errors" + "fmt" + "math" + "time" + + cbor "github.com/fxamacker/cbor/v2" + "github.com/veraison/go-cose" +) + +const CBORTagsCWT = 61 + +var ( + em, emError = initCBOREncMode() + dm, dmError = initCBORDecMode() +) + +func signCWT(payload []byte, alg cose.Algorithm, key crypto.Signer, headers *cose.Headers) ([]byte, error) { + signer, err := cose.NewSigner(alg, key) + if err != nil { + return nil, err + } + + if headers == nil { + headers = &cose.Headers{ + Protected: cose.ProtectedHeader{ + cose.HeaderLabelAlgorithm: alg, + }, + } + } + + coseSign1, err := cose.Sign1(rand.Reader, signer, *headers, payload, nil) + if err != nil { + return nil, err + } + + tag := cbor.RawTag{ + Number: CBORTagsCWT, + Content: coseSign1, + } + + return em.Marshal(tag) +} + +func verifyAndUnwrapCWT(data []byte, alg cose.Algorithm, key crypto.PublicKey) ([]byte, error) { + verifier, err := cose.NewVerifier(alg, key) + if err != nil { + return nil, err + } + + coseSign1, err := unpackCoseSign1(data) + if err != nil { + return nil, err + } + + if err := coseSign1.Verify(nil, verifier); err != nil { + return nil, fmt.Errorf("COSE_Sign1: %w", err) + } + + if err := checkValidity(coseSign1.Payload, time.Now().UTC()); err != nil { + return nil, err + } + + return coseSign1.Payload, nil +} + +func unwrapCWT(data []byte) ([]byte, error) { + coseSign1, err := unpackCoseSign1(data) + if err != nil { + return nil, err + } + + return coseSign1.Payload, nil +} + +func unpackCoseSign1(data []byte) (cose.Sign1Message, error) { + var tag cbor.RawTag + if err := dm.Unmarshal(data, &tag); err != nil { + return cose.Sign1Message{}, err + } + + var coseSign1Bytes []byte + switch tag.Number { + case CBORTagsCWT: + coseSign1Bytes = tag.Content + case cose.CBORTagSign1Message: + coseSign1Bytes = data + default: + return cose.Sign1Message{}, fmt.Errorf("expected CWT or COSE_Sign1, found tag %d", tag.Number) + } + + var coseSign1 cose.Sign1Message + if err := coseSign1.UnmarshalCBOR(coseSign1Bytes); err != nil { + return cose.Sign1Message{}, err + } + + return coseSign1, nil +} + +type epochTime time.Time + +func (e *epochTime) UnmarshalCBOR(data []byte) error { + var raw any + if err := cbor.Unmarshal(data, &raw); err != nil { + return err + } + + switch v := raw.(type) { + case int64: + *e = epochTime(time.Unix(v, 0).UTC()) + case uint64: + if v > uint64(math.MaxInt64) { + return errors.New("overflow") + } + *e = epochTime(time.Unix(int64(v), 0).UTC()) + case float64: + if math.IsNaN(v) || v >= float64(math.MaxInt64) || v <= float64(math.MinInt64) { + return fmt.Errorf("cannot convert to int64: %f", v) + } + sec := int64(v) + nsec := int64((v - float64(sec)) * 1e9) + *e = epochTime(time.Unix(sec, nsec).UTC()) + default: + return fmt.Errorf("unexpected type %T for time value", raw) + } + return nil +} + +func (e epochTime) Time() time.Time { + return time.Time(e) +} + +func checkValidity(data []byte, now time.Time) error { + type validityClaims struct { + Expiry *epochTime `cbor:"4,keyasint,omitempty"` + NotBefore *epochTime `cbor:"6,keyasint,omitempty"` + } + + var validity validityClaims + if err := dm.Unmarshal(data, &validity); err != nil { + return err + } + + if validity.NotBefore != nil && now.Before(validity.NotBefore.Time()) { + return fmt.Errorf("CWT not yet valid: nbf=%s, now=%s", validity.NotBefore.Time(), now) + } + + if validity.Expiry != nil && !now.Before(validity.Expiry.Time()) { + return fmt.Errorf("CWT expired: exp=%s, now=%s", validity.Expiry.Time(), now) + } + + return nil +} + +func initCBOREncMode() (en cbor.EncMode, err error) { + encOpt := cbor.EncOptions{ + Sort: cbor.SortCoreDeterministic, + IndefLength: cbor.IndefLengthForbidden, + NilContainers: cbor.NilContainerAsEmpty, + TimeTag: cbor.EncTagRequired, + } + return encOpt.EncMode() +} + +func initCBORDecMode() (dm cbor.DecMode, err error) { + decOpt := cbor.DecOptions{ + IndefLength: cbor.IndefLengthAllowed, + TimeTag: cbor.DecTagOptional, + DupMapKey: cbor.DupMapKeyEnforcedAPF, + } + return decOpt.DecMode() +} + +func init() { + if emError != nil { + panic(emError) + } + if dmError != nil { + panic(dmError) + } +} diff --git a/cbor_test.go b/cbor_test.go new file mode 100644 index 0000000..0fefd35 --- /dev/null +++ b/cbor_test.go @@ -0,0 +1,250 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package ear + +import ( + "crypto/ecdsa" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v4/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/veraison/go-cose" +) + +func Test_CWT_sign_verify(t *testing.T) { + payload := []byte{ + 0xa2, 0x04, 0x1b, 0x00, 0x00, 0x00, 0x02, 0x54, + 0x0b, 0xe3, 0xff, 0x06, 0x1a, 0x6a, 0xd1, 0xe2, + 0xe0, + } + + key, err := jwk.ParseKey([]byte(testECDSAPrivateKey)) + require.NoError(t, err) + + alg, signer, err := SignerFromJWK(key) + require.NoError(t, err) + + signedCWT, err := signCWT(payload, alg, signer, nil) + assert.NoError(t, err) + + jwkPubKey, err := key.PublicKey() + require.NoError(t, err) + + pubKey, err := jwk.Export[*ecdsa.PublicKey](jwkPubKey) + require.NoError(t, err) + + unwrapped, err := verifyAndUnwrapCWT(signedCWT, alg, pubKey) + assert.NoError(t, err) + assert.Equal(t, payload, unwrapped) + + coseSign1 := signedCWT[2:] + unwrapped, err = verifyAndUnwrapCWT(coseSign1, alg, pubKey) + assert.NoError(t, err) + assert.Equal(t, payload, unwrapped) + + unwrapped, err = unwrapCWT(signedCWT) + assert.NoError(t, err) + assert.Equal(t, payload, unwrapped) + + unwrapped, err = unwrapCWT(coseSign1) + assert.NoError(t, err) + assert.Equal(t, payload, unwrapped) + + jwkPubKey, err = jwk.ParseKey([]byte(testECDSAPublicKeyOther)) + require.NoError(t, err) + + pubKey, err = jwk.Export[*ecdsa.PublicKey](jwkPubKey) + require.NoError(t, err) + + _, err = verifyAndUnwrapCWT(signedCWT, alg, pubKey) + assert.ErrorContains(t, err, "COSE_Sign1: verification error") +} + +func Test_CWT_unwrap_negative(t *testing.T) { + jwkPubKey, err := jwk.ParseKey([]byte(testECDSAPublicKeyOther)) + require.NoError(t, err) + + pubKey, err := jwk.Export[*ecdsa.PublicKey](jwkPubKey) + require.NoError(t, err) + + testCases := []struct { + title string + input []byte + err string + }{ + { + title: "err empty", + err: "EOF", + }, + { + title: "err bad type", + input: []byte{0x01}, + err: "cbor: cannot unmarshal positive integer into Go value of type cbor.RawTag", + }, + { + title: "err bad tag", + input: []byte{0xc1, 0x01}, + err: "expected CWT or COSE_Sign1, found tag 1", + }, + { + title: "err bad inner type", + input: []byte{0xd2, 0x01}, + err: "cbor: invalid COSE_Sign1_Tagged object", + }, + } + + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + _, err = verifyAndUnwrapCWT(tc.input, cose.AlgorithmES256, pubKey) + assert.ErrorContains(t, err, tc.err) + + _, err = unwrapCWT(tc.input) + assert.ErrorContains(t, err, tc.err) + }) + } +} + +func Test_epocTime_UnmarshalCBOR(t *testing.T) { + testCases := []struct { + title string + input []byte + expected time.Time + err string + }{ + { + title: "ok uint", + input: []byte{0x01}, + expected: time.Unix(1, 0).UTC(), + }, + { + title: "ok nint", + input: []byte{0x20}, + expected: time.Unix(-1, 0).UTC(), + }, + { + title: "ok float64", + input: []byte{ + 0xfb, 0x3f, 0xf1, 0x99, 0x99, 0x99, 0x99, 0x99, + 0x9a, + }, + expected: time.Unix(1, 1e8).UTC(), + }, + { + title: "err empty", + input: []byte{}, + err: "EOF", + }, + { + title: "err bad type", + input: []byte{0xf5}, + err: "unexpected type bool for time value", + }, + { + title: "err overflow", + input: []byte{ + 0x1b, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + }, + err: "overflow", + }, + { + title: "err NaN", + input: []byte{ + 0xfb, 0x7f, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + }, + err: "cannot convert to int64: NaN", + }, + } + + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + var epocTime epochTime + err := epocTime.UnmarshalCBOR(tc.input) + + if tc.err == "" { + assert.NoError(t, err) + assert.Equal(t, tc.expected, epocTime.Time()) + } else { + assert.ErrorContains(t, err, tc.err) + } + }) + } + +} + +func Test_checkValidity(t *testing.T) { + now := time.Unix(1792147122, 0).UTC() + testCases := []struct { + title string + input []byte + err string + }{ + { + title: "ok", + input: []byte{ + 0xa2, // map(2) [validityStruct] + 0x04, // . key: 4 [exp] + 0x1b, // . value: int(8) + 0x00, 0x00, 0x00, 0x02, 0x54, 0x0b, 0xe3, 0xff, // . . 9999999999 + 0x06, // . key: 6 [nbf] + 0x1a, // . value: int(4) + 0x6a, 0xd1, 0xe2, 0xe0, // . . 1792140000 + }, + }, + { + title: "ok no expiry", + input: []byte{ + 0xa1, // map(1) + 0x01, // . key: 1 + 0x01, // . value: 1 + }, + }, + { + title: "err expired", + input: []byte{ + 0xa2, // map(2) [validityStruct] + 0x04, // . key: 4 [exp] + 0x1a, // . value: int(4) + 0x6a, 0xd1, 0xe2, 0xe0, // . . 1792140000 + 0x06, // . key: 6 [nbf] + 0x1a, // . value: int(4) + 0x6a, 0xd1, 0xe2, 0xe0, // . . 1792140000 + }, + err: "CWT expired", + }, + { + title: "err premature", + input: []byte{ + 0xa2, // map(2) [validityStruct] + 0x04, // . key: 4 [exp] + 0x1b, // . value: int(8) + 0x00, 0x00, 0x00, 0x02, 0x54, 0x0b, 0xe3, 0xff, // . . 9999999999 + 0x06, // . key: 6 [nbf] + 0x1b, // . value: int(8) + 0x00, 0x00, 0x00, 0x02, 0x54, 0x0b, 0xe3, 0xff, // . . 9999999999 + }, + err: "CWT not yet valid", + }, + { + title: "err empty", + input: []byte{}, + err: "EOF", + }, + } + + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + err := checkValidity(tc.input, now) + + if tc.err == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.err) + } + }) + } +} diff --git a/extensions.go b/extensions.go index 4e4cc78..f09f88a 100644 --- a/extensions.go +++ b/extensions.go @@ -140,6 +140,13 @@ func (o *Extensions) GetInt64Map(key any) (map[int64]any, error) { return getTyped[map[int64]any](o, key) } +// GetMap returns the map[any]any value associated with the specified key (must +// be int, string or eat.IntOrString). An Error is returned if the key is +// invalid or doesn't exist, or the associated value is of the wrong type. +func (o *Extensions) GetMap(key any) (map[any]any, error) { + return getTyped[map[any]any](o, key) +} + // GetStringMap returns the map[string]any value associated with the specified // key (must be int, string or eat.IntOrString). An Error is returned if the // key is invalid or doesn't exist, or the associated value is of the wrong diff --git a/go.mod b/go.mod index 520b093..f33d106 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/veraison/ear go 1.26.0 require ( + github.com/fxamacker/cbor/v2 v2.9.6 github.com/huandu/xstrings v1.6.2 github.com/lestrrat-go/jwx/v4 v4.5.0 github.com/spf13/afero v1.15.0 @@ -11,11 +12,11 @@ require ( github.com/stretchr/testify v1.12.1 github.com/veraison/cmw v0.3.0 github.com/veraison/eat v0.0.0-20261009144514-752feb05b2e9 + github.com/veraison/go-cose v1.3.0 ) require ( github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/fxamacker/cbor/v2 v2.9.4 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -27,7 +28,6 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect - github.com/veraison/go-cose v1.3.0 // indirect github.com/veraison/swid v1.1.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/go.sum b/go.sum index ac6cd27..c857480 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= -github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo= -github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs= +github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= diff --git a/submods.go b/submods.go index 9e0a801..77cb811 100644 --- a/submods.go +++ b/submods.go @@ -108,7 +108,7 @@ func (o *Submod) UnmarshalJSON(data []byte) error { } o.value = &token - case '{': + case '{': // nolint:dupl // object -> Appraisal or AttestationResult type resultChecker struct { Iat *int64 `json:"iat"` @@ -144,6 +144,61 @@ func (o *Submod) UnmarshalJSON(data []byte) error { return nil } +func (o Submod) MarshalCBOR() ([]byte, error) { + return em.Marshal(o.value) +} + +func (o *Submod) UnmarshalCBOR(data []byte) error { + if len(data) == 0 { + return errors.New("truncated input") + } + + majorType := (data[0] & 0xe0) >> 5 + switch majorType { + case 3, 4: + // ( string -> JWT | array -> JSON-Selector ) -> Nested-Token + var token eat.NestedToken + if err := token.UnmarshalCBOR(data); err != nil { + return fmt.Errorf("nested token: %w", err) + } + + o.value = &token + case 5: // nolint:dupl + // map -> Appraisal or AttestationResult + type resultChecker struct { + Iat *int64 `cbor:"6,keyasint"` + } + + var checker resultChecker + + if err := dm.Unmarshal(data, &checker); err != nil { + return err + } + + if checker.Iat == nil { + // missing iat -> Appraisal + var appraisal Appraisal + if err := appraisal.UnmarshalCBOR(data); err != nil { + return fmt.Errorf("appraisal: %w", err) + } + + o.value = &appraisal + } else { + // iat present -> AttestationResult + var result AttestationResult + if err := result.UnmarshalCBOR(data); err != nil { + return fmt.Errorf("attestation result: %w", err) + } + + o.value = &result + } + default: + return fmt.Errorf("unexpected CBOR major type %d (expected 3, 4, or 5)", majorType) + } + + return nil +} + // Submods is a collection of named submodule claims. Each submodule is either // an AttestationResult, an Appraisal, or a eat.NestedToken. type Submods map[string]*Submod @@ -248,6 +303,20 @@ func (o Submods) AddJWT(name string, jwtBytes []byte) Submods { return o } +// AddCWT adds the provided CWT bytes to the Submods as a NestedToken under the +// specified name, overwriting any existing submod under that name. Pointer to +// the Submods is returned to allow chaining with other adds. Panics if the +// provided []byte does not appear to contain a valid CWT. +func (o Submods) AddCWT(name string, cwtBytes []byte) Submods { + token, err := eat.NewNestedToken(eat.NestedTokenCBOR, cwtBytes) + if err != nil { + panic(err) + } + + o.AddNestedToken(name, token) + return o +} + // Get retrieves a submod's value by name. If name is not in the collection, // nil is returned. func (o Submods) Get(name string) any { diff --git a/submods_test.go b/submods_test.go index 1be526b..068b71d 100644 --- a/submods_test.go +++ b/submods_test.go @@ -121,6 +121,7 @@ func TestSubmod_round_trip(t *testing.T) { title string submod Submod expectedJSON string + expectedCBOR []byte }{ { title: "ok Appraisal", @@ -131,6 +132,11 @@ func TestSubmod_round_trip(t *testing.T) { }, }, expectedJSON: `{"ear_status":"affirming"}`, + expectedCBOR: []byte{ + 0xa1, // map(1) + 0x19, 0x03, 0xe8, // . key: 1000 [eat_status] + 0x02, // // . value: 2 [affirming] + }, }, { title: "ok AttestationResult", @@ -150,6 +156,35 @@ func TestSubmod_round_trip(t *testing.T) { }, }, expectedJSON: `{"ear_verifier_id":{"developer":"test-developer","build":"test-build"},"eat_profile":"tag:ietf.org,2026:rats/ear#04","iat":1666091373,"submods":{"test-appraisal":{"ear_status":"affirming"}}}`, + expectedCBOR: []byte{ + 0xa4, // map(4) + 0x06, // . key: 6 [iat] + 0x1a, 0x63, 0x4e, 0x89, 0x6d, // . value: 1666091373 + 0x19, 0x01, 0x09, // . key: 265 [eat_profile] + 0x78, 0x1d, // . value: tstr(29) + 0x74, 0x61, 0x67, 0x3a, 0x69, 0x65, 0x74, 0x66, // . . "tag:ietf" + 0x2e, 0x6f, 0x72, 0x67, 0x2c, 0x32, 0x30, 0x32, // . . ".org,202" + 0x36, 0x3a, 0x72, 0x61, 0x74, 0x73, 0x2f, 0x65, // . . "6:rats/e" + 0x61, 0x72, 0x23, 0x30, 0x34, // . . "ar#04" + 0x19, 0x01, 0x0a, // . key: 266 [submods] + 0xa1, // . value: map(1) [Submods] + 0x6e, // . key: tstr(14) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x61, 0x70, 0x70, // . . "test-app" + 0x72, 0x61, 0x69, 0x73, 0x61, 0x6c, // . . "raisal" + 0xa1, // . value: map(1) [Appraisal] + 0x19, 0x03, 0xe8, // . . key: 1000 [ear_status] + 0x02, // . . value: 2 [affirming] + 0x19, 0x03, 0xec, // . key: 1004 [ear_verifier_id] + 0xa2, // . value: map(2) [VerifierIdentity] + 0x00, // . . key: 0 [developer] + 0x6e, // . . value: tstr(14) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x64, 0x65, 0x76, // . . . "test-dev" + 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x72, // . . . "eloper" + 0x01, // . . key: 1 [build] + 0x6a, // . . value: tstr(10) + 0x74, 0x65, 0x73, 0x74, 0x2d, 0x62, 0x75, 0x69, // . . . "test-bui" + 0x6c, 0x64, // . . . "ld" + }, }, { title: "ok eat.NestedToken", @@ -160,6 +195,87 @@ func TestSubmod_round_trip(t *testing.T) { }, }, expectedJSON: `["JWT","eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlYXJfdmVyaWZpZXJfaWQiOnsiYnVpbGQiOiJycnRyYXAtdjEuMC4wIiwiZGV2ZWxvcGVyIjoiQWNtZSBJbmMuIn0sImVhdF9wcm9maWxlIjoidGFnOmlldGYub3JnLDIwMjY6cmF0cy9lYXIjMDQiLCJpYXQiOjE2NjYwOTEzNzMsInN1Ym1vZHMiOnsidGVzdCI6eyJlYXJfYXBwcmFpc2FsX3BvbGljeV9pZHMiOlsicG9saWN5Oi8vdGVzdC8wMTIzNCJdLCJlYXJfYXR0ZXN0ZXJfY2xhaW1zIjp7ImsxIjoidjEiLCJrMiI6InYyIn0sImVhcl9zdGF0dXMiOiJhZmZpcm1pbmciLCJlYXJfdmVyYWlzb25fa2V5X2F0dGVzdGF0aW9uIjp7ImFrcHViIjoiWVd0d2RXSUsifSwiZWFyX3ZlcmlmaWVyX2NsYWltcyI6eyJiYXIiOiJiYXoiLCJmb28iOiJiYXIifX19fQ.aLyO-JRxqyvEFIZyvqhVebdLar9KSAeShB-DbZZ1yqfdGzk6-5nu1tU0Dgx_Mjoyo13hgNpW19iHvvj1pBCgtg"]`, + expectedCBOR: []byte{ + 0x79, 0x02, 0x70, // tstr(624) + 0x22, 0x65, 0x79, 0x4a, 0x68, 0x62, 0x47, 0x63, + 0x69, 0x4f, 0x69, 0x4a, 0x46, 0x55, 0x7a, 0x49, + 0x31, 0x4e, 0x69, 0x49, 0x73, 0x49, 0x6e, 0x52, + 0x35, 0x63, 0x43, 0x49, 0x36, 0x49, 0x6b, 0x70, + 0x58, 0x56, 0x43, 0x4a, 0x39, 0x2e, 0x65, 0x79, + 0x4a, 0x6c, 0x59, 0x58, 0x4a, 0x66, 0x64, 0x6d, + 0x56, 0x79, 0x61, 0x57, 0x5a, 0x70, 0x5a, 0x58, + 0x4a, 0x66, 0x61, 0x57, 0x51, 0x69, 0x4f, 0x6e, + 0x73, 0x69, 0x59, 0x6e, 0x56, 0x70, 0x62, 0x47, + 0x51, 0x69, 0x4f, 0x69, 0x4a, 0x79, 0x63, 0x6e, + 0x52, 0x79, 0x59, 0x58, 0x41, 0x74, 0x64, 0x6a, + 0x45, 0x75, 0x4d, 0x43, 0x34, 0x77, 0x49, 0x69, + 0x77, 0x69, 0x5a, 0x47, 0x56, 0x32, 0x5a, 0x57, + 0x78, 0x76, 0x63, 0x47, 0x56, 0x79, 0x49, 0x6a, + 0x6f, 0x69, 0x51, 0x57, 0x4e, 0x74, 0x5a, 0x53, + 0x42, 0x4a, 0x62, 0x6d, 0x4d, 0x75, 0x49, 0x6e, + 0x30, 0x73, 0x49, 0x6d, 0x56, 0x68, 0x64, 0x46, + 0x39, 0x77, 0x63, 0x6d, 0x39, 0x6d, 0x61, 0x57, + 0x78, 0x6c, 0x49, 0x6a, 0x6f, 0x69, 0x64, 0x47, + 0x46, 0x6e, 0x4f, 0x6d, 0x6c, 0x6c, 0x64, 0x47, + 0x59, 0x75, 0x62, 0x33, 0x4a, 0x6e, 0x4c, 0x44, + 0x49, 0x77, 0x4d, 0x6a, 0x59, 0x36, 0x63, 0x6d, + 0x46, 0x30, 0x63, 0x79, 0x39, 0x6c, 0x59, 0x58, + 0x49, 0x6a, 0x4d, 0x44, 0x51, 0x69, 0x4c, 0x43, + 0x4a, 0x70, 0x59, 0x58, 0x51, 0x69, 0x4f, 0x6a, + 0x45, 0x32, 0x4e, 0x6a, 0x59, 0x77, 0x4f, 0x54, + 0x45, 0x7a, 0x4e, 0x7a, 0x4d, 0x73, 0x49, 0x6e, + 0x4e, 0x31, 0x59, 0x6d, 0x31, 0x76, 0x5a, 0x48, + 0x4d, 0x69, 0x4f, 0x6e, 0x73, 0x69, 0x64, 0x47, + 0x56, 0x7a, 0x64, 0x43, 0x49, 0x36, 0x65, 0x79, + 0x4a, 0x6c, 0x59, 0x58, 0x4a, 0x66, 0x59, 0x58, + 0x42, 0x77, 0x63, 0x6d, 0x46, 0x70, 0x63, 0x32, + 0x46, 0x73, 0x58, 0x33, 0x42, 0x76, 0x62, 0x47, + 0x6c, 0x6a, 0x65, 0x56, 0x39, 0x70, 0x5a, 0x48, + 0x4d, 0x69, 0x4f, 0x6c, 0x73, 0x69, 0x63, 0x47, + 0x39, 0x73, 0x61, 0x57, 0x4e, 0x35, 0x4f, 0x69, + 0x38, 0x76, 0x64, 0x47, 0x56, 0x7a, 0x64, 0x43, + 0x38, 0x77, 0x4d, 0x54, 0x49, 0x7a, 0x4e, 0x43, + 0x4a, 0x64, 0x4c, 0x43, 0x4a, 0x6c, 0x59, 0x58, + 0x4a, 0x66, 0x59, 0x58, 0x52, 0x30, 0x5a, 0x58, + 0x4e, 0x30, 0x5a, 0x58, 0x4a, 0x66, 0x59, 0x32, + 0x78, 0x68, 0x61, 0x57, 0x31, 0x7a, 0x49, 0x6a, + 0x70, 0x37, 0x49, 0x6d, 0x73, 0x78, 0x49, 0x6a, + 0x6f, 0x69, 0x64, 0x6a, 0x45, 0x69, 0x4c, 0x43, + 0x4a, 0x72, 0x4d, 0x69, 0x49, 0x36, 0x49, 0x6e, + 0x59, 0x79, 0x49, 0x6e, 0x30, 0x73, 0x49, 0x6d, + 0x56, 0x68, 0x63, 0x6c, 0x39, 0x7a, 0x64, 0x47, + 0x46, 0x30, 0x64, 0x58, 0x4d, 0x69, 0x4f, 0x69, + 0x4a, 0x68, 0x5a, 0x6d, 0x5a, 0x70, 0x63, 0x6d, + 0x31, 0x70, 0x62, 0x6d, 0x63, 0x69, 0x4c, 0x43, + 0x4a, 0x6c, 0x59, 0x58, 0x4a, 0x66, 0x64, 0x6d, + 0x56, 0x79, 0x59, 0x57, 0x6c, 0x7a, 0x62, 0x32, + 0x35, 0x66, 0x61, 0x32, 0x56, 0x35, 0x58, 0x32, + 0x46, 0x30, 0x64, 0x47, 0x56, 0x7a, 0x64, 0x47, + 0x46, 0x30, 0x61, 0x57, 0x39, 0x75, 0x49, 0x6a, + 0x70, 0x37, 0x49, 0x6d, 0x46, 0x72, 0x63, 0x48, + 0x56, 0x69, 0x49, 0x6a, 0x6f, 0x69, 0x57, 0x56, + 0x64, 0x30, 0x64, 0x32, 0x52, 0x58, 0x53, 0x55, + 0x73, 0x69, 0x66, 0x53, 0x77, 0x69, 0x5a, 0x57, + 0x46, 0x79, 0x58, 0x33, 0x5a, 0x6c, 0x63, 0x6d, + 0x6c, 0x6d, 0x61, 0x57, 0x56, 0x79, 0x58, 0x32, + 0x4e, 0x73, 0x59, 0x57, 0x6c, 0x74, 0x63, 0x79, + 0x49, 0x36, 0x65, 0x79, 0x4a, 0x69, 0x59, 0x58, + 0x49, 0x69, 0x4f, 0x69, 0x4a, 0x69, 0x59, 0x58, + 0x6f, 0x69, 0x4c, 0x43, 0x4a, 0x6d, 0x62, 0x32, + 0x38, 0x69, 0x4f, 0x69, 0x4a, 0x69, 0x59, 0x58, + 0x49, 0x69, 0x66, 0x58, 0x31, 0x39, 0x66, 0x51, + 0x2e, 0x61, 0x4c, 0x79, 0x4f, 0x2d, 0x4a, 0x52, + 0x78, 0x71, 0x79, 0x76, 0x45, 0x46, 0x49, 0x5a, + 0x79, 0x76, 0x71, 0x68, 0x56, 0x65, 0x62, 0x64, + 0x4c, 0x61, 0x72, 0x39, 0x4b, 0x53, 0x41, 0x65, + 0x53, 0x68, 0x42, 0x2d, 0x44, 0x62, 0x5a, 0x5a, + 0x31, 0x79, 0x71, 0x66, 0x64, 0x47, 0x7a, 0x6b, + 0x36, 0x2d, 0x35, 0x6e, 0x75, 0x31, 0x74, 0x55, + 0x30, 0x44, 0x67, 0x78, 0x5f, 0x4d, 0x6a, 0x6f, + 0x79, 0x6f, 0x31, 0x33, 0x68, 0x67, 0x4e, 0x70, + 0x57, 0x31, 0x39, 0x69, 0x48, 0x76, 0x76, 0x6a, + 0x31, 0x70, 0x42, 0x43, 0x67, 0x74, 0x67, 0x22, + }, }, } @@ -173,6 +289,14 @@ func TestSubmod_round_trip(t *testing.T) { err = decoded.UnmarshalJSON(encodedJSON) assert.NoError(t, err) assert.Equal(t, tc.submod, decoded) + + encodedCBOR, err := tc.submod.MarshalCBOR() + assert.NoError(t, err) + assert.Equal(t, tc.expectedCBOR, encodedCBOR) + + err = decoded.UnmarshalCBOR(encodedCBOR) + assert.NoError(t, err) + assert.Equal(t, tc.submod, decoded) }) } } @@ -219,6 +343,33 @@ func TestSubmod_UnmarshalJSON_negative(t *testing.T) { } } +func TestSubmod_UnmarshalCBOR_negative(t *testing.T) { + testCases := []struct { + title string + input []byte + err string + }{ + { + title: "err empty", + input: []byte{}, + err: "truncated input", + }, + { + title: "err bad CBOR", + input: []byte{0xde, 0xad, 0xbe, 0xef}, + err: "unexpected CBOR major type 6 (expected 3, 4, or 5)", + }, + } + + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + var decoded Submod + err := decoded.UnmarshalCBOR(tc.input) + assert.ErrorContains(t, err, tc.err) + }) + } +} + func TestSubmodsFromAny(t *testing.T) { testCases := []struct { title string diff --git a/trusttier.go b/trusttier.go index ec8f850..80cf351 100644 --- a/trusttier.go +++ b/trusttier.go @@ -261,3 +261,19 @@ func (o *TrustTier) UnmarshalJSON(data []byte) error { return nil } + +func (o TrustTier) MarshalCBOR() ([]byte, error) { + return em.Marshal(o.value) +} + +func (o *TrustTier) UnmarshalCBOR(data []byte) error { + var value int + var err error + + if err := dm.Unmarshal(data, &value); err != nil { + return err + } + + *o, err = TrustTierFromInt(value) + return err +} diff --git a/trusttier_test.go b/trusttier_test.go index 8f7ccf3..2654b88 100644 --- a/trusttier_test.go +++ b/trusttier_test.go @@ -16,31 +16,37 @@ func TestTrustTier_round_trip(t *testing.T) { title string tier TrustTier expectedJSON string + expectedCBOR []byte }{ { title: "ok none", tier: TrustTierNone, expectedJSON: `"none"`, + expectedCBOR: []byte{0x00}, }, { title: "ok affirming", tier: TrustTierAffirming, expectedJSON: `"affirming"`, + expectedCBOR: []byte{0x02}, }, { title: "ok warning", tier: TrustTierWarning, expectedJSON: `"warning"`, + expectedCBOR: []byte{0x18, 0x20}, }, { title: "ok contraindicated", tier: TrustTierContraindicated, expectedJSON: `"contraindicated"`, + expectedCBOR: []byte{0x18, 0x60}, }, { title: "ok incomplete", tier: TrustTierIncomplete, expectedJSON: `"incomplete"`, + expectedCBOR: []byte{0x18, 0x7f}, }, } @@ -54,6 +60,14 @@ func TestTrustTier_round_trip(t *testing.T) { err = json.Unmarshal(encodedJSON, &tier) assert.NoError(t, err) assert.Equal(t, tc.tier, tier) + + encodedCBOR, err := tc.tier.MarshalCBOR() + assert.NoError(t, err) + assert.Equal(t, tc.expectedCBOR, encodedCBOR) + + err = tier.UnmarshalCBOR(encodedCBOR) + assert.NoError(t, err) + assert.Equal(t, tc.tier, tier) }) } } diff --git a/trustvector.go b/trustvector.go index 4b99543..a81d28b 100644 --- a/trustvector.go +++ b/trustvector.go @@ -14,16 +14,16 @@ import ( ) // TrustVector is an implementation of the Trustworthiness Vector (and Claims) -// described in §2.3 of draft-ietf-rats-ar4si-03, using a JSON serialization. +// described in §2.3 of draft-ietf-rats-ar4si-03. type TrustVector struct { - InstanceIdentity TrustClaim `json:"instance-identity,omitzero"` - Configuration TrustClaim `json:"configuration,omitzero"` - Executables TrustClaim `json:"executables,omitzero"` - FileSystem TrustClaim `json:"file-system,omitzero"` - Hardware TrustClaim `json:"hardware,omitzero"` - RuntimeOpaque TrustClaim `json:"runtime-opaque,omitzero"` - StorageOpaque TrustClaim `json:"storage-opaque,omitzero"` - SourcedData TrustClaim `json:"sourced-data,omitzero"` + InstanceIdentity TrustClaim `cbor:"0,keyasint,omitzero" json:"instance-identity,omitzero"` + Configuration TrustClaim `cbor:"1,keyasint,omitzero" json:"configuration,omitzero"` + Executables TrustClaim `cbor:"2,keyasint,omitzero" json:"executables,omitzero"` + FileSystem TrustClaim `cbor:"3,keyasint,omitzero" json:"file-system,omitzero"` + Hardware TrustClaim `cbor:"4,keyasint,omitzero" json:"hardware,omitzero"` + RuntimeOpaque TrustClaim `cbor:"5,keyasint,omitzero" json:"runtime-opaque,omitzero"` + StorageOpaque TrustClaim `cbor:"6,keyasint,omitzero" json:"storage-opaque,omitzero"` + SourcedData TrustClaim `cbor:"7,keyasint,omitzero" json:"sourced-data,omitzero"` } // NewTrustVector returns a new TrustVector with all its claims set to NoClaim. diff --git a/utils.go b/utils.go index a713613..009d896 100644 --- a/utils.go +++ b/utils.go @@ -4,6 +4,9 @@ package ear import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" "encoding/json" "errors" "fmt" @@ -14,8 +17,10 @@ import ( "strings" "time" + "github.com/lestrrat-go/jwx/v4/jwk" "github.com/veraison/cmw" "github.com/veraison/eat" + "github.com/veraison/go-cose" ) // Int64FromAny converts provided value into an int64. If the conversion is not @@ -92,6 +97,33 @@ func Ptr[T any](v T) *T { return &v } +const noAlg = cose.Algorithm(-65537) + +// SignerFromJWK converts provided jwk.Key into the corresponding +// cose.Algorithm and crypto.Signer, returning an error if the conversion is +// not possible. +// Note: only ecdsa keys with P256, P384, and P512 curves are supported. +func SignerFromJWK(jwkKey jwk.Key) (cose.Algorithm, crypto.Signer, error) { + key, err := jwk.Export[*ecdsa.PrivateKey](jwkKey) + if err != nil { + return noAlg, nil, err + } + + var alg cose.Algorithm + switch key.Curve { + case elliptic.P256(): + alg = cose.AlgorithmES256 + case elliptic.P384(): + alg = cose.AlgorithmES384 + case elliptic.P521(): + alg = cose.AlgorithmES512 + default: + return noAlg, nil, fmt.Errorf("unknown elliptic curve %v", key.Curve) + } + + return alg, key, nil +} + func getTagLabels(source any, tagType string) map[eat.IntOrString]bool { ret := make(map[eat.IntOrString]bool) diff --git a/verifier_identity.go b/verifier_identity.go index 698f30c..c566faf 100644 --- a/verifier_identity.go +++ b/verifier_identity.go @@ -15,9 +15,9 @@ import ( type VerifierIdentity struct { // Developer uniquely identifies the organizational unit responsible // for this build. - Developer string `json:"developer"` + Developer string `cbor:"0,keyasint" json:"developer"` // Build uniquely identifies the software build running the verifier. - Build string `json:"build"` + Build string `cbor:"1,keyasint" json:"build"` } // NewVerifierIdentity returns a pointer to a new NewVerifierIdentity