amd-ucode-patch 1.0.1__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/.github/workflows/build.yml +3 -3
  2. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/.github/workflows/codeql.yml +3 -3
  3. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/.github/workflows/scorecard.yml +3 -3
  4. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/.gitignore +1 -0
  5. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/PKG-INFO +15 -26
  6. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/README.md +12 -23
  7. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/pyproject.toml +6 -2
  8. amd_ucode_patch-2.0.0/src/amd_ucode_patch/cli/argtypes.py +48 -0
  9. amd_ucode_patch-2.0.0/src/amd_ucode_patch/cli/info.py +192 -0
  10. amd_ucode_patch-2.0.0/src/amd_ucode_patch/cli/paths.py +23 -0
  11. amd_ucode_patch-2.0.0/src/amd_ucode_patch/cli/sign.py +156 -0
  12. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/body.py +61 -0
  13. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/body_data.py +21 -0
  14. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/body_data_encrypted.py +26 -0
  15. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/body_data_plaintext.py +26 -0
  16. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/body_data_registry.py +22 -0
  17. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/body_header.py +84 -0
  18. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/date.py +64 -0
  19. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/header.py +97 -0
  20. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/header_data.py +29 -0
  21. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/header_data_default.py +66 -0
  22. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/header_data_fam0fto12.py +99 -0
  23. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/header_data_registry.py +43 -0
  24. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/loader_id.py +46 -0
  25. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/patch.py +99 -0
  26. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/patch_level.py +137 -0
  27. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/signature.py +27 -0
  28. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/signature_fam16.py +38 -0
  29. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/signature_fam17plus.py +86 -0
  30. amd_ucode_patch-2.0.0/src/amd_ucode_patch/structures/signature_registry.py +36 -0
  31. amd_ucode_patch-2.0.0/src/amd_ucode_patch/utils/cmac.py +40 -0
  32. amd_ucode_patch-2.0.0/src/amd_ucode_patch/utils/entrysign.py +133 -0
  33. amd_ucode_patch-2.0.0/src/amd_ucode_patch/utils/numtheory.py +20 -0
  34. amd_ucode_patch-2.0.0/src/amd_ucode_patch/utils/rsa.py +107 -0
  35. amd_ucode_patch-2.0.0/src/amd_ucode_patch/utils/xor.py +19 -0
  36. amd_ucode_patch-2.0.0/tests/baseline_facts.py +54 -0
  37. amd_ucode_patch-2.0.0/tests/conftest.py +111 -0
  38. amd_ucode_patch-2.0.0/tests/data/baseline.json +3442 -0
  39. amd_ucode_patch-2.0.0/tests/test_baseline.py +34 -0
  40. amd_ucode_patch-2.0.0/tests/test_body.py +159 -0
  41. amd_ucode_patch-2.0.0/tests/test_date.py +58 -0
  42. amd_ucode_patch-2.0.0/tests/test_header.py +148 -0
  43. amd_ucode_patch-2.0.0/tests/test_header_data.py +276 -0
  44. amd_ucode_patch-2.0.0/tests/test_loader_id.py +45 -0
  45. amd_ucode_patch-2.0.0/tests/test_naming.py +118 -0
  46. amd_ucode_patch-2.0.0/tests/test_patch.py +90 -0
  47. amd_ucode_patch-2.0.0/tests/test_patch_level.py +184 -0
  48. amd_ucode_patch-2.0.0/tests/test_roundtrip.py +24 -0
  49. amd_ucode_patch-2.0.0/tests/test_signature.py +93 -0
  50. amd_ucode_patch-2.0.0/tests/test_signature_verify.py +130 -0
  51. amd_ucode_patch-1.0.1/src/amd_ucode_patch/cli/info.py +0 -92
  52. amd_ucode_patch-1.0.1/src/amd_ucode_patch/cli/sign.py +0 -89
  53. amd_ucode_patch-1.0.1/src/amd_ucode_patch/naming.py +0 -16
  54. amd_ucode_patch-1.0.1/src/amd_ucode_patch/parse.py +0 -9
  55. amd_ucode_patch-1.0.1/src/amd_ucode_patch/structures/body.py +0 -45
  56. amd_ucode_patch-1.0.1/src/amd_ucode_patch/structures/patch.py +0 -36
  57. amd_ucode_patch-1.0.1/src/amd_ucode_patch/structures/patch_date.py +0 -90
  58. amd_ucode_patch-1.0.1/src/amd_ucode_patch/structures/patch_header.py +0 -121
  59. amd_ucode_patch-1.0.1/src/amd_ucode_patch/structures/patch_level.py +0 -280
  60. amd_ucode_patch-1.0.1/src/amd_ucode_patch/structures/signature.py +0 -68
  61. amd_ucode_patch-1.0.1/src/amd_ucode_patch/structures/verified_header.py +0 -42
  62. amd_ucode_patch-1.0.1/src/amd_ucode_patch/utils/rsa.py +0 -65
  63. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/.github/dependabot.yml +0 -0
  64. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/LICENSE.md +0 -0
  65. {amd_ucode_patch-1.0.1/src/amd_ucode_patch → amd_ucode_patch-2.0.0/src/amd_ucode_patch/cli}/banner.py +0 -0
  66. {amd_ucode_patch-1.0.1 → amd_ucode_patch-2.0.0}/src/amd_ucode_patch/utils/bcd.py +0 -0
@@ -18,10 +18,10 @@ jobs:
18
18
 
19
19
  steps:
20
20
  - name: Checkout repository
21
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
21
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
22
22
 
23
23
  - name: Set up Python ${{ matrix.python-version }}
24
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
24
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
25
25
  with:
26
26
  python-version: ${{ matrix.python-version }}
27
27
 
@@ -58,4 +58,4 @@ jobs:
58
58
  path: dist/
59
59
 
60
60
  - name: Publish distribution to PyPI
61
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
61
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
@@ -48,7 +48,7 @@ jobs:
48
48
  # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
49
49
  steps:
50
50
  - name: Checkout repository
51
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
51
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
52
52
 
53
53
  # Add any setup steps before running the `github/codeql-action/init` action.
54
54
  # This includes steps like installing compilers or runtimes (`actions/setup-node`
@@ -58,7 +58,7 @@ jobs:
58
58
 
59
59
  # Initializes the CodeQL tools for scanning.
60
60
  - name: Initialize CodeQL
61
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
61
+ uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4
62
62
  with:
63
63
  languages: ${{ matrix.language }}
64
64
  build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
86
86
  exit 1
87
87
 
88
88
  - name: Perform CodeQL Analysis
89
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
89
+ uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4
90
90
  with:
91
91
  category: "/language:${{matrix.language}}"
@@ -19,12 +19,12 @@ jobs:
19
19
 
20
20
  steps:
21
21
  - name: Checkout repository
22
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
23
  with:
24
24
  persist-credentials: false
25
25
 
26
26
  - name: Run analysis
27
- uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
27
+ uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
28
28
  with:
29
29
  results_file: results.sarif
30
30
  results_format: sarif
@@ -38,6 +38,6 @@ jobs:
38
38
  retention-days: 5
39
39
 
40
40
  - name: Upload to code-scanning
41
- uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
41
+ uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
42
42
  with:
43
43
  sarif_file: results.sarif
@@ -1,2 +1,3 @@
1
1
  # Python
2
2
  __pycache__/
3
+ dist/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: amd-ucode-patch
3
- Version: 1.0.1
3
+ Version: 2.0.0
4
4
  Summary: A Python library for parsing and interpreting AMD microcode patch files
5
5
  Project-URL: Homepage, https://github.com/amd-zenith/amd-ucode-patch
6
6
  Project-URL: Repository, https://github.com/amd-zenith/amd-ucode-patch
@@ -23,7 +23,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Classifier: Topic :: System :: Hardware
24
24
  Classifier: Topic :: Utilities
25
25
  Requires-Python: >=3.10
26
- Requires-Dist: amd-cpuid==1.0.1
26
+ Requires-Dist: amd-cpuid==1.0.2
27
27
  Requires-Dist: pycryptodome==3.23.0
28
28
  Requires-Dist: rich==15.0.0
29
29
  Description-Content-Type: text/markdown
@@ -47,35 +47,24 @@ pip install amd-ucode-patch
47
47
 
48
48
  ## Command line tools
49
49
 
50
- Installing the package provides the following command line tools.
50
+ Installing the package provides the following command line tools. Run any tool
51
+ with `--help` for usage details.
51
52
 
52
- ### `amd_ucode_patch_info`
53
-
54
- Inspect one or more AMD microcode patch files and print their header
55
- information as a table.
56
-
57
- ```bash
58
- amd_ucode_patch_info <files...> [-f {text,md,csv}]
59
- ```
60
-
61
- Arguments:
62
-
63
- - `files`: One or more patch files to inspect. Glob patterns (e.g. `*.bin` or `**/*.bin`) are expanded automatically.
64
- - `-f`, `--format`: Output format. One of:
65
- - `text` (default): A table rendered for the terminal.
66
- - `md`: A Markdown table.
67
- - `csv`: Comma separated values.
68
-
69
- For every patch file the following fields are reported: file name, date, update revision, loader ID, processor revision, CPUID, family, model, stepping, autorun, encrypted and body size.
53
+ | Tool | Description |
54
+ | ---------------------- | -------------------------------------------------------------------------------------- |
55
+ | `amd_ucode_patch_info` | Inspect patch files and print their decoded fields as a table (text, Markdown or CSV). |
56
+ | `amd_ucode_patch_sign` | Inspect, verify (`verify`) and re-sign (`resign`) patch signatures. |
70
57
 
71
58
  ## Library usage
72
59
 
73
60
  The package can also be used programmatically:
74
61
 
75
62
  ```python
76
- from amd_ucode_patch.parse import ucode_patch_parse
63
+ from pathlib import Path
64
+ from amd_ucode_patch.structures.patch import Patch
77
65
 
78
- patch = ucode_patch_parse("firmware.bin")
79
- print(patch.header.cpuid_str)
80
- print(patch.header.update_revision)
66
+ patch = Patch.from_file(Path("firmware.bin"))
67
+ print(patch.header.patch_level) # e.g. 0a000033
68
+ print(f"{patch.header.data.cpuid.cpuid_signature:08X}") # e.g. 00A00F00
69
+ print(patch.name_canonical) # canonical filename
81
70
  ```
@@ -17,35 +17,24 @@ pip install amd-ucode-patch
17
17
 
18
18
  ## Command line tools
19
19
 
20
- Installing the package provides the following command line tools.
20
+ Installing the package provides the following command line tools. Run any tool
21
+ with `--help` for usage details.
21
22
 
22
- ### `amd_ucode_patch_info`
23
-
24
- Inspect one or more AMD microcode patch files and print their header
25
- information as a table.
26
-
27
- ```bash
28
- amd_ucode_patch_info <files...> [-f {text,md,csv}]
29
- ```
30
-
31
- Arguments:
32
-
33
- - `files`: One or more patch files to inspect. Glob patterns (e.g. `*.bin` or `**/*.bin`) are expanded automatically.
34
- - `-f`, `--format`: Output format. One of:
35
- - `text` (default): A table rendered for the terminal.
36
- - `md`: A Markdown table.
37
- - `csv`: Comma separated values.
38
-
39
- For every patch file the following fields are reported: file name, date, update revision, loader ID, processor revision, CPUID, family, model, stepping, autorun, encrypted and body size.
23
+ | Tool | Description |
24
+ | ---------------------- | -------------------------------------------------------------------------------------- |
25
+ | `amd_ucode_patch_info` | Inspect patch files and print their decoded fields as a table (text, Markdown or CSV). |
26
+ | `amd_ucode_patch_sign` | Inspect, verify (`verify`) and re-sign (`resign`) patch signatures. |
40
27
 
41
28
  ## Library usage
42
29
 
43
30
  The package can also be used programmatically:
44
31
 
45
32
  ```python
46
- from amd_ucode_patch.parse import ucode_patch_parse
33
+ from pathlib import Path
34
+ from amd_ucode_patch.structures.patch import Patch
47
35
 
48
- patch = ucode_patch_parse("firmware.bin")
49
- print(patch.header.cpuid_str)
50
- print(patch.header.update_revision)
36
+ patch = Patch.from_file(Path("firmware.bin"))
37
+ print(patch.header.patch_level) # e.g. 0a000033
38
+ print(f"{patch.header.data.cpuid.cpuid_signature:08X}") # e.g. 00A00F00
39
+ print(patch.name_canonical) # canonical filename
51
40
  ```
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling==1.30.1", "hatch-vcs==0.5.0"]
2
+ requires = ["hatchling==1.32.0", "hatch-vcs==0.5.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
@@ -30,7 +30,7 @@ classifiers = [
30
30
  "Topic :: Utilities",
31
31
  ]
32
32
  dependencies = [
33
- "amd-cpuid==1.0.1",
33
+ "amd-cpuid==1.0.2",
34
34
  "pycryptodome==3.23.0",
35
35
  "rich==15.0.0",
36
36
  ]
@@ -49,3 +49,7 @@ source = "vcs"
49
49
 
50
50
  [tool.hatch.build.targets.wheel]
51
51
  packages = ["src/amd_ucode_patch"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ pythonpath = ["src"]
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """
5
+ Argparse ``type=`` converters for hex-encoded key material shared by the
6
+ command line tools. Each raises ``argparse.ArgumentTypeError`` on malformed or
7
+ wrong-length input, which argparse renders as a usage error.
8
+ """
9
+
10
+ import argparse
11
+
12
+
13
+ def parse_fixed_hex(value: str, nbytes: int, what: str) -> bytes:
14
+ """
15
+ Parse ``value`` as hex (an optional ``0x`` prefix is allowed) and require it
16
+ to decode to exactly ``nbytes`` bytes. ``what`` names the field in error
17
+ messages.
18
+ """
19
+ text = value.strip().lower()
20
+ if text.startswith("0x"):
21
+ text = text[2:]
22
+ try:
23
+ data = bytes.fromhex(text)
24
+ except ValueError:
25
+ raise argparse.ArgumentTypeError(f"{what} must be hexadecimal, got {value!r}")
26
+ if len(data) != nbytes:
27
+ raise argparse.ArgumentTypeError(f"{what} must be {nbytes} bytes ({2 * nbytes} hex chars), got {len(data)}")
28
+ return data
29
+
30
+
31
+ def parse_key(value: str) -> bytes:
32
+ """Parse a 16-byte AES-128 CMAC key from hex."""
33
+ return parse_fixed_hex(value, 16, "key")
34
+
35
+
36
+ def parse_modulus(value: str) -> bytes:
37
+ """Parse a 256-byte RSA modulus from hex."""
38
+ return parse_fixed_hex(value, 256, "modulus")
39
+
40
+
41
+ def parse_private(value: str) -> bytes:
42
+ """Parse a 256-byte RSA private exponent from hex."""
43
+ return parse_fixed_hex(value, 256, "private exponent")
44
+
45
+
46
+ def parse_check(value: str) -> bytes:
47
+ """Parse a 256-byte signature check (Montgomery helper) field from hex."""
48
+ return parse_fixed_hex(value, 256, "check")
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """
5
+ A command line tool to print information about AMD uCode patch files.
6
+
7
+ A format with no header-data model of its own still parses, with the fields
8
+ only that format defines left empty.
9
+ """
10
+
11
+ import argparse
12
+ import csv
13
+ import sys
14
+
15
+ from rich import box
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from amd_ucode_patch.cli.banner import BANNER
20
+ from amd_ucode_patch.cli.paths import expand_paths
21
+ from amd_ucode_patch.structures.header_data import HeaderData
22
+ from amd_ucode_patch.structures.patch import Patch
23
+
24
+ #: Columns follow the order of the fields in the patch: the prologue, then the
25
+ #: header data in offset order, then the signature block and the body header,
26
+ #: with derived values shown next to the field they come from.
27
+ COLS = ["File", "Date", "Patch level", "Loader ID", "Triads", "Init",
28
+ "Checksum", "CPUID", "Signed", "Encrypted", "Body PL", "Size"]
29
+
30
+ #: Shown wherever a field is not defined by the patch's format.
31
+ _NA = "-"
32
+
33
+
34
+ def _field(data: HeaderData, name: str) -> object | None:
35
+ """A header-data field, or ``None`` when this format does not model it."""
36
+ return getattr(data, name, None)
37
+
38
+
39
+ def _checksum_matches(data: HeaderData, raw: bytes) -> bool | None:
40
+ """
41
+ Whether the stored checksum matches the content it covers, or ``None`` when
42
+ this format has no checksum or the patch is too short to compute one.
43
+ """
44
+ stored = _field(data, "op_triad_checksum")
45
+ compute = getattr(data, "compute_op_triad_checksum", None)
46
+ if stored is None or compute is None:
47
+ return None
48
+ try:
49
+ return compute(raw) == stored
50
+ except ValueError:
51
+ return None
52
+
53
+
54
+ def _row_fields(path, raw: bytes) -> tuple[tuple[str, ...], dict[str, str]]:
55
+ """
56
+ One table row for ``path``, filling what the patch's format supports, plus a
57
+ map of column -> "green"/"red" for the cells whose value is cross-checked.
58
+ """
59
+ patch = Patch.from_bytes(raw)
60
+ header = patch.header
61
+ data = header.data
62
+ body_header = patch.body.body_header
63
+
64
+ triads = _field(data, "op_triad_count")
65
+ # The same "run the patch on load" signal lives in the pre-Zen header data
66
+ # (``init_flag``) and in the Zen body header; show whichever one this format
67
+ # carries.
68
+ init = _field(data, "init_flag")
69
+ if init is None and body_header is not None:
70
+ init = body_header.init_flag
71
+ checksum = _field(data, "op_triad_checksum")
72
+ cpuid = _field(data, "cpuid")
73
+ pl_family = header.patch_level.family
74
+ # ``encrypted`` and the mirrored patch level live only in the body header.
75
+ encrypted = body_header.encrypted if body_header is not None else None
76
+ body_pl = body_header.patch_level if body_header is not None else None
77
+
78
+ cells = (
79
+ str(path),
80
+ str(header.date),
81
+ f"{header.patch_level} (fam {pl_family:#04x})",
82
+ str(header.loader_id),
83
+ str(triads) if triads is not None else _NA,
84
+ ("yes" if init else "no") if init is not None else _NA,
85
+ f"{checksum:08x}" if checksum is not None else _NA,
86
+ f"{cpuid.ucode_signature:04x} ({cpuid.description})"
87
+ if cpuid is not None else _NA,
88
+ "yes" if header.signature is not None else "no",
89
+ ("yes" if encrypted else "no") if encrypted is not None else _NA,
90
+ str(body_pl) if body_pl is not None else _NA,
91
+ str(len(raw)),
92
+ )
93
+
94
+ colours: dict[str, str] = {}
95
+ # Flag the known-malformed patch levels red; every other one is well-formed.
96
+ colours["Patch level"] = (
97
+ "red" if header.patch_level.is_anomalous else "green"
98
+ )
99
+ checksum_ok = _checksum_matches(data, raw)
100
+ if checksum_ok is not None:
101
+ colours["Checksum"] = "green" if checksum_ok else "red"
102
+ # Cross-check the patch level against the header's CPUID field. Newer patch
103
+ # levels pack the whole CPUID, so the whole signature is checked; older ones
104
+ # carry only the family, so just that is.
105
+ pl_cpuid = header.patch_level.cpuid
106
+ if cpuid is not None:
107
+ if pl_cpuid is not None:
108
+ matches = pl_cpuid.ucode_signature == cpuid.ucode_signature
109
+ else:
110
+ matches = pl_family == cpuid.family
111
+ colours["CPUID"] = "green" if matches else "red"
112
+ if body_pl is not None:
113
+ # The body header's patch level should mirror the header's.
114
+ matches = body_pl.value == header.patch_level.value
115
+ colours["Body PL"] = "green" if matches else "red"
116
+ return cells, colours
117
+
118
+
119
+ def _rows(paths):
120
+ """
121
+ Build a row per readable patch, with its checksum verdict.
122
+
123
+ Diagnostics go to stderr so that piping the table or the CSV somewhere
124
+ yields only the data.
125
+ """
126
+ console = Console(stderr=True)
127
+ unmodelled = 0
128
+ for path in paths:
129
+ try:
130
+ raw = path.read_bytes()
131
+ cells, colours = _row_fields(path, raw)
132
+ except Exception as exc: # noqa: BLE001
133
+ console.log(f"Error parsing {path}: {exc}")
134
+ continue
135
+ unmodelled += cells[COLS.index("Triads")] == _NA
136
+ yield cells, colours
137
+ if unmodelled:
138
+ console.log(f"{unmodelled} file(s): that format has no header-data "
139
+ f"model of its own, so only shared fields are shown")
140
+
141
+
142
+ def _apply_colours(cells: tuple[str, ...], colours: dict[str, str]) -> list[str]:
143
+ """Wrap each cross-checked cell in its verdict colour (green/red)."""
144
+ out = list(cells)
145
+ for column, colour in colours.items():
146
+ index = COLS.index(column)
147
+ out[index] = f"[{colour}]{out[index]}[/{colour}]"
148
+ return out
149
+
150
+
151
+ def print_table(console: Console, paths, format):
152
+ table = Table(*COLS, box=format)
153
+ for cells, colours in _rows(paths):
154
+ table.add_row(*_apply_colours(cells, colours))
155
+ console.print(table)
156
+
157
+
158
+ def print_csv(paths):
159
+ """
160
+ Write the rows as CSV to stdout.
161
+
162
+ Written with :mod:`csv` rather than through the console: cell values contain
163
+ commas (a processor description, for one), and machine-readable output
164
+ should carry neither console formatting nor the checksum colouring.
165
+ """
166
+ writer = csv.writer(sys.stdout, lineterminator="\n")
167
+ writer.writerow(COLS)
168
+ writer.writerows(cells for cells, _ in _rows(paths))
169
+
170
+
171
+ def main():
172
+ console = Console()
173
+
174
+ parser = argparse.ArgumentParser(description="Inspect AMD microcode patch files.")
175
+ parser.add_argument("files", nargs="+", help="Patch files to inspect")
176
+ parser.add_argument("-f", "--format", choices=["text", "md", "csv"], default="text")
177
+ args = parser.parse_args()
178
+
179
+ if args.format != "csv":
180
+ console.print(BANNER, highlight=False)
181
+
182
+ paths = expand_paths(args.files)
183
+ if args.format == "text":
184
+ print_table(console, paths, box.HEAVY_HEAD)
185
+ elif args.format == "md":
186
+ print_table(console, paths, box.MARKDOWN)
187
+ elif args.format == "csv":
188
+ print_csv(paths)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """Shared path handling for the command line tools."""
5
+
6
+ import glob
7
+ from pathlib import Path
8
+ from collections.abc import Iterable, Iterator
9
+
10
+
11
+ def expand_paths(patterns: Iterable[str]) -> Iterator[Path]:
12
+ """
13
+ Expand each glob pattern into the matching paths (sorted, recursive ``**``
14
+ supported). A pattern with no matches is yielded verbatim as a ``Path`` so the
15
+ caller can surface a sensible "file not found" error.
16
+ """
17
+ for pattern in patterns:
18
+ matches = sorted(glob.glob(pattern, recursive=True))
19
+ if not matches:
20
+ yield Path(pattern)
21
+ continue
22
+ for match in matches:
23
+ yield Path(match)
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """
5
+ A command line tool to verify and resign AMD uCode patch signatures.
6
+ """
7
+
8
+ import sys
9
+ import argparse
10
+ from pathlib import Path
11
+
12
+ from rich import box
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from amd_ucode_patch.cli.argtypes import parse_key, parse_modulus, parse_private
17
+ from amd_ucode_patch.cli.banner import BANNER
18
+ from amd_ucode_patch.cli.paths import expand_paths
19
+ from amd_ucode_patch.structures.patch import Patch
20
+ from amd_ucode_patch.structures.signature_fam17plus import SignatureFam17Plus
21
+ from amd_ucode_patch.utils.cmac import cmac_digest
22
+ from amd_ucode_patch.utils.entrysign import produce_colliding_key
23
+ from amd_ucode_patch.utils.rsa import montgomery_n_prime, sign_pkcs1_v15_payload
24
+
25
+ COLS = ["File", "Signed", "Digest", "Valid"]
26
+
27
+
28
+ def _verify_row(path: Path, key: bytes | None) -> tuple[str, ...]:
29
+ """The four display cells (File, Signed, Digest, Valid) for one patch."""
30
+ patch = Patch.from_bytes(path.read_bytes())
31
+ signature = patch.header.signature
32
+
33
+ if signature is None:
34
+ return (str(path), "[yellow]no[/yellow]", "", "[dim]n/a[/dim]")
35
+
36
+ if not isinstance(signature, SignatureFam17Plus):
37
+ # A signature slot that is not the RSA block (family 0x16): present, but
38
+ # nothing to recover or verify.
39
+ return (str(path), "[green]yes[/green]", "[dim]not RSA[/dim]",
40
+ "[dim]n/a[/dim]")
41
+
42
+ recovered = signature.recover_digest()
43
+ digest = recovered.hex() if recovered is not None else "[red]<bad padding>[/red]"
44
+
45
+ if key is None:
46
+ return (str(path), "[green]yes[/green]", digest, "[dim]not checked[/dim]")
47
+
48
+ verified = patch.signature_verifies(key)
49
+ valid = "[green]yes[/green]" if verified else "[red]no[/red]"
50
+ return (str(path), "[green]yes[/green]", digest, valid)
51
+
52
+
53
+ def verify(args, console: Console) -> None:
54
+ """The ``verify`` command: print a signature table for each file."""
55
+ table = Table(*COLS, box=box.HEAVY_HEAD)
56
+ for path in expand_paths(args.files):
57
+ try:
58
+ table.add_row(*_verify_row(path, args.key))
59
+ except Exception as exc: # noqa: BLE001
60
+ console.log(f"Error reading {path}: {exc}")
61
+ console.print(table)
62
+
63
+
64
+ def _resign_one(
65
+ path: Path,
66
+ key: bytes,
67
+ private: bytes | None,
68
+ modulus: bytes | None,
69
+ output: Path | None,
70
+ ) -> tuple[Path, str]:
71
+ """Resign one patch, returning where it was written and the signed digest."""
72
+ patch = Patch.from_bytes(path.read_bytes())
73
+ signature = patch.header.signature
74
+ if not isinstance(signature, SignatureFam17Plus):
75
+ raise ValueError("patch has no RSA signature to resign")
76
+
77
+ digest = cmac_digest(patch.body.to_bytes(), key)
78
+ if private is None:
79
+ # No private key given.
80
+ # Forge a modulus that collides to the CMAC the
81
+ # loader expects, the way zentool does for the EntrySign exploit.
82
+ target = cmac_digest(signature.modulus, key)
83
+ new_modulus, new_private = produce_colliding_key(target, cmac_key=key)
84
+ else:
85
+ new_modulus = modulus if modulus is not None else signature.modulus
86
+ new_private = private
87
+
88
+ signature.modulus = new_modulus
89
+ signature.check = montgomery_n_prime(new_modulus)
90
+ signature.signature = sign_pkcs1_v15_payload(digest, new_modulus, new_private)
91
+
92
+ out_path = output if output is not None else path
93
+ out_path.write_bytes(patch.to_bytes())
94
+ return out_path, digest.hex()
95
+
96
+
97
+ def resign(args, console: Console) -> int:
98
+ """
99
+ The ``resign`` command: re-sign edited patches, in place by default.
100
+
101
+ Returns a process exit code, non-zero if any file failed or the arguments
102
+ were invalid.
103
+ """
104
+ if args.output is not None and len(args.files) != 1:
105
+ console.log("Error: --output is only allowed with a single input file")
106
+ return 1
107
+ if args.modulus is not None and args.private is None:
108
+ console.log("Error: --modulus requires --private")
109
+ return 1
110
+
111
+ exit_code = 0
112
+ for path in expand_paths(args.files):
113
+ try:
114
+ target, digest = _resign_one(
115
+ path, args.key, args.private, args.modulus, args.output
116
+ )
117
+ console.print(f"[green]resigned[/green] {target} digest={digest}")
118
+ except Exception as exc: # noqa: BLE001
119
+ console.log(f"Error resigning {path}: {exc}")
120
+ exit_code = 1
121
+ return exit_code
122
+
123
+
124
+ def main():
125
+ console = Console()
126
+ console.print(BANNER, highlight=False)
127
+
128
+ parser = argparse.ArgumentParser(
129
+ description="Inspect, verify, sign and resign AMD microcode patch signatures.",
130
+ epilog="The published Zen 1-4 CMAC key is 2b7e151628aed2a6abf7158809cf4f3c.",
131
+ )
132
+ subparsers = parser.add_subparsers(dest="command")
133
+
134
+ verify_parser = subparsers.add_parser("verify", help="Inspect signatures, and verify against the body with -k")
135
+ verify_parser.add_argument("files", nargs="+", help="Patch files to inspect")
136
+ verify_parser.add_argument("-k", "--key", type=parse_key, default=None, help="AES-128 CMAC key as 32 hex chars. Enables body verification.")
137
+
138
+ resign_parser = subparsers.add_parser("resign", help="Re-sign edited patches in-place (zentool-style)")
139
+ resign_parser.add_argument("files", nargs="+", help="Patch files to resign")
140
+ resign_parser.add_argument("-k", "--key", type=parse_key, required=True, help="AES-128 CMAC key as 32 hex chars")
141
+ resign_parser.add_argument("-d", "--private", type=parse_private, default=None, help="RSA private exponent as 512 hex chars (256 bytes). Optional.")
142
+ resign_parser.add_argument("-m", "--modulus", type=parse_modulus, default=None, help="RSA modulus as 512 hex chars (256 bytes). Used with --private.")
143
+ resign_parser.add_argument("-o", "--output", type=Path, default=None, help="Output file (single-input only). Defaults to in-place rewrite")
144
+
145
+ args = parser.parse_args()
146
+
147
+ if args.command == "verify":
148
+ verify(args, console)
149
+ elif args.command == "resign":
150
+ sys.exit(resign(args, console))
151
+ else:
152
+ parser.print_help()
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()