Skip to content

Commit 07eaec5

Browse files
authored
Merge pull request #36 from peppelinux/utc-fix
fix: datetime verification evaluation about UTC in issuance and expiration
2 parents 22198bb + 92fcb33 commit 07eaec5

8 files changed

Lines changed: 173 additions & 30 deletions

File tree

‎pymdoccbor/mdoc/issuer.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from pymdoccbor.mdoc.exceptions import InvalidStatusDescriptor
1717
from pymdoccbor.mso.issuer import MsoIssuer
18+
from pymdoccbor.tools import thaw_cbor
1819

1920
logger = logging.getLogger("pymdoccbor")
2021

@@ -228,7 +229,8 @@ def new(
228229
ns: [v for k, v in dgst.items()]
229230
for ns, dgst in msoi.disclosure_map.items()
230231
},
231-
"issuerAuth": cbor2.loads(mso_cbor)
232+
# thaw_cbor: cbor2 >= 6 returns tuple/frozendict
233+
"issuerAuth": thaw_cbor(cbor2.loads(mso_cbor))
232234
},
233235
}
234236
],

‎pymdoccbor/mdoc/issuersigned.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ class IssuerSigned:
2424
]
2525
"""
2626

27-
def __init__(self, nameSpaces: dict, issuerAuth: Union[cbor2.CBORTag, dict, bytes]) -> None:
27+
def __init__(
28+
self,
29+
nameSpaces: dict,
30+
issuerAuth: Union[cbor2.CBORTag, list, tuple, dict, bytes],
31+
) -> None:
2832
"""
2933
Initialize the IssuerSigned object
3034
3135
:param nameSpaces: dict: the nameSpaces of the document
32-
:param issuerAuth: Union[dict, bytes]: the issuerAuth info of the document
36+
:param issuerAuth: COSE_Sign1 as list/tuple/bytes/CBORTag(18)
3337
"""
3438

3539
self.namespaces: dict = nameSpaces

‎pymdoccbor/mso/issuer.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,19 @@ def format_datetime_repr(self, dt: datetime.datetime) -> str:
164164
"""
165165
return dt.isoformat().split(".")[0] + "Z"
166166

167+
@staticmethod
168+
def parse_date(date_str: str, *, end_of_day: bool = False) -> datetime.datetime:
169+
"""
170+
Parse a date-only string (YYYY-MM-DD) into a datetime.
171+
172+
Date-only expiry values are interpreted as end-of-day so that
173+
validityInfo.signed remains within [validFrom, validUntil].
174+
"""
175+
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d")
176+
if end_of_day:
177+
dt = dt.replace(hour=23, minute=59, second=59)
178+
return dt
179+
167180
def sign(
168181
self,
169182
device_key: dict | None = None,
@@ -181,16 +194,12 @@ def sign(
181194
"""
182195

183196
utcnow = datetime.datetime.utcnow()
184-
valid_from = datetime.datetime.strptime(
185-
self.validity["issuance_date"], "%Y-%m-%d"
186-
)
197+
valid_from = self.parse_date(self.validity["issuance_date"])
187198

188199
if settings.PYMDOC_EXP_DELTA_HOURS:
189200
exp = utcnow + datetime.timedelta(hours=settings.PYMDOC_EXP_DELTA_HOURS)
190201
else:
191-
# five years
192-
exp = datetime.datetime.strptime(self.validity["expiry_date"], "%Y-%m-%d")
193-
# exp = utcnow + datetime.timedelta(hours=(24 * 365) * 5)
202+
exp = self.parse_date(self.validity["expiry_date"], end_of_day=True)
194203

195204
if utcnow > valid_from:
196205
valid_from = utcnow

‎pymdoccbor/mso/verifier.py‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import hashlib
33
import logging
44
from datetime import datetime, timezone
5-
from typing import Union
5+
from typing import Sequence, Union
66

77
import cbor2
88
import cryptography
@@ -31,30 +31,34 @@ class MsoVerifier:
3131
structure as defined in RFC 8152.
3232
"""
3333

34-
def __init__(self, data: Union[cbor2.CBORTag, bytes, list]) -> None:
34+
def __init__(
35+
self, data: Union[cbor2.CBORTag, bytes, list, tuple, Sequence]
36+
) -> None:
3537
"""
3638
Initialize the MsoParser object
3739
38-
:param data: Union[cbor2.CBORTag, bytes, list]: the data to parse
40+
:param data: Union[cbor2.CBORTag, bytes, list, tuple]: the data to parse
3941
"""
4042

4143
self._data = data
4244

4345
if isinstance(self._data, bytes):
4446
self.object: Sign1Message = bytes2CoseSign1(
4547
cbor2.dumps(cbor2.CBORTag(18, value=self._data)))
46-
elif isinstance(self._data, list):
48+
elif isinstance(self._data, (list, tuple)):
49+
# cbor2 >= 6 decodes arrays as tuple
4750
self.object: Sign1Message = cborlist2CoseSign1(self._data)
4851
elif isinstance(self._data, cbor2.CBORTag) and self._data.tag == 18:
49-
# COSE_Sign1 is CBOR tag 18; value can be list (decoded) or bytes
52+
# COSE_Sign1 is CBOR tag 18; value can be list/tuple (decoded) or bytes
5053
val = self._data.value
51-
if isinstance(val, list):
54+
if isinstance(val, (list, tuple)):
5255
self.object = cborlist2CoseSign1(val)
5356
else:
5457
self.object = bytes2CoseSign1(cbor2.dumps(self._data))
5558
else:
5659
raise UnsupportedMsoDataFormat(
57-
f"MsoParser only supports raw bytes, list, or CBORTag(18); got {type(data)}"
60+
f"MsoParser only supports raw bytes, list, tuple, or "
61+
f"CBORTag(18); got {type(data)}"
5862
)
5963

6064
self.object.key = None

‎pymdoccbor/tests/test_02_mdoc_issuer.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pymdoccbor.tests.cert_data import CERT_DATA
1212
from pymdoccbor.tests.pid_data import PID_DATA
1313
from pymdoccbor.tests.pkey import PKEY, PKEY_ED25519, PKEY_RSA
14+
from pymdoccbor.tools import bytes2CoseSign1
1415

1516

1617
def extract_mso(mdoc: dict):
@@ -39,7 +40,8 @@ def test_mso_writer():
3940

4041
mso = msoi.sign()
4142

42-
Sign1Message.decode(mso.encode())
43+
# Use our helper: pycose Sign1Message.decode breaks on cbor2 >= 6 (tuple)
44+
assert isinstance(bytes2CoseSign1(mso.encode()), Sign1Message)
4345

4446

4547
def test_mdoc_issuer():

‎pymdoccbor/tests/test_06_mso_issuer.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import datetime
2+
3+
import cbor2
14
from pycose.messages import CoseMessage
25

36
from pymdoccbor.mso.issuer import MsoIssuer
@@ -46,3 +49,33 @@ def test_mso_issuer_sign():
4649

4750
mso = msoi.sign()
4851
assert isinstance(mso, CoseMessage)
52+
53+
54+
def test_mso_issuer_validity_same_day():
55+
today = datetime.datetime.utcnow().strftime("%Y-%m-%d")
56+
msoi = MsoIssuer(
57+
data=MICOV_DATA,
58+
private_key=PKEY,
59+
validity={"issuance_date": today, "expiry_date": today},
60+
alg="ES256",
61+
cert_info=CERT_DATA,
62+
)
63+
64+
mso = msoi.sign()
65+
payload = cbor2.loads(mso.payload)
66+
mso_body = cbor2.loads(payload.value)
67+
validity = mso_body["validityInfo"]
68+
69+
def _as_utc(dt):
70+
if isinstance(dt, cbor2.CBORTag):
71+
dt = dt.value
72+
if isinstance(dt, str):
73+
return datetime.datetime.fromisoformat(dt.replace("Z", "+00:00"))
74+
return dt.replace(tzinfo=datetime.timezone.utc)
75+
76+
signed = _as_utc(validity["signed"])
77+
valid_from = _as_utc(validity["validFrom"])
78+
valid_until = _as_utc(validity["validUntil"])
79+
80+
assert valid_from <= signed <= valid_until
81+
assert valid_until.hour == 23 and valid_until.minute == 59 and valid_until.second == 59
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Compatibility with cbor2 >= 6 (immutable tuple / frozendict decode)."""
2+
import cbor2
3+
from pycose.messages import Sign1Message
4+
5+
from pymdoccbor.mdoc.issuer import MdocCborIssuer
6+
from pymdoccbor.mdoc.verifier import MobileDocument
7+
from pymdoccbor.mso.verifier import MsoVerifier
8+
from pymdoccbor.tests.cert_data import CERT_DATA
9+
from pymdoccbor.tests.micov_data import MICOV_DATA
10+
from pymdoccbor.tests.pkey import PKEY
11+
from pymdoccbor.tools import cborlist2CoseSign1, thaw_cbor
12+
13+
14+
def test_thaw_cbor_tuple_and_mapping():
15+
tagged = cbor2.dumps(cbor2.CBORTag(18, [b"\xa1\x01&", {33: b"cert"}, b"pay", b"sig"]))
16+
loaded = cbor2.loads(tagged)
17+
# cbor2 5 -> list/dict; cbor2 6 -> tuple/frozendict
18+
thawed = thaw_cbor(loaded)
19+
assert isinstance(thawed, cbor2.CBORTag)
20+
assert isinstance(thawed.value, list)
21+
assert isinstance(thawed.value[1], dict)
22+
assert thawed.value[1][33] == b"cert"
23+
24+
25+
def test_cborlist2CoseSign1_accepts_tuple():
26+
cose_tuple = (b"\xa1\x01&", {33: b"cert"}, b"pay", b"sig")
27+
msg = cborlist2CoseSign1(cose_tuple)
28+
assert isinstance(msg, Sign1Message)
29+
assert msg.payload == b"pay"
30+
31+
32+
def test_mso_verifier_with_cbor2_loads_roundtrip():
33+
mdoc = MdocCborIssuer(private_key=PKEY, alg="ES256", cert_info=CERT_DATA)
34+
mdoc.new(
35+
data=MICOV_DATA,
36+
doctype="org.micov.medical.1",
37+
validity={"issuance_date": "2024-12-31", "expiry_date": "2050-12-31"},
38+
)
39+
issuer_auth = mdoc.signed["documents"][0]["issuerSigned"]["issuerAuth"]
40+
assert isinstance(issuer_auth, list)
41+
42+
# Simulate cbor2 >= 6 decode of untagged COSE_Sign1
43+
reloaded = cbor2.loads(cbor2.dumps(issuer_auth))
44+
msov = MsoVerifier(reloaded)
45+
assert isinstance(msov.object, Sign1Message)
46+
assert msov.verify_signature()
47+
48+
49+
def test_mobile_document_from_issuer_signed():
50+
mdoc = MdocCborIssuer(private_key=PKEY, alg="ES256", cert_info=CERT_DATA)
51+
mdoc.new(
52+
data=MICOV_DATA,
53+
doctype="org.micov.medical.1",
54+
validity={"issuance_date": "2024-12-31", "expiry_date": "2050-12-31"},
55+
)
56+
document = mdoc.signed["documents"][0]
57+
doc = MobileDocument(**document)
58+
assert doc.doctype == "org.micov.medical.1"
59+
assert doc.errors == {}

‎pymdoccbor/tools.py‎

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,68 @@
11
# Aligns with https://github.com/eu-digital-identity-wallet/pyMDOC-CBOR
22
import json
33
import random
4+
from collections.abc import Mapping, Sequence
5+
from typing import Any, Union
46

57
import cbor2
68
from cbor2.tool import DefaultEncoder, key_to_str
79
from pycose.messages import Sign1Message
810

911

12+
def thaw_cbor(obj: Any) -> Any:
13+
"""
14+
Convert cbor2 6.x immutable decode results into mutable structures.
15+
16+
cbor2 >= 6 returns tuple for arrays and frozendict for maps. pycose 1.x
17+
requires a mutable list (it uses .pop) and a plain dict for COSE headers.
18+
"""
19+
if isinstance(obj, cbor2.CBORTag):
20+
return cbor2.CBORTag(obj.tag, thaw_cbor(obj.value))
21+
if isinstance(obj, Mapping):
22+
return {thaw_cbor(k): thaw_cbor(v) for k, v in obj.items()}
23+
if isinstance(obj, tuple):
24+
return [thaw_cbor(item) for item in obj]
25+
if isinstance(obj, list):
26+
return [thaw_cbor(item) for item in obj]
27+
return obj
28+
29+
30+
def _cose_sign1_from_obj(cose_obj: Any) -> Sign1Message:
31+
"""Build a Sign1Message from a decoded COSE_Sign1 array (list or tuple)."""
32+
cose_obj = thaw_cbor(cose_obj)
33+
if not isinstance(cose_obj, list):
34+
raise TypeError("Bytes cannot be decoded as COSE message")
35+
return Sign1Message.from_cose_obj(cose_obj, True)
36+
37+
1038
def bytes2CoseSign1(data: bytes) -> Sign1Message:
1139
"""
1240
Gets bytes and return a COSE_Sign1 object
1341
14-
:param data: bytes: the COSE Sign1 as bytes
42+
:param data: bytes: the COSE Sign1 as bytes (optionally CBOR-tagged 18)
1543
:return: Sign1Message: the COSE Sign1 object
1644
"""
17-
decoded = Sign1Message.decode(cbor2.loads(data).value)
45+
loaded = cbor2.loads(data)
46+
if isinstance(loaded, cbor2.CBORTag):
47+
cose_obj = loaded.value
48+
else:
49+
cose_obj = loaded
50+
51+
# Tag value may itself be the encoded array as bytes
52+
if isinstance(cose_obj, (bytes, bytearray)):
53+
cose_obj = cbor2.loads(cose_obj)
1854

19-
return decoded
55+
return _cose_sign1_from_obj(cose_obj)
2056

2157

22-
def cborlist2CoseSign1(data: list) -> Sign1Message:
58+
def cborlist2CoseSign1(data: Union[list, tuple, Sequence]) -> Sign1Message:
2359
"""
24-
Gets cbor2 decoded COSE Sign1 as a list and return a COSE_Sign1 object
60+
Gets cbor2 decoded COSE Sign1 as a list/tuple and return a COSE_Sign1 object
2561
26-
:param data: list: the COSE Sign1 as a list
62+
:param data: list | tuple: the COSE Sign1 as a decoded array
2763
:return: Sign1Message: the COSE Sign1 object
2864
"""
29-
decoded = Sign1Message.decode(
30-
cbor2.dumps(
31-
cbor2.CBORTag(18, value=data)
32-
)
33-
)
34-
35-
return decoded
65+
return _cose_sign1_from_obj(data)
3666

3767

3868
def pretty_print(cbor_loaded: dict) -> None:

0 commit comments

Comments
 (0)