Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `DeleteAll` now also clears the EUDI storage and database contents
- `yivi` top-level command line tool that wraps the existing `irma` command (container image published as `ghcr.io/privacybydesign/yivi`); the existing `irma` command remains available as `yivi irma ...`
- Disclosure UI improvements: support for attribute group headers, credential images shown during disclosure, and a more reliable selection of the requestor's display name
- ISO/IEC 18013-5 mdoc CBOR/COSE foundation in the new `eudi/credentials/mdoc` package: canonical CBOR encode/decode helpers, the tag 24 `EncodedCBOR`, tag 1004 `FullDate` and tag 0 `DateTime` types, COSE_Key (EC2/P-256) ⇄ ECDSA ⇄ JWK conversion, and `COSE_Sign1` sign/verify wrappers with `x5chain` support (validated against the ISO 18013-5 Annex D test vectors)

### Changed
- Requests using `irma.HTTPTransport` have a doubled response timeout (20 seconds) to accommodate for slow and/or foreign connections
Expand Down
211 changes: 211 additions & 0 deletions eudi/credentials/mdoc/cbor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Package mdoc implements the low-level CBOR/COSE primitives required for
// ISO/IEC 18013-5 mobile documents (mdoc / mDL). It is the foundation that the
// mdoc data model, issuance and disclosure code build on.
//
// This file contains the CBOR encoding/decoding helpers. mdoc mandates
// deterministic (canonical) CBOR, so a single pair of fixed encode/decode modes
// is used throughout the package. It also defines the small set of tagged types
// that appear in the mdoc data model:
//
// - EncodedCBOR: tag 24, a byte string wrapping nested, already-encoded CBOR
// (#6.24(bstr .cbor X)). Used pervasively in 18013-5 so that nested
// structures can be digested and signed byte-exactly.
// - FullDate: tag 1004 (RFC 8943), a "full-date" text string "YYYY-MM-DD".
// - DateTime: tag 0 (RFC 8949 §3.4.1), an RFC 3339 "date-time" text string,
// encoded in UTC with no fractional seconds as required by 18013-5.
package mdoc

import (
"fmt"
"time"

"github.com/fxamacker/cbor/v2"
)

// CBOR tag numbers used by ISO/IEC 18013-5.
const (
// tagDateTime is the standard date/time tag (RFC 8949 §3.4.1).
tagDateTime uint64 = 0
// tagEncodedCBOR wraps a byte string holding embedded CBOR (RFC 8949 §3.4.5.1).
tagEncodedCBOR uint64 = 24
// tagFullDate is the "full-date" tag from RFC 8943.
tagFullDate uint64 = 1004
)

var (
encMode cbor.EncMode
decMode cbor.DecMode
)

func init() {
// ISO/IEC 18013-5 §9.1.2 requires canonical CBOR encoding (RFC 7049 §3.9):
// definite-length items, shortest-form integers and length-first ("canonical")
// map key ordering. fxamacker's CanonicalEncOptions encodes exactly that.
var err error
encMode, err = cbor.CanonicalEncOptions().EncMode()
if err != nil {
panic(fmt.Sprintf("mdoc: failed to build CBOR encode mode: %v", err))
}

// Decoding stays deliberately lenient (input arrives from issuers and
// verifiers): default options, but reject indefinite-length items which are
// not allowed by the mdoc profile and complicate digest recomputation.
decMode, err = cbor.DecOptions{
IndefLength: cbor.IndefLengthForbidden,
}.DecMode()
if err != nil {
panic(fmt.Sprintf("mdoc: failed to build CBOR decode mode: %v", err))
}
}

// MarshalCBOR encodes v using the canonical mdoc CBOR encoding.
func MarshalCBOR(v any) ([]byte, error) {
return encMode.Marshal(v)
}

// UnmarshalCBOR decodes mdoc CBOR from data into v.
func UnmarshalCBOR(data []byte, v any) error {
return decMode.Unmarshal(data, v)
}

// EncodeMode exposes the canonical mdoc CBOR encode mode, e.g. for callers that
// need to drive an encoder directly.
func EncodeMode() cbor.EncMode { return encMode }

// DecodeMode exposes the mdoc CBOR decode mode.
func DecodeMode() cbor.DecMode { return decMode }

// EncodedCBOR is the #6.24(bstr .cbor) construct: a byte string, tagged with 24,
// whose contents are themselves a complete, already-encoded CBOR data item. The
// raw embedded bytes are preserved verbatim so that digests and signatures over
// them remain byte-exact.
type EncodedCBOR struct {
// Data holds the embedded, already-encoded CBOR bytes (the inner data item).
Data []byte
}

// NewEncodedCBOR canonically encodes v and wraps the result as an EncodedCBOR.
func NewEncodedCBOR(v any) (EncodedCBOR, error) {
data, err := encMode.Marshal(v)
if err != nil {
return EncodedCBOR{}, err
}
return EncodedCBOR{Data: data}, nil
}

// DecodeInto decodes the embedded CBOR into v.
func (e EncodedCBOR) DecodeInto(v any) error {
return decMode.Unmarshal(e.Data, v)
}

// MarshalCBOR implements cbor.Marshaler, emitting #6.24(bstr) around the
// embedded bytes.
func (e EncodedCBOR) MarshalCBOR() ([]byte, error) {
if e.Data == nil {
return nil, fmt.Errorf("mdoc: EncodedCBOR has no data")
}
return encMode.Marshal(cbor.Tag{Number: tagEncodedCBOR, Content: e.Data})
}

// UnmarshalCBOR implements cbor.Unmarshaler, expecting #6.24(bstr).
func (e *EncodedCBOR) UnmarshalCBOR(data []byte) error {
var raw cbor.RawTag
if err := decMode.Unmarshal(data, &raw); err != nil {
return err
}
if raw.Number != tagEncodedCBOR {
return fmt.Errorf("mdoc: expected tag %d (EncodedCBOR), got %d", tagEncodedCBOR, raw.Number)
}
var inner []byte
if err := decMode.Unmarshal(raw.Content, &inner); err != nil {
return fmt.Errorf("mdoc: EncodedCBOR content is not a byte string: %w", err)
}
e.Data = inner
return nil
}

// FullDate is a calendar date with no time-of-day component, encoded as a
// tag 1004 (RFC 8943) "full-date" text string in "YYYY-MM-DD" form.
type FullDate time.Time

const fullDateLayout = "2006-01-02"

// NewFullDate builds a FullDate from a time.Time, discarding the time of day.
func NewFullDate(t time.Time) FullDate { return FullDate(t) }

// Time returns the underlying time.Time.
func (d FullDate) Time() time.Time { return time.Time(d) }

// String returns the full-date representation.
func (d FullDate) String() string { return time.Time(d).Format(fullDateLayout) }

// MarshalCBOR implements cbor.Marshaler.
func (d FullDate) MarshalCBOR() ([]byte, error) {
return encMode.Marshal(cbor.Tag{Number: tagFullDate, Content: time.Time(d).Format(fullDateLayout)})
}

// UnmarshalCBOR implements cbor.Unmarshaler.
func (d *FullDate) UnmarshalCBOR(data []byte) error {
var raw cbor.RawTag
if err := decMode.Unmarshal(data, &raw); err != nil {
return err
}
if raw.Number != tagFullDate {
return fmt.Errorf("mdoc: expected tag %d (full-date), got %d", tagFullDate, raw.Number)
}
var s string
if err := decMode.Unmarshal(raw.Content, &s); err != nil {
return fmt.Errorf("mdoc: full-date content is not a text string: %w", err)
}
t, err := time.Parse(fullDateLayout, s)
if err != nil {
return fmt.Errorf("mdoc: invalid full-date %q: %w", s, err)
}
*d = FullDate(t)
return nil
}

// DateTime is a point in time encoded as a tag 0 (RFC 8949 §3.4.1) RFC 3339
// "date-time" text string. Per ISO/IEC 18013-5 it is always serialised in UTC
// with second precision and no fractional seconds.
type DateTime time.Time

const dateTimeLayout = "2006-01-02T15:04:05Z"

// NewDateTime builds a DateTime from a time.Time.
func NewDateTime(t time.Time) DateTime { return DateTime(t) }

// Time returns the underlying time.Time.
func (t DateTime) Time() time.Time { return time.Time(t) }

// String returns the RFC 3339 representation used on the wire.
func (t DateTime) String() string {
return time.Time(t).UTC().Truncate(time.Second).Format(dateTimeLayout)
}

// MarshalCBOR implements cbor.Marshaler.
func (t DateTime) MarshalCBOR() ([]byte, error) {
s := time.Time(t).UTC().Truncate(time.Second).Format(dateTimeLayout)
return encMode.Marshal(cbor.Tag{Number: tagDateTime, Content: s})
}

// UnmarshalCBOR implements cbor.Unmarshaler.
func (t *DateTime) UnmarshalCBOR(data []byte) error {
var raw cbor.RawTag
if err := decMode.Unmarshal(data, &raw); err != nil {
return err
}
if raw.Number != tagDateTime {
return fmt.Errorf("mdoc: expected tag %d (date-time), got %d", tagDateTime, raw.Number)
}
var s string
if err := decMode.Unmarshal(raw.Content, &s); err != nil {
return fmt.Errorf("mdoc: date-time content is not a text string: %w", err)
}
parsed, err := time.Parse(time.RFC3339, s)
if err != nil {
return fmt.Errorf("mdoc: invalid date-time %q: %w", s, err)
}
*t = DateTime(parsed.UTC())
return nil
}
170 changes: 170 additions & 0 deletions eudi/credentials/mdoc/cbor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package mdoc

import (
"bytes"
"encoding/hex"
"testing"
"time"

"github.com/fxamacker/cbor/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCanonicalEncodingIsDeterministic(t *testing.T) {
// A map whose keys differ in length exercises the length-first ("canonical")
// ordering: the single-character keys must sort before the longer one,
// regardless of insertion order.
in := map[string]int{
"org.iso.18013.5.1.US": 3,
"a": 1,
"bb": 2,
}

first, err := MarshalCBOR(in)
require.NoError(t, err)

// Re-encoding must produce byte-identical output.
for i := 0; i < 16; i++ {
again, err := MarshalCBOR(in)
require.NoError(t, err)
require.True(t, bytes.Equal(first, again), "canonical encoding must be stable")
}

// Canonical order is length-first then bytewise: "a", "bb", "org...".
expected, err := hex.DecodeString("a3" +
"6161" + "01" + // "a": 1
"626262" + "02" + // "bb": 2
"74" + "6f72672e69736f2e31383031332e352e312e5553" + "03") // "org.iso.18013.5.1.US": 3
require.NoError(t, err)
assert.Equal(t, expected, first)
}

func TestEncodedCBORRoundTrip(t *testing.T) {
type inner struct {
Name string `cbor:"name"`
N int `cbor:"n"`
}
original := inner{Name: "alice", N: 42}

wrapped, err := NewEncodedCBOR(original)
require.NoError(t, err)

encoded, err := MarshalCBOR(wrapped)
require.NoError(t, err)

// The outer item must be a tag 24 (0xd818) wrapping a byte string.
require.GreaterOrEqual(t, len(encoded), 2)
assert.Equal(t, byte(0xd8), encoded[0])
assert.Equal(t, byte(0x18), encoded[1])

var decoded EncodedCBOR
require.NoError(t, UnmarshalCBOR(encoded, &decoded))
assert.Equal(t, wrapped.Data, decoded.Data)

var got inner
require.NoError(t, decoded.DecodeInto(&got))
assert.Equal(t, original, got)
}

func TestEncodedCBORByteExact(t *testing.T) {
// #6.24(bstr) of an empty CBOR map {} (0xa0): d818 41 a0.
wrapped, err := NewEncodedCBOR(map[string]any{})
require.NoError(t, err)

encoded, err := MarshalCBOR(wrapped)
require.NoError(t, err)

expected, err := hex.DecodeString("d81841a0")
require.NoError(t, err)
assert.Equal(t, expected, encoded)
}

func TestEncodedCBORRejectsWrongTag(t *testing.T) {
// A plain byte string (not tagged with 24) must be rejected.
plain, err := MarshalCBOR([]byte{0x01, 0x02})
require.NoError(t, err)

var decoded EncodedCBOR
assert.Error(t, UnmarshalCBOR(plain, &decoded))
}

func TestFullDateRoundTrip(t *testing.T) {
d := NewFullDate(time.Date(2019, time.October, 20, 13, 30, 0, 0, time.UTC))

encoded, err := MarshalCBOR(d)
require.NoError(t, err)

// tag 1004 (0xd903ec) + text string "2019-10-20".
expected, err := hex.DecodeString("d903ec" + "6a" + hex.EncodeToString([]byte("2019-10-20")))
require.NoError(t, err)
assert.Equal(t, expected, encoded)

var got FullDate
require.NoError(t, UnmarshalCBOR(encoded, &got))
assert.Equal(t, "2019-10-20", got.String())
}

func TestDateTimeRoundTrip(t *testing.T) {
// Fractional seconds and a non-UTC zone must be normalised away.
loc := time.FixedZone("CET", 3600)
dt := NewDateTime(time.Date(2020, time.October, 1, 14, 30, 2, 500_000_000, loc))

encoded, err := MarshalCBOR(dt)
require.NoError(t, err)

// tag 0 (0xc0) + text string "2020-10-01T13:30:02Z" (UTC, no fractional secs).
expected, err := hex.DecodeString("c0" + "74" + hex.EncodeToString([]byte("2020-10-01T13:30:02Z")))
require.NoError(t, err)
assert.Equal(t, expected, encoded)

var got DateTime
require.NoError(t, UnmarshalCBOR(encoded, &got))
assert.True(t, got.Time().Equal(time.Date(2020, time.October, 1, 13, 30, 2, 0, time.UTC)))
}

func TestDateTimeMatchesAnnexDValidityInfo(t *testing.T) {
// The validityInfo "signed" value from the Annex D MSO is tag 0 with
// "2020-10-01T13:30:02Z"; confirm we produce the identical encoding.
signed := NewDateTime(time.Date(2020, time.October, 1, 13, 30, 2, 0, time.UTC))
encoded, err := MarshalCBOR(signed)
require.NoError(t, err)

expected, err := hex.DecodeString("c074323032302d31302d30315431333a33303a30325a")
require.NoError(t, err)
assert.Equal(t, expected, encoded)
}

func TestWrongTagTypeRejected(t *testing.T) {
var fd FullDate
dt, err := MarshalCBOR(NewDateTime(time.Now()))
require.NoError(t, err)
// A tag-0 date-time must not decode as a tag-1004 full-date.
assert.Error(t, UnmarshalCBOR(dt, &fd))
}

func TestIndefiniteLengthRejected(t *testing.T) {
// 0x9f ... 0xff is an indefinite-length array, forbidden by the mdoc profile.
indef, err := hex.DecodeString("9f01ff")
require.NoError(t, err)
var v []int
assert.Error(t, UnmarshalCBOR(indef, &v))

// Sanity check: the definite-length form decodes fine.
def, err := hex.DecodeString("8101")
require.NoError(t, err)
require.NoError(t, UnmarshalCBOR(def, &v))
assert.Equal(t, []int{1}, v)
}

// TestCanonicalModeMatchesFxamacker guards against accidental drift from the
// documented canonical option set.
func TestCanonicalModeMatchesFxamacker(t *testing.T) {
want, err := cbor.CanonicalEncOptions().EncMode()
require.NoError(t, err)
a, err := want.Marshal(map[string]int{"a": 1, "bb": 2})
require.NoError(t, err)
b, err := MarshalCBOR(map[string]int{"a": 1, "bb": 2})
require.NoError(t, err)
assert.Equal(t, a, b)
}
Loading
Loading