From 922e0f9582dfbed8d01c361138b2949470512618 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 14 Dec 2024 12:30:34 +0100 Subject: [PATCH] Generate rules with Python This patch adds a Python script to generate the udev rules. The required data is defined in a TOML file. This makes the rules easier to maintain and update. This causes some small changes in the formatting of the rules. Also, the rule for the Nitrokey Storage bootloader is updated to use ATTRS instead of ATTR for consistency with similar rules. --- .github/workflows/ci.yaml | 30 +++++++++ 41-nitrokey.rules | 19 +++--- Makefile | 18 +++++ devices.toml | 89 +++++++++++++++++++++++++ generate.py | 136 ++++++++++++++++++++++++++++++++++++++ poetry.lock | 75 +++++++++++++++++++++ pyproject.toml | 9 +++ 7 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 Makefile create mode 100644 devices.toml create mode 100644 generate.py create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..98a0c6a --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,30 @@ +name: Continuous integration +on: [push, pull_request] + +jobs: + check: + name: Run checks + runs-on: ubuntu-latest + container: python:3.11 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install dependencies + run: | + pip install poetry + poetry install + - name: Run checks + run: make check PYRIGHT="poetry run pyright" RUFF="poetry run ruff" + + validate: + name: Validate rules + runs-on: ubuntu-latest + container: python:3.11 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Validate rules + run: | + cp 41-nitrokey.rules original + make generate + diff original 41-nitrokey.rules diff --git a/41-nitrokey.rules b/41-nitrokey.rules index e967893..1898c0f 100644 --- a/41-nitrokey.rules +++ b/41-nitrokey.rules @@ -4,7 +4,7 @@ # Here rules in new style should be provided. Matching devices should be tagged with 'uaccess'. # File prefix number should be lower than 73, to be correctly processed by the Udev. # Recommended udev version: >= 188. -# + ACTION!="add|change", GOTO="u2f_end" # Nitrokey U2F @@ -30,20 +30,19 @@ LABEL="u2f_end" SUBSYSTEM!="usb", GOTO="gnupg_rules_end" ACTION!="add", GOTO="gnupg_rules_end" -# USB SmartCard Readers -## Crypto Stick 1.2 +# CryptoStick 1.2 ATTR{idVendor}=="20a0", ATTR{idProduct}=="4107", ENV{ID_SMARTCARD_READER}="1", ENV{ID_SMARTCARD_READER_DRIVER}="gnupg", TAG+="uaccess" -## Nitrokey Pro +# Nitrokey Pro ATTR{idVendor}=="20a0", ATTR{idProduct}=="4108", ENV{ID_SMARTCARD_READER}="1", ENV{ID_SMARTCARD_READER_DRIVER}="gnupg", TAG+="uaccess" -## Nitrokey Pro Bootloader +# Nitrokey Pro Bootloader ATTRS{idVendor}=="20a0", ATTRS{idProduct}=="42b4", TAG+="uaccess" -## Nitrokey Storage +# Nitrokey Storage ATTR{idVendor}=="20a0", ATTR{idProduct}=="4109", ENV{ID_SMARTCARD_READER}="1", ENV{ID_SMARTCARD_READER_DRIVER}="gnupg", TAG+="uaccess" -## Nitrokey Storage Bootloader -ATTR{idVendor}=="03eb", ATTR{idProduct}=="2ff1", TAG+="uaccess" -## Nitrokey Start +# Nitrokey Storage Bootloader +ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2ff1", TAG+="uaccess" +# Nitrokey Start ATTR{idVendor}=="20a0", ATTR{idProduct}=="4211", ENV{ID_SMARTCARD_READER}="1", ENV{ID_SMARTCARD_READER_DRIVER}="gnupg", TAG+="uaccess" -## Nitrokey HSM +# Nitrokey HSM ATTR{idVendor}=="20a0", ATTR{idProduct}=="4230", ENV{ID_SMARTCARD_READER}="1", ENV{ID_SMARTCARD_READER_DRIVER}="gnupg", TAG+="uaccess" LABEL="gnupg_rules_end" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7d9246b --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +PYTHON3 ?= python3 +PYRIGHT ?= pyright +RUFF ?= ruff + +.PHONY: check +check: + $(RUFF) check + $(RUFF) format --diff + $(PYRIGHT) + +.PHONY: fix +fix: + $(RUFF) check --fix + $(PYRIGHT) format + +.PHONY: generate +generate: + $(PYTHON3) generate.py devices.toml 41-nitrokey.rules diff --git a/devices.toml b/devices.toml new file mode 100644 index 0000000..0b6d9df --- /dev/null +++ b/devices.toml @@ -0,0 +1,89 @@ +[[u2f]] +name = "Nitrokey U2F" +vid = 0x2581 +pid = 0xf1d0 +hidraw = true + +[[u2f]] +name = "Nitrokey FIDO U2F" +vid = 0x20a0 +pid = 0x4287 +hidraw = true + +[[u2f]] +name = "Nitrokey FIDO2" +vid = 0x20a0 +pid = 0x42b1 +hidraw = true + +[[u2f]] +name = "Nitrokey 3A Mini/3A NFC/3C NFC" +vid = 0x20a0 +pid = 0x42b2 +hidraw = true + +[[u2f]] +name = "Nitrokey 3A NFC Bootloader/3C NFC Bootloader" +vid = 0x20a0 +pid = 0x42dd +hidraw = true + +[[u2f]] +name = "Nitrokey 3A Mini Bootloader" +vid = 0x20a0 +pid = 0x42e8 +all = true + +[[u2f]] +name = "Nitrokey Passkey" +vid = 0x20a0 +pid = 0x42f3 +hidraw = true + +[[u2f]] +name = "Nitrokey Passkey Bootloader" +vid = 0x20a0 +pid = 0x42f4 +all = true + +[[ccid]] +name = "CryptoStick 1.2" +vid = 0x20a0 +pid = 0x4107 +gnupg = true + +[[ccid]] +name = "Nitrokey Pro" +vid = 0x20a0 +pid = 0x4108 +gnupg = true + +[[ccid]] +name = "Nitrokey Pro Bootloader" +vid = 0x20a0 +pid = 0x42b4 +all = true + +[[ccid]] +name = "Nitrokey Storage" +vid = 0x20a0 +pid = 0x4109 +gnupg = true + +[[ccid]] +name = "Nitrokey Storage Bootloader" +vid = 0x03eb +pid = 0x2ff1 +all = true + +[[ccid]] +name = "Nitrokey Start" +vid = 0x20a0 +pid = 0x4211 +gnupg = true + +[[ccid]] +name = "Nitrokey HSM" +vid = 0x20a0 +pid = 0x4230 +gnupg = true diff --git a/generate.py b/generate.py new file mode 100644 index 0000000..4388034 --- /dev/null +++ b/generate.py @@ -0,0 +1,136 @@ +import argparse +import dataclasses +import textwrap +import tomllib +import typing + + +@dataclasses.dataclass(frozen=True) +class Device: + name: str + vid: int + pid: int + hidraw: bool = False + gnupg: bool = False + all: bool = False + + def generate(self) -> str: + s = f"# {self.name}\n" + attr_vid_pid = [ + ("ATTR{idVendor}", "==", f"{self.vid:04x}"), + ("ATTR{idProduct}", "==", f"{self.pid:04x}"), + ] + attrs_vid_pid = [ + ("ATTRS{idVendor}", "==", f"{self.vid:04x}"), + ("ATTRS{idProduct}", "==", f"{self.pid:04x}"), + ] + uaccess = [("TAG", "+=", "uaccess")] + if self.hidraw: + s += generate_rule( + [("KERNEL", "==", "hidraw*"), ("SUBSYSTEM", "==", "hidraw")] + + attrs_vid_pid + + uaccess + ) + if self.gnupg: + s += generate_rule( + attr_vid_pid + + [ + ("ENV{ID_SMARTCARD_READER}", "=", "1"), + ("ENV{ID_SMARTCARD_READER_DRIVER}", "=", "gnupg"), + ] + + uaccess + ) + if self.all: + s += generate_rule(attrs_vid_pid + uaccess) + return s + + @classmethod + def from_dict(cls, data: dict[str, typing.Any]) -> "Device": + return cls(**data) + + +def generate_rule(matches: typing.Sequence[tuple[str, str, str]]) -> str: + rules = [f'{key}{op}"{value}"' for (key, op, value) in matches] + return ", ".join(rules) + "\n" + + +def generate_u2f(devices: list[Device]) -> str: + output = 'ACTION!="add|change", GOTO="u2f_end"\n' + output += "\n" + for device in devices: + output += device.generate() + output += "\n" + output += 'LABEL="u2f_end"\n' + return output + + +def generate_ccid(devices: list[Device]) -> str: + output = "" + output += 'SUBSYSTEM!="usb", GOTO="gnupg_rules_end"\n' + output += 'ACTION!="add", GOTO="gnupg_rules_end"\n' + output += "\n" + for device in devices: + output += device.generate() + output += "\n" + output += 'LABEL="gnupg_rules_end"\n' + return output + + +def generate(u2f_devices: list[Device], ccid_devices: list[Device]) -> str: + header = """\ + # Copyright (c) Nitrokey GmbH + # SPDX-License-Identifier: CC0-1.0 + + # Here rules in new style should be provided. Matching devices should be tagged with 'uaccess'. + # File prefix number should be lower than 73, to be correctly processed by the Udev. + # Recommended udev version: >= 188. + + """ + + sections = [] + if u2f_devices: + sections.append(generate_u2f(u2f_devices)) + if ccid_devices: + sections.append(generate_ccid(ccid_devices)) + + output = textwrap.dedent(header) + output += "\n\n".join(sections) + # TODO: can we remove this? + output += textwrap.dedent(""" + + # Nitrokey Storage dev Entry + KERNEL=="sd?1", ATTRS{idVendor}=="20a0", ATTRS{idProduct}=="4109", SYMLINK+="nitrospace" + """) + return output + + +def run(input: str, output: str) -> None: + with open(input, "rb") as f: + data = tomllib.load(f) + + u2f_devices = [] + if "u2f" in data: + assert isinstance(data["u2f"], list) + for device in data["u2f"]: + assert isinstance(device, dict) + u2f_devices.append(Device.from_dict(device)) + + ccid_devices = [] + if "ccid" in data: + assert isinstance(data["ccid"], list) + for device in data["ccid"]: + assert isinstance(device, dict) + ccid_devices.append(Device.from_dict(device)) + + rules = generate(u2f_devices, ccid_devices) + with open(output, "w") as f: + f.write(rules) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("input") + parser.add_argument("output") + + args = parser.parse_args() + run(args.input, args.output) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..d9c221b --- /dev/null +++ b/poetry.lock @@ -0,0 +1,75 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "pyright" +version = "1.1.390" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.390-py3-none-any.whl", hash = "sha256:ecebfba5b6b50af7c1a44c2ba144ba2ab542c227eb49bc1f16984ff714e0e110"}, + {file = "pyright-1.1.390.tar.gz", hash = "sha256:aad7f160c49e0fbf8209507a15e17b781f63a86a1facb69ca877c71ef2e9538d"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + +[[package]] +name = "ruff" +version = "0.8.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6"}, + {file = "ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939"}, + {file = "ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d"}, + {file = "ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13"}, + {file = "ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18"}, + {file = "ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502"}, + {file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d"}, + {file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82"}, + {file = "ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452"}, + {file = "ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd"}, + {file = "ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20"}, + {file = "ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc"}, + {file = "ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060"}, + {file = "ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea"}, + {file = "ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964"}, + {file = "ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9"}, + {file = "ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936"}, + {file = "ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "ffa1adbfa456ece54e1ece4615f9746cfe02098ab2ca093dc2d939fab9d850f4" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f88fb89 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.11" + +[tool.poetry.group.dev.dependencies] +pyright = "^1" +ruff = "^0.8"