Skip to content

Commit

Permalink
Add dataclasses for CHUID and FASC-N
Browse files Browse the repository at this point in the history
  • Loading branch information
dainnilsson committed Jul 25, 2024
1 parent d175d4a commit 238fb51
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 17 deletions.
73 changes: 72 additions & 1 deletion tests/test_piv.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -7,7 +11,10 @@
PIN_POLICY,
TOUCH_POLICY,
_do_check_key_support,
FascN,
Chuid,
)
from datetime import date

import pytest

Expand Down Expand Up @@ -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
27 changes: 12 additions & 15 deletions ykman/piv.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
ALGORITHM,
TAG_LRC,
SlotMetadata,
FascN,
Chuid,
)
from .util import display_serial

Expand All @@ -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
Expand Down Expand Up @@ -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)."""
Expand Down
143 changes: 142 additions & 1 deletion yubikit/piv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 238fb51

Please sign in to comment.