From 7d96fe753657ab7a1015e108f575bdeb9972023a Mon Sep 17 00:00:00 2001 From: Sergei Trofimov Date: Thu, 15 Oct 2026 10:24:05 +0100 Subject: [PATCH 1/3] feat: add CBOR encoding Add CBOR encoding support to EAR types. Signed-off-by: Sergei Trofimov --- appraisal.go | 81 ++++++++++++++++---- appraisal_test.go | 79 +++++++++++++++++++ attestation_result.go | 84 +++++++++++++++------ attestation_result_test.go | 99 ++++++++++++++++++++++++ cbor.go | 41 ++++++++++ extensions.go | 7 ++ go.mod | 2 +- go.sum | 2 + submods.go | 57 +++++++++++++- submods_test.go | 151 +++++++++++++++++++++++++++++++++++++ trusttier.go | 16 ++++ trusttier_test.go | 14 ++++ trustvector.go | 18 ++--- verifier_identity.go | 4 +- 14 files changed, 606 insertions(+), 49 deletions(-) create mode 100644 cbor.go 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/attestation_result.go b/attestation_result.go index 70bc8ef..a3958bc 100644 --- a/attestation_result.go +++ b/attestation_result.go @@ -39,16 +39,16 @@ type JWTKeyEntry struct { // by the verifier. It is serialized to JSON and signed by the verifier using // JWT. 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. @@ -156,7 +156,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 +168,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) @@ -455,11 +455,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 +486,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..18894a6 100644 --- a/attestation_result_test.go +++ b/attestation_result_test.go @@ -70,6 +70,7 @@ func TestAttestationResult_round_trip(t *testing.T) { title string result AttestationResult expectedJSON string + expectedCBOR []byte }{ { title: "ok minimal", @@ -87,6 +88,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 +148,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 +229,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) }) } } diff --git a/cbor.go b/cbor.go new file mode 100644 index 0000000..9a23b6c --- /dev/null +++ b/cbor.go @@ -0,0 +1,41 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package ear + +import ( + cbor "github.com/fxamacker/cbor/v2" +) + +var ( + em, emError = initCBOREncMode() + dm, dmError = initCBORDecMode() +) + +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/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..17c337c 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( require ( github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/fxamacker/cbor/v2 v2.9.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.6 // 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 diff --git a/go.sum b/go.sum index ac6cd27..c5e7b34 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxv 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..b22a329 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 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/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 From bb425424425cd1ad64573d8ef2c44314735aedf4 Mon Sep 17 00:00:00 2001 From: Sergei Trofimov Date: Thu, 15 Oct 2026 15:37:04 +0100 Subject: [PATCH 2/3] feat: add CWT support Add support for creating, verifying, and extracting attestation results form CWTs. Attetation result now has SignCWT, as well as verified and unverified FromXXXCWT() methods and functions that exist for JWT. arc has also been updated to work with CWTs. "create" command now has a --cwt flag to indicate that a CWT, rather than JWT, should be created. "verify" command determines whether it has a CWT or JWT based on initial bytes. Signed-off-by: Sergei Trofimov --- arc/cmd/create.go | 41 ++++-- arc/cmd/create_test.go | 27 +++- arc/cmd/test_vars.go | 30 +++++ arc/cmd/verify.go | 57 +++++++-- arc/cmd/verify_test.go | 65 +++++++++- attestation_result.go | 182 +++++++++++++++++++++++---- attestation_result_test.go | 178 ++++++++++++++++++++++++-- cbor.go | 149 +++++++++++++++++++++- cbor_test.go | 250 +++++++++++++++++++++++++++++++++++++ go.mod | 4 +- go.sum | 2 - submods.go | 14 +++ utils.go | 32 +++++ 13 files changed, 968 insertions(+), 63 deletions(-) create mode 100644 cbor_test.go 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/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 a3958bc..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,9 +37,17 @@ 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 `cbor:"265,keyasint" json:"eat_profile"` Status *TrustTier `cbor:"1000,keyasint,omitempty" json:"ear_status,omitempty"` @@ -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 { @@ -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 + } + + verified = true + + result.UpdateStatusFromSubmods(keys...) + if result.Status.WorseThan(status) { + status = *result.Status + } - 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 { diff --git a/attestation_result_test.go b/attestation_result_test.go index 18894a6..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." @@ -616,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) } @@ -682,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{ @@ -698,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 { @@ -1030,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 index 9a23b6c..1263e8a 100644 --- a/cbor.go +++ b/cbor.go @@ -4,14 +4,161 @@ 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, @@ -26,7 +173,7 @@ func initCBORDecMode() (dm cbor.DecMode, err error) { decOpt := cbor.DecOptions{ IndefLength: cbor.IndefLengthAllowed, TimeTag: cbor.DecTagOptional, - DupMapKey: cbor.DupMapKeyEnforcedAPF, + DupMapKey: cbor.DupMapKeyEnforcedAPF, } return decOpt.DecMode() } 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/go.mod b/go.mod index 17c337c..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.6 // 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 c5e7b34..c857480 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ 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= diff --git a/submods.go b/submods.go index b22a329..77cb811 100644 --- a/submods.go +++ b/submods.go @@ -303,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/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) From d7d0e5e29046fbd271a563ace1651de79553534d Mon Sep 17 00:00:00 2001 From: Sergei Trofimov Date: Thu, 15 Oct 2026 16:59:08 +0100 Subject: [PATCH 3/3] chore: bump coverage Previous commit pushed coverage of arc/cmd to just below 90%. There is nothing that can be sensibly tested in the additions -- the drop is due to a switch that translates algorithm. To exercise other paths would require generating (valid!) keys that use those algorithms and adding them to test vars. Instead I'm bumping the coverage of the "root" command (which the culprit for low overall arc/cmd coverage) by exercising paths that are not used in other tests. These paths do not error, so there is nothing to check. The new test is purely to bump reported coverage for root.go (and therefore arc/cmd subpackage). Signed-off-by: Sergei Trofimov --- arc/cmd/root_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 arc/cmd/root_test.go 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() +}