From 17aab9a6f38c8b5e26364839b956018e237eaa33 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Thu, 25 Jul 2024 21:08:20 +0200 Subject: [PATCH 1/6] Add dataclasses for CHUID and FASC-N --- tests/test_piv.py | 73 ++++++++++++++++++++++- ykman/piv.py | 27 ++++----- yubikit/piv.py | 143 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 17 deletions(-) diff --git a/tests/test_piv.py b/tests/test_piv.py index 461612b8..fbc97710 100644 --- a/tests/test_piv.py +++ b/tests/test_piv.py @@ -1,4 +1,8 @@ -from ykman.piv import generate_random_management_key, parse_rfc4514_string +from ykman.piv import ( + generate_random_management_key, + parse_rfc4514_string, + generate_chuid, +) from yubikit.core import NotSupportedError, Version from yubikit.piv import ( @@ -7,7 +11,10 @@ PIN_POLICY, TOUCH_POLICY, _do_check_key_support, + FascN, + Chuid, ) +from datetime import date import pytest @@ -96,3 +103,67 @@ def test_supported_algorithms(self): _do_check_key_support( Version(5, 7, 0), key_type, PIN_POLICY.DEFAULT, TOUCH_POLICY.DEFAULT ) + + +def test_fascn(): + fascn = FascN( + agency_code=32, + system_code=1, + credential_number=92446, + credential_series=0, + individual_credential_issue=1, + person_identifier=1112223333, + organizational_category=1, + organizational_identifier=1223, + organization_association_category=2, + ) + + # https://www.idmanagement.gov/docs/pacs-tig-scepacs.pdf + # page 32 + expected = bytes.fromhex("D0439458210C2C19A0846D83685A1082108CE73984108CA3FC") + assert bytes(fascn) == expected + + assert FascN.from_bytes(expected) == fascn + + +def test_chuid(): + guid = b"x" * 16 + chuid = Chuid( + # Non-Federal Issuer FASC-N + fasc_n=FascN(9999, 9999, 999999, 0, 1, 0000000000, 3, 0000, 1), + guid=guid, + expiration_date=date(2030, 1, 1), + asymmetric_signature=b"", + ) + + expected = bytes.fromhex( + "3019d4e739da739ced39ce739d836858210842108421c84210c3eb3410787878787878787878" + "78787878787878350832303330303130313e00fe00" + ) + + assert bytes(chuid) == expected + + assert Chuid.from_bytes(expected) == chuid + + +def test_chuid_deserialize(): + chuid = Chuid( + buffer_length=123, + fasc_n=FascN(9999, 9999, 999999, 0, 1, 0000000000, 3, 0000, 1), + agency_code=b"1234", + organizational_identifier=b"5678", + duns=b"123456789", + guid=b"x" * 16, + expiration_date=date(2030, 1, 1), + authentication_key_map=b"1234567890", + asymmetric_signature=b"0987654321", + lrc=255, + ) + + assert Chuid.from_bytes(bytes(chuid)) == chuid + + +def test_chuid_generate(): + chuid = Chuid.from_bytes(generate_chuid()) + assert chuid.expiration_date == date(2030, 1, 1) + assert chuid.fasc_n.agency_code == 9999 diff --git a/ykman/piv.py b/ykman/piv.py index 632dad7e..d86c85f5 100644 --- a/ykman/piv.py +++ b/ykman/piv.py @@ -37,6 +37,8 @@ ALGORITHM, TAG_LRC, SlotMetadata, + FascN, + Chuid, ) from .util import display_serial @@ -48,7 +50,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.backends import default_backend from cryptography.x509.oid import NameOID -from datetime import datetime +from datetime import datetime, date import logging import struct import os @@ -468,23 +470,18 @@ def check_key( def generate_chuid() -> bytes: """Generate a CHUID (Cardholder Unique Identifier).""" - # Non-Federal Issuer FASC-N - # [9999-9999-999999-0-1-0000000000300001] - FASC_N = ( - b"\xd4\xe7\x39\xda\x73\x9c\xed\x39\xce\x73\x9d\x83\x68" - + b"\x58\x21\x08\x42\x10\x84\x21\xc8\x42\x10\xc3\xeb" - ) - # Expires on: 2030-01-01 - EXPIRY = b"\x32\x30\x33\x30\x30\x31\x30\x31" - return ( - Tlv(0x30, FASC_N) - + Tlv(0x34, os.urandom(16)) - + Tlv(0x35, EXPIRY) - + Tlv(0x3E) - + Tlv(TAG_LRC) + chuid = Chuid( + # Non-Federal Issuer FASC-N + fasc_n=FascN(9999, 9999, 999999, 0, 1, 0000000000, 3, 0000, 1), + guid=os.urandom(16), + # Expires on: 2030-01-01 + expiration_date=date(2030, 1, 1), + asymmetric_signature=b"", ) + return bytes(chuid) + def generate_ccc() -> bytes: """Generate a CCC (Card Capability Container).""" diff --git a/yubikit/piv.py b/yubikit/piv.py index aae183fe..71f74ae7 100755 --- a/yubikit/piv.py +++ b/yubikit/piv.py @@ -59,7 +59,8 @@ from cryptography.hazmat.primitives.asymmetric.utils import Prehashed from cryptography.hazmat.backends import default_backend -from dataclasses import dataclass +from datetime import date +from dataclasses import dataclass, astuple from enum import Enum, IntEnum, unique from typing import Optional, Union, Type, cast, overload @@ -393,6 +394,146 @@ class BioMetadata: temporary_pin: bool +def _bcd(val, ln=1): + bits = f"{val % 10:04b}"[::-1] + bits += str((bits.count("1") + 1) % 2) + return bits if ln == 1 else _bcd(val // 10, ln - 1) + bits + + +BCD_SS = "11010" +BCD_FS = "10110" +BCD_ES = "11111" + +_FASCN_LENS = (4, 4, 6, 1, 1, 10, 1, 4, 1) + + +@dataclass +class FascN: + """FASC-N data structure + + https://www.idmanagement.gov/docs/pacs-tig-scepacs.pdf + """ + + agency_code: int # 4 digits + system_code: int # 4 digits + credential_number: int # 6 digits + credential_series: int # 1 digit + individual_credential_issue: int # 1 digit + person_identifier: int # 10 digits + organizational_category: int # 1 digit + organizational_identifier: int # 4 digits + organization_association_category: int # 1 digit + + def __bytes__(self): + # Convert values to BCD + vs = iter(_bcd(v, ln) for v, ln in zip(astuple(self), _FASCN_LENS)) + + # Add separators + bs = ( + BCD_SS + + next(vs) + + BCD_FS + + next(vs) + + BCD_FS + + next(vs) + + BCD_FS + + next(vs) + + BCD_FS + + next(vs) + + BCD_FS + + next(vs) + + next(vs) + + next(vs) + + next(vs) + + BCD_ES + ) + + # Calculate LRC + lrc = 0 + for i in range(0, len(bs), 5): + lrc ^= int(bs[i : i + 5], 2) + + return int2bytes(int(bs, 2) << 5 | lrc) + + @classmethod + def from_bytes(cls, value: bytes) -> "FascN": + bs = f"{bytes2int(value):0200b}" + ds = [int(bs[i : i + 4][::-1], 2) for i in range(0, 200, 5)] + args = ( + int("".join(str(d) for d in ds[offs : offs + ln])) + # offsets considering separators + for offs, ln in zip((1, 6, 11, 18, 20, 22, 32, 33, 37), _FASCN_LENS) + ) + return cls(*args) + + def __str__(self): + return "[%04d-%04d-%06d-%d-%d-%010d%d%04d%d]" % astuple(self) + + +# From Python 3.10 we can use kw_only instead +_chuid_no_value = object() + + +@dataclass +class Chuid: + buffer_length: Optional[int] = None + fasc_n: FascN = cast(FascN, _chuid_no_value) + agency_code: Optional[bytes] = None + organizational_identifier: Optional[bytes] = None + duns: Optional[bytes] = None + guid: bytes = cast(bytes, _chuid_no_value) + expiration_date: date = cast(date, _chuid_no_value) + authentication_key_map: Optional[bytes] = None + asymmetric_signature: bytes = cast(bytes, _chuid_no_value) + lrc: Optional[int] = None + + def __post_init__(self): + if _chuid_no_value in ( + self.fasc_n, + self.guid, + self.expiration_date, + self.asymmetric_signature, + ): + raise ValueError("Missing required field(s)") + + def __bytes__(self): + bs = b"" + if self.buffer_length is not None: + bs += Tlv(0xEE, int2bytes(self.buffer_length)) + bs += Tlv(0x30, bytes(self.fasc_n)) + if self.agency_code is not None: + bs += Tlv(0x31, self.agency_code) + if self.organizational_identifier is not None: + bs += Tlv(0x32, self.organizational_identifier) + if self.duns is not None: + bs += Tlv(0x33, self.duns) + bs += Tlv(0x34, self.guid) + bs += Tlv(0x35, self.expiration_date.isoformat().replace("-", "").encode()) + if self.authentication_key_map is not None: + bs += Tlv(0x3D, self.authentication_key_map) + bs += Tlv(0x3E, self.asymmetric_signature) + bs += Tlv(TAG_LRC, bytes([self.lrc]) if self.lrc is not None else b"") + return bs + + @classmethod + def from_bytes(cls, value: bytes) -> "Chuid": + data = Tlv.parse_dict(value) + buffer_length = data.get(0xEE) + lrc = data.get(TAG_LRC) + return cls( + buffer_length=bytes2int(buffer_length) if buffer_length else None, + fasc_n=FascN.from_bytes(data[0x30]), + agency_code=data.get(0x31), + organizational_identifier=data.get(0x32), + duns=data.get(0x33), + guid=data[0x34], + expiration_date=date.fromisoformat(data[0x35].decode()), + authentication_key_map=data.get(0x3D), + asymmetric_signature=data[0x3E], + lrc=lrc[0] if lrc else None, + ) + + def _pad_message(key_type, message, hash_algorithm, padding): if key_type in (KEY_TYPE.ED25519, KEY_TYPE.X25519): return message From 1b567c27b8e32f6d1db95772e389bafcc6851209 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Tue, 6 Aug 2024 11:17:58 +0200 Subject: [PATCH 2/6] PIV: Update existing unsigned CHUID instead of replace --- ykman/_cli/piv.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/ykman/_cli/piv.py b/ykman/_cli/piv.py index 8501cc41..e25b2ce2 100644 --- a/ykman/_cli/piv.py +++ b/ykman/_cli/piv.py @@ -38,6 +38,7 @@ PIN_POLICY, TOUCH_POLICY, DEFAULT_MANAGEMENT_KEY, + Chuid, ) from yubikit.core.smartcard import ApduError, SW @@ -79,6 +80,7 @@ from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.backends import default_backend +import os import click import datetime import logging @@ -960,6 +962,30 @@ def cert(): """ +def _update_chuid(session): + try: + chuid_data = session.get_object(OBJECT_ID.CHUID) + try: + chuid = Chuid.from_bytes(chuid_data) + except ValueError: + logger.debug("Leaving unparsable CHUID as-is.") + return + if chuid.asymmetric_signature: + # Signed CHUID, leave it alone + logger.debug("Leaving signed CHUID as-is.") + return + chuid.guid = os.urandom(16) + chuid_data = bytes(chuid) + logger.debug("Updating CHUID GUID.") + except ApduError as e: + if e.sw == SW.FILE_NOT_FOUND: + logger.debug("Generating new CHUID.") + chuid_data = generate_chuid() + else: + raise + session.put_object(OBJECT_ID.CHUID, chuid_data) + + @cert.command("import") @click.pass_context @click_management_key_option @@ -1054,7 +1080,7 @@ def do_verify(): _verify_pin_if_needed(ctx, session, do_verify, pin) session.put_certificate(slot, cert_to_import, compress) - session.put_object(OBJECT_ID.CHUID, generate_chuid()) + _update_chuid(session) click.echo(f"Certificate imported into slot {slot.name}") @@ -1157,7 +1183,7 @@ def generate_certificate( session, slot, public_key, subject, now, valid_to, hash_algorithm ) session.put_certificate(slot, cert) - session.put_object(OBJECT_ID.CHUID, generate_chuid()) + _update_chuid(session) click.echo(f"Certificate generated in slot {slot.name}.") except ApduError: raise CliFail("Certificate generation failed.") @@ -1244,7 +1270,7 @@ def delete_certificate(ctx, management_key, pin, slot): session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) session.delete_certificate(slot) - session.put_object(OBJECT_ID.CHUID, generate_chuid()) + _update_chuid(session) click.echo(f"Certificate in slot {slot.name} deleted.") From 5afae60b60e16430404fc62f984282404afa74bf Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Tue, 6 Aug 2024 11:18:34 +0200 Subject: [PATCH 3/6] PIV: Make PUBLIC-KEY optional when generating certificate --- ykman/_cli/piv.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ykman/_cli/piv.py b/ykman/_cli/piv.py index e25b2ce2..dd7a9033 100644 --- a/ykman/_cli/piv.py +++ b/ykman/_cli/piv.py @@ -1120,7 +1120,9 @@ def export_certificate(ctx, format, slot, certificate): @click_management_key_option @click_pin_option @click_slot_argument -@click.argument("public-key", type=click.File("rb"), metavar="PUBLIC-KEY") +@click.argument( + "public-key", type=click.File("rb"), metavar="PUBLIC-KEY", required=False +) @click.option( "-s", "--subject", @@ -1164,8 +1166,13 @@ def generate_certificate( except NotSupportedError: timeout = 1.0 - data = public_key.read() - public_key = serialization.load_pem_public_key(data, default_backend()) + if public_key: + data = public_key.read() + public_key = serialization.load_pem_public_key(data, default_backend()) + elif session.version < (5, 4, 0): + raise CliFail("PUBLIC-KEY required for YubiKey prior to 5.4.") + else: + public_key = session.get_slot_metadata(slot).public_key now = datetime.datetime.now(datetime.timezone.utc) valid_to = now + datetime.timedelta(days=valid_days) From 6af66b152d6cf33bbab3188cee6c0f09b2676297 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Tue, 6 Aug 2024 11:33:04 +0200 Subject: [PATCH 4/6] Python < 3.11 compatibility --- yubikit/piv.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/yubikit/piv.py b/yubikit/piv.py index 71f74ae7..243025bf 100755 --- a/yubikit/piv.py +++ b/yubikit/piv.py @@ -520,6 +520,9 @@ def from_bytes(cls, value: bytes) -> "Chuid": data = Tlv.parse_dict(value) buffer_length = data.get(0xEE) lrc = data.get(TAG_LRC) + # From Python 3.11: date.fromisoformat(data[0x35]) + d = data[0x35] + expiration_date = date(int(d[:4]), int(d[4:6]), int(d[6:8])) return cls( buffer_length=bytes2int(buffer_length) if buffer_length else None, fasc_n=FascN.from_bytes(data[0x30]), @@ -527,7 +530,7 @@ def from_bytes(cls, value: bytes) -> "Chuid": organizational_identifier=data.get(0x32), duns=data.get(0x33), guid=data[0x34], - expiration_date=date.fromisoformat(data[0x35].decode()), + expiration_date=expiration_date, authentication_key_map=data.get(0x3D), asymmetric_signature=data[0x3E], lrc=lrc[0] if lrc else None, From 80697252746cf96b294d28c942c55aa327e9016f Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Mon, 19 Aug 2024 10:14:46 +0200 Subject: [PATCH 5/6] PIV: Use UUID4 for GUID generation --- ykman/_cli/piv.py | 4 ++-- ykman/piv.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ykman/_cli/piv.py b/ykman/_cli/piv.py index dd7a9033..b5195e3f 100644 --- a/ykman/_cli/piv.py +++ b/ykman/_cli/piv.py @@ -79,8 +79,8 @@ ) from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.backends import default_backend +from uuid import uuid4 -import os import click import datetime import logging @@ -974,7 +974,7 @@ def _update_chuid(session): # Signed CHUID, leave it alone logger.debug("Leaving signed CHUID as-is.") return - chuid.guid = os.urandom(16) + chuid.guid = uuid4().bytes chuid_data = bytes(chuid) logger.debug("Updating CHUID GUID.") except ApduError as e: diff --git a/ykman/piv.py b/ykman/piv.py index d86c85f5..b1421e49 100644 --- a/ykman/piv.py +++ b/ykman/piv.py @@ -51,6 +51,7 @@ from cryptography.hazmat.backends import default_backend from cryptography.x509.oid import NameOID from datetime import datetime, date +from uuid import uuid4 import logging import struct import os @@ -474,7 +475,7 @@ def generate_chuid() -> bytes: chuid = Chuid( # Non-Federal Issuer FASC-N fasc_n=FascN(9999, 9999, 999999, 0, 1, 0000000000, 3, 0000, 1), - guid=os.urandom(16), + guid=uuid4().bytes, # Expires on: 2030-01-01 expiration_date=date(2030, 1, 1), asymmetric_signature=b"", From e1ee063df0f8240aa31c144dca46e2e0185a9709 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Mon, 19 Aug 2024 10:20:52 +0200 Subject: [PATCH 6/6] Remove punctuation from logs --- ykman/_cli/piv.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ykman/_cli/piv.py b/ykman/_cli/piv.py index b5195e3f..747877f0 100644 --- a/ykman/_cli/piv.py +++ b/ykman/_cli/piv.py @@ -968,18 +968,18 @@ def _update_chuid(session): try: chuid = Chuid.from_bytes(chuid_data) except ValueError: - logger.debug("Leaving unparsable CHUID as-is.") + logger.debug("Leaving unparsable CHUID as-is") return if chuid.asymmetric_signature: # Signed CHUID, leave it alone - logger.debug("Leaving signed CHUID as-is.") + logger.debug("Leaving signed CHUID as-is") return chuid.guid = uuid4().bytes chuid_data = bytes(chuid) - logger.debug("Updating CHUID GUID.") + logger.debug("Updating CHUID GUID") except ApduError as e: if e.sw == SW.FILE_NOT_FOUND: - logger.debug("Generating new CHUID.") + logger.debug("Generating new CHUID") chuid_data = generate_chuid() else: raise