diff --git a/eat.go b/eat.go index fc88a93..23c9163 100644 --- a/eat.go +++ b/eat.go @@ -4,7 +4,9 @@ package eat import ( + "encoding/base64" "encoding/json" + "fmt" ) // Eat is the internal representation of a EAT token @@ -14,7 +16,7 @@ type Eat struct { // TODO: support SUEIDs // TODO: support oemid-pem = int type OemID *[]byte `cbor:"258,keyasint,omitempty" json:"oemid,omitempty"` - HardwareModel *[]byte `cbor:"259,keyasint,omitempty" json:"hwmodel,omitempty"` + HardwareModel *B64Url `cbor:"259,keyasint,omitempty" json:"hwmodel,omitempty"` HardwareVersion *Version `cbor:"260,keyasint,omitempty" json:"hwversion,omitempty"` Uptime *uint `cbor:"261,keyasint,omitempty" json:"uptime,omitempty"` OemBoot *bool `cbor:"262,keyasint,omitempty" json:"oemboot,omitempty"` @@ -23,7 +25,7 @@ type Eat struct { Profile *Profile `cbor:"265,keyasint,omitempty" json:"eat-profile,omitempty"` Submods *Submods `cbor:"266,keyasint,omitempty" json:"submods,omitempty"` BootCount *uint `cbor:"267,keyasint,omitempty" json:"bootcount,omitempty"` - BootSeed *[]byte `cbor:"268,keyasint,omitempty" json:"bootseed,omitempty"` + BootSeed *B64Url `cbor:"268,keyasint,omitempty" json:"bootseed,omitempty"` // TODO: DLOAs SoftwareName *StringOrURI `cbor:"270,keyasint,omitempty" json:"swname,omitempty"` SoftwareVersion *Version `cbor:"271,keyasint,omitempty" json:"swversion,omitempty"` @@ -57,3 +59,37 @@ func (e *Eat) FromJSON(data []byte) error { func (e Eat) ToJSON() ([]byte, error) { return json.Marshal(e) } + +// B64Url is base64url (§5 of RFC4648) without padding. +// bstr MUST be base64url encoded as per EAT §7.2.2 "JSON Interoperability". +type B64Url []byte + +func (o B64Url) MarshalJSON() ([]byte, error) { + return json.Marshal( + base64.RawURLEncoding.EncodeToString(o), + ) +} + +func (b *B64Url) UnmarshalJSON(data []byte) error { + // get string body + var encoded string + if err := json.Unmarshal(data, &encoded); err != nil { + return err + } + + // while base64.RawURLEncoding.DecodeString("") returns + // no err, we need to return err because the CDDL definition is here, + // base64-url-text = tstr .regexp "[A-Za-z0-9_-]+" + if encoded == "" { + return fmt.Errorf("base64url must be a non-empty string") + } + + // decode base64url-encoded string + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return fmt.Errorf("base64url decode error: %w", err) + } + + *b = decoded + return nil +} diff --git a/eat_test.go b/eat_test.go index a8979af..d2d0eee 100644 --- a/eat_test.go +++ b/eat_test.go @@ -167,7 +167,7 @@ func TestEat_Full_RoundtripJSON(t *testing.T) { "lat": 12.34, "long": 56.78 }, - "ueid": "Ad6tvu/erb7v3q2+796tvu8=", + "ueid": "Ad6tvu_erb7v3q2-796tvu8", "uptime": 60, "iss": "Acme Inc.", "sub": "rr-trap", diff --git a/measured_component.go b/measured_component.go index 0f8576a..b91ebab 100644 --- a/measured_component.go +++ b/measured_component.go @@ -4,15 +4,16 @@ package eat import ( - "github.com/veraison/swid" + "encoding/json" + "fmt" ) type MeasuredComponent struct { - Id ComponentID `cbor:"1,keyasint" json:"id"` - Measurement *swid.HashEntry `cbor:"2,keyasint,omitempty" json:"measurement,omitempty"` - Signers *[][]byte `cbor:"3,keyasint,omitempty" json:"signers,omitempty"` - Flags *[]byte `cbor:"4,keyasint,omitempty" json:"flags,omitempty"` - RawMeasurement *[]byte `cbor:"5,keyasint,omitempty" json:"raw-measurement,omitempty"` + Id ComponentID `cbor:"1,keyasint" json:"id"` + Measurement *Digest `cbor:"2,keyasint,omitempty" json:"measurement,omitempty"` + Signers *[]B64Url `cbor:"3,keyasint,omitempty" json:"signers,omitempty"` + Flags *B64Url `cbor:"4,keyasint,omitempty" json:"flags,omitempty"` + RawMeasurement *B64Url `cbor:"5,keyasint,omitempty" json:"raw-measurement,omitempty"` } type ComponentID struct { @@ -20,3 +21,73 @@ type ComponentID struct { Name string `cbor:"0,keyasint"` Version *Version `cbor:"1,keyasint,omitempty"` } + +func (c *ComponentID) UnmarshalJSON(data []byte) error { + var tmp []json.RawMessage + if err := json.Unmarshal(data, &tmp); err != nil { + return fmt.Errorf("expected JSON array: %w", err) + } + + if len(tmp) < 1 || 2 < len(tmp) { + return fmt.Errorf("not component-id value: %#v", tmp) + } + if err := json.Unmarshal(tmp[0], &c.Name); err != nil { + return fmt.Errorf("invalid name: %w", err) + } + + if len(tmp) == 2 { + if err := json.Unmarshal(tmp[1], &c.Version); err != nil { + return fmt.Errorf("invalid version: %w", err) + } + } + + return nil +} + +func (c ComponentID) MarshalJSON() ([]byte, error) { + if c.Version == nil { + return json.Marshal([]string{c.Name}) + } + return json.Marshal([2]interface{}{c.Name, c.Version}) +} + +// Based on https://github.com/veraison/swid +// Digest does not support stringify() generating "sha-256;ABC..." +// and encodes/decodes the digest value with base64-url for JSON. +type Digest struct { + _ struct{} `cbor:",toarray"` + + // The number used as a value for hash-alg-id is an integer-based + // hash algorithm identifier who's value MUST refer to an ID in the + // IANA "Named Information Hash Algorithm Registry" [IANA.named-information] + // with a Status of "current" (at the time the generator software was built + // or later); other hash algorithms MUST NOT be used. If the hash-alg-id is + // not known, then the integer value "0" MUST be used. This allows for + // conversion from ISO SWID tags [SWID], which do not allow an algorithm to + // be identified for this field. + Alg int `cbor:"0,keyasint"` + + // The digest value will be base64-url encoded for JSON. + Val B64Url `cbor:"1,keyasint"` +} + +func (d *Digest) UnmarshalJSON(data []byte) error { + var tmp [2]json.RawMessage + if err := json.Unmarshal(data, &tmp); err != nil { + return fmt.Errorf("expected JSON array of length 2: %w", err) + } + + if err := json.Unmarshal(tmp[0], &d.Alg); err != nil { + return fmt.Errorf("invalid alg-id: %w", err) + } + + if err := json.Unmarshal(tmp[1], &d.Val); err != nil { + return fmt.Errorf("invalid base64url hash value: %w", err) + } + + return nil +} + +func (d Digest) MarshalJSON() ([]byte, error) { + return json.Marshal([2]interface{}{d.Alg, d.Val}) +} diff --git a/measured_component_test.go b/measured_component_test.go index ba16db3..7ba3768 100644 --- a/measured_component_test.go +++ b/measured_component_test.go @@ -1,12 +1,70 @@ package eat import ( + "encoding/json" "testing" + cbor "github.com/fxamacker/cbor/v2" "github.com/stretchr/testify/assert" ) -func TestMeasuredComponent(t *testing.T) { +var ( + expectedHashValue = B64Url{ + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + } +) + +func TestDigest_MarshalJSON_OK(t *testing.T) { + data := []byte(`[1,"3q2-796tvu_erb7v3q2-796tvu_erb7v3q2-796tvu8"]`) + + var digest Digest + assert.Nil(t, json.Unmarshal(data, &digest)) + assert.Equal(t, 1, digest.Alg) + assert.Equal(t, expectedHashValue, digest.Val) + + encoded, err := json.Marshal(digest) + assert.Nil(t, err) + assert.Equal(t, data, encoded) +} + +func TestDigest_MarshalCBOR_OK(t *testing.T) { + data := []byte{ + 0x82, // array(2) + 0x01, // unsigned(1) + 0x58, 0x20, // bytes(32) + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + } + + var digest Digest + assert.Nil(t, dm.Unmarshal(data, &digest)) + assert.Equal(t, 1, digest.Alg) + assert.Equal(t, expectedHashValue, digest.Val) + + encoded, err := cbor.Marshal(digest) + assert.Nil(t, err) + assert.Equal(t, data, encoded) +} + +func TestMeasuredComponent_MarshalJSON_OK(t *testing.T) { + data := []byte(`{"id":["Foo",["1.3.4","multipartnumeric"]],"measurement":[1,"3q2-796tvu_erb7v3q2-796tvu_erb7v3q2-796tvu8"]}`) + + var mc MeasuredComponent + assert.Nil(t, json.Unmarshal(data, &mc)) + assert.Equal(t, "Foo", mc.Id.Name) + assert.Equal(t, "1.3.4", mc.Id.Version.Version) + assert.Equal(t, 1, mc.Measurement.Alg) + assert.Equal(t, expectedHashValue, mc.Measurement.Val) + + encoded, err := json.Marshal(mc) + assert.Nil(t, err) + assert.Equal(t, data, encoded) +} + +func TestMeasuredComponent_MarshalCBOR_OK(t *testing.T) { data := []byte{ 0xA2, // map(2) 0x01, // unsigned(1) @@ -21,11 +79,19 @@ func TestMeasuredComponent(t *testing.T) { 0x82, // array(2) 0x01, // unsigned(1) 0x58, 0x20, // bytes(32) - 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, + 0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF, } var mc MeasuredComponent assert.Nil(t, dm.Unmarshal(data, &mc)) - assert.Equal(t, mc.Id.Name, "Foo") - assert.Equal(t, mc.Id.Version.Version, "1.3.4") + assert.Equal(t, "Foo", mc.Id.Name) + assert.Equal(t, "1.3.4", mc.Id.Version.Version) + assert.Equal(t, 1, mc.Measurement.Alg) + assert.Equal(t, expectedHashValue, mc.Measurement.Val) + + encoded, err := cbor.Marshal(mc) + assert.Nil(t, err) + assert.Equal(t, data, encoded) } diff --git a/measurement.go b/measurement.go index 33bb05d..1495fa5 100644 --- a/measurement.go +++ b/measurement.go @@ -6,5 +6,5 @@ package eat type Measurement struct { _ struct{} `cbor:",toarray"` // TODO: implement Unmarshal.JSON Type int // coap-content-format, see https://www.iana.org/assignments/core-parameters/core-parameters.xhtml - Format []byte // bstr wrapped untagged-coswid, measured-component, ... + Format B64Url // bstr wrapped untagged-coswid, measured-component, ... } diff --git a/measurement_test.go b/measurement_test.go index 947a445..d619549 100644 --- a/measurement_test.go +++ b/measurement_test.go @@ -12,7 +12,7 @@ import ( var ( measurementType = 258 - measurementFormat = []byte{ + measurementFormat = B64Url{ 0xa4, 0x00, 0x63, 0x66, 0x6f, 0x6f, 0x0c, 0x01, 0x01, 0x63, 0x62, 0x61, 0x72, 0x02, 0xa2, 0x18, 0x1f, 0x63, 0x62, 0x61, 0x7a, 0x18, 0x21, 0x82, 0x01, 0x02, diff --git a/nonce.go b/nonce.go index c94bb03..9caa2d8 100644 --- a/nonce.go +++ b/nonce.go @@ -194,6 +194,11 @@ func (ns *Nonce) UnmarshalCBOR(data []byte) error { // MarshalJSON encodes the receiver Nonce as either a JSON string containing // the base64 encoding of the binary nonce (if the array comprises only one // element) or as an array of base64-encoded JSON strings. +// +// NOTE: While RFC 9711 (EAT) does not restrict the nonce format to base64-encoded +// JSON strings, VERAISON/eat imposes a narrower interpretation for +// JSON <-> CBOR conversion. +// See discussion: https://github.com/ietf-rats-wg/eat/pull/421 func (ns Nonce) MarshalJSON() ([]byte, error) { if err := ns.Validate(); err != nil { return nil, fmt.Errorf("JSON encoding failed: %w", err) diff --git a/ueid.go b/ueid.go index 8d70cab..71ade45 100644 --- a/ueid.go +++ b/ueid.go @@ -3,7 +3,11 @@ package eat -import "fmt" +import ( + "encoding/base64" + "encoding/json" + "fmt" +) const ( UEIDTypeInvalid = iota @@ -81,3 +85,25 @@ func validateIMEI(value []byte) error { } return nil } + +// MarshalJSON encodes the UEID as a base64url string +func (u UEID) MarshalJSON() ([]byte, error) { + return json.Marshal( + base64.RawURLEncoding.EncodeToString(u), + ) +} + +// UnmarshalJSON decodes the supplied base64url string to an UEID +func (u *UEID) UnmarshalJSON(data []byte) error { + var s string + + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("JSON decoding failed for UEID: %w", err) + } + value, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return fmt.Errorf("%s", err.Error()) + } + *u = value + return nil +} diff --git a/ueid_test.go b/ueid_test.go index 1dddee8..c9d7ad9 100644 --- a/ueid_test.go +++ b/ueid_test.go @@ -4,6 +4,7 @@ package eat import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -61,3 +62,71 @@ func TestUEID_Verify(t *testing.T) { assert.EqualError(t, u7.Validate(), "invalid UEID type 255") } + +func TestUEID_JSONMarshal_OK(t *testing.T) { + e1 := UEID{ + 0x01, // RAND + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, // 16 bytes + } + s1 := []byte(`"Ad6tvu_erb7v3q2-796tvu8"`) + t1, err := json.Marshal(e1) + assert.Nil(t, err) + assert.Equal(t, s1, t1) + + var u1 UEID + err = json.Unmarshal(s1, &u1) + assert.Nil(t, err) + assert.Equal(t, e1, u1) + + e2 := UEID{ + 0x02, // EUI + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, // 6 bytes + } + s2 := []byte(`"At6tvu_erQ"`) + + t2, err := json.Marshal(e2) + assert.Nil(t, err) + assert.Equal(t, s2, t2) + + var u2 UEID + err = json.Unmarshal(s2, &u2) + assert.Nil(t, err) + assert.Equal(t, e2, u2) + + e3 := UEID{ + 0x03, // IMEI + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, // 14 bytes + } + s3 := []byte(`"A96tvu_erb7v3q2-796t"`) + + t3, err := json.Marshal(e3) + assert.Nil(t, err) + assert.Equal(t, s3, t3) + + var u3 UEID + err = json.Unmarshal(s3, &u3) + assert.Nil(t, err) + assert.Equal(t, e3, u3) +} + +func TestUEID_JSONUnmarshal_NG(t *testing.T) { + // not a string + s1 := []byte(`0`) + var u1 UEID + err := json.Unmarshal(s1, &u1) + assert.NotNil(t, err) + + // not base64 + s2 := []byte(`"&"`) + var u2 UEID + err = json.Unmarshal(s2, &u2) + assert.NotNil(t, err) + + // base64 but not base64url + s3 := []byte(`"A96tvu/erb7v3q2+796t"`) + var u3 UEID + err = json.Unmarshal(s3, &u3) + assert.NotNil(t, err) +}