amd-ucode-patch 1.0.0__tar.gz → 1.1.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 (33) hide show
  1. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/.github/workflows/codeql.yml +2 -2
  2. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/.github/workflows/scorecard.yml +1 -1
  3. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/PKG-INFO +4 -2
  4. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/pyproject.toml +6 -3
  5. amd_ucode_patch-1.1.0/src/amd_ucode_patch/cli/argtypes.py +48 -0
  6. amd_ucode_patch-1.0.0/src/amd_ucode_patch/cli_info.py → amd_ucode_patch-1.1.0/src/amd_ucode_patch/cli/info.py +24 -25
  7. amd_ucode_patch-1.1.0/src/amd_ucode_patch/cli/paths.py +23 -0
  8. amd_ucode_patch-1.1.0/src/amd_ucode_patch/cli/sign.py +153 -0
  9. amd_ucode_patch-1.1.0/src/amd_ucode_patch/naming.py +18 -0
  10. amd_ucode_patch-1.1.0/src/amd_ucode_patch/parse.py +9 -0
  11. amd_ucode_patch-1.1.0/src/amd_ucode_patch/structures/body.py +45 -0
  12. amd_ucode_patch-1.1.0/src/amd_ucode_patch/structures/patch.py +50 -0
  13. amd_ucode_patch-1.1.0/src/amd_ucode_patch/structures/patch_date.py +90 -0
  14. amd_ucode_patch-1.1.0/src/amd_ucode_patch/structures/patch_header.py +121 -0
  15. amd_ucode_patch-1.1.0/src/amd_ucode_patch/structures/patch_level.py +280 -0
  16. amd_ucode_patch-1.1.0/src/amd_ucode_patch/structures/signature.py +69 -0
  17. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/structures/verified_header.py +10 -10
  18. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/bcd.py +48 -0
  19. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/cmac.py +40 -0
  20. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/entrysign.py +133 -0
  21. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/numtheory.py +20 -0
  22. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/rsa.py +107 -0
  23. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/xor.py +19 -0
  24. amd_ucode_patch-1.0.0/src/amd_ucode_patch/naming.py +0 -15
  25. amd_ucode_patch-1.0.0/src/amd_ucode_patch/parse.py +0 -9
  26. amd_ucode_patch-1.0.0/src/amd_ucode_patch/structures/ucode_patch.py +0 -29
  27. amd_ucode_patch-1.0.0/src/amd_ucode_patch/structures/ucode_patch_header.py +0 -126
  28. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/.github/dependabot.yml +0 -0
  29. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/.github/workflows/build.yml +0 -0
  30. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/.gitignore +0 -0
  31. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/LICENSE.md +0 -0
  32. {amd_ucode_patch-1.0.0 → amd_ucode_patch-1.1.0}/README.md +0 -0
  33. {amd_ucode_patch-1.0.0/src/amd_ucode_patch → amd_ucode_patch-1.1.0/src/amd_ucode_patch/cli}/banner.py +0 -0
@@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # 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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
90
90
  with:
91
91
  category: "/language:${{matrix.language}}"
@@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
42
42
  with:
43
43
  sarif_file: results.sarif
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amd-ucode-patch
3
- Version: 1.0.0
3
+ Version: 1.1.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,9 @@ 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: rich
26
+ Requires-Dist: amd-cpuid==1.0.1
27
+ Requires-Dist: pycryptodome==3.23.0
28
+ Requires-Dist: rich==15.0.0
27
29
  Description-Content-Type: text/markdown
28
30
 
29
31
  # AMD Microcode Patch
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling", "hatch-vcs"]
2
+ requires = ["hatchling==1.31.0", "hatch-vcs==0.5.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
@@ -30,7 +30,9 @@ classifiers = [
30
30
  "Topic :: Utilities",
31
31
  ]
32
32
  dependencies = [
33
- "rich",
33
+ "amd-cpuid==1.0.1",
34
+ "pycryptodome==3.23.0",
35
+ "rich==15.0.0",
34
36
  ]
35
37
 
36
38
  [project.urls]
@@ -39,7 +41,8 @@ Repository = "https://github.com/amd-zenith/amd-ucode-patch"
39
41
  Issues = "https://github.com/amd-zenith/amd-ucode-patch/issues"
40
42
 
41
43
  [project.scripts]
42
- amd_ucode_patch_info = "amd_ucode_patch.cli_info:main"
44
+ amd_ucode_patch_info = "amd_ucode_patch.cli.info:main"
45
+ amd_ucode_patch_sign = "amd_ucode_patch.cli.sign:main"
43
46
 
44
47
  [tool.hatch.version]
45
48
  source = "vcs"
@@ -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")
@@ -4,42 +4,41 @@
4
4
  A command line tool to print information about AMD uCode patch files.
5
5
  '''
6
6
 
7
- import glob
8
7
  import argparse
9
- from pathlib import Path
10
8
  from rich import box
11
9
  from rich.console import Console
12
10
  from rich.table import Table
13
- from .banner import BANNER
14
- from .parse import ucode_patch_parse
11
+ from amd_ucode_patch.cli.banner import BANNER
12
+ from amd_ucode_patch.cli.paths import expand_paths
13
+ from amd_ucode_patch.parse import ucode_patch_parse
14
+ from amd_ucode_patch.structures.patch_level import PatchLevelV2
15
15
 
16
- COLS = ["File", "Date", "Upd. Rev", "Loader ID", "Proc. Rev", "CPUID", "Family", "Model", "Stepping", "Autorun", "Encrypted", "Body size"]
17
-
18
-
19
- def expand_paths(patterns):
20
- for pattern in patterns:
21
- matches = sorted(glob.glob(pattern, recursive=True))
22
- if not matches:
23
- yield Path(pattern)
24
- continue
25
- for match in matches:
26
- yield Path(match)
16
+ COLS = ["File", "Date", "Patch level", "PL Ver", "PL Rev", "Loader ID", "Proc. Rev", "CPUID", "Family", "Family Name", "Model", "Stepping", "Microarch", "Codename", "Signed", "Autorun", "Encrypted", "Body size"]
27
17
 
28
18
 
29
19
  def _row_fields(path, patch):
20
+ patch_level = patch.header.patch_level
21
+ # Advanced (v2 / Zen+) patch level fields are only meaningful when present.
22
+ patch_rev = f"{patch_level.rev:02x}" if isinstance(patch_level, PatchLevelV2) else ""
30
23
  return (
31
24
  str(path),
32
- f"{patch.header.year:04d}-{patch.header.month:02d}-{patch.header.day:02d}",
33
- f"{patch.header.update_revision:08x}",
25
+ str(patch.header.date),
26
+ str(patch_level),
27
+ f"v{int(patch_level.version)}",
28
+ patch_rev,
34
29
  f"{patch.header.loader_id:04x}",
35
- f"{patch.header.processor_rev_id:04x}",
36
- patch.header.cpuid_str,
37
- f"0x{patch.header.cpu_family:02x}",
38
- f"0x{patch.header.cpu_model:02x}",
39
- f"0x{patch.header.cpu_stepping:02x}",
40
- f"{patch.verified_header.autorun if patch.verified_header is not None else ''}",
41
- f"{patch.verified_header.encrypted if patch.verified_header is not None else ''}",
42
- f"{len(patch.body)}",
30
+ f"{patch.header.cpuid.ucode_signature:04x}",
31
+ f"{patch.header.cpuid.cpuid_signature:08X}",
32
+ f"0x{patch.header.cpuid.family:02x}",
33
+ patch.header.cpuid.familyname,
34
+ f"0x{patch.header.cpuid.model:02x}",
35
+ f"0x{patch.header.cpuid.stepping:02x}",
36
+ patch.header.cpuid.microarchitecture,
37
+ patch.header.cpuid.codename,
38
+ "yes" if patch.header.signature is not None else "no",
39
+ f"{patch.body.verified_header.autorun if patch.body.verified_header is not None else ''}",
40
+ f"{patch.body.verified_header.encrypted if patch.body.verified_header is not None else ''}",
41
+ f"{len(patch.body.data)}",
43
42
  )
44
43
 
45
44
  def print_table(console: Console, paths, format):
@@ -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,153 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+ '''
4
+ A command line tool to inspect, verify, sign and resign AMD uCode patches.
5
+ '''
6
+
7
+ import sys
8
+ import argparse
9
+ from pathlib import Path
10
+ from rich import box
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from amd_ucode_patch.cli.banner import BANNER
14
+ from amd_ucode_patch.parse import ucode_patch_parse
15
+ from amd_ucode_patch.utils.cmac import cmac_digest
16
+ from amd_ucode_patch.utils.entrysign import produce_colliding_key
17
+ from amd_ucode_patch.utils.rsa import montgomery_n_prime, sign_pkcs1_v15_payload
18
+ from amd_ucode_patch.cli.paths import expand_paths
19
+ from amd_ucode_patch.cli.argtypes import parse_key, parse_modulus, parse_private
20
+
21
+
22
+ COLS = ["File", "Signed", "Digest", "Valid"]
23
+
24
+
25
+ def _verify_row(path, key):
26
+ """Return the four display cells (File, Signed, Digest, Valid) for one patch."""
27
+ patch = ucode_patch_parse(path)
28
+ signature = patch.header.signature
29
+
30
+ if signature is None:
31
+ return (str(path), "[yellow]no[/yellow]", "", "[dim]n/a[/dim]")
32
+
33
+ recovered = signature.recover_digest()
34
+ digest = recovered.hex() if recovered is not None else "[red]<bad padding>[/red]"
35
+
36
+ if key is None:
37
+ return (str(path), "[green]yes[/green]", digest, "[dim]not checked[/dim]")
38
+
39
+ computed = cmac_digest(patch.body.to_bytes(), key)
40
+ verdict = recovered is not None and recovered == computed
41
+ valid = "[green]yes[/green]" if verdict else "[red]no[/red]"
42
+ return (str(path), "[green]yes[/green]", digest, valid)
43
+
44
+
45
+ def verify(args, console):
46
+ """Process the ``verify`` command: print a signature table for each file."""
47
+ table = Table(*COLS, box=box.HEAVY_HEAD)
48
+ for path in expand_paths(args.files):
49
+ try:
50
+ row = _verify_row(path, args.key)
51
+ table.add_row(*row)
52
+ except Exception as e:
53
+ console.log(f"Error reading {path}: {e}")
54
+ console.print(table)
55
+
56
+
57
+ def _write_target(src: Path, output: Path | None) -> Path:
58
+ return output if output is not None else src
59
+
60
+
61
+ def _resign_one(
62
+ path: Path,
63
+ key: bytes,
64
+ private: bytes | None,
65
+ modulus: bytes | None,
66
+ output: Path | None,
67
+ ) -> tuple[Path, str]:
68
+ patch = ucode_patch_parse(path)
69
+ signature = patch.header.signature
70
+ if signature is None:
71
+ raise ValueError("patch is unsigned (pre-Zen), cannot resign")
72
+
73
+ digest = cmac_digest(patch.body.to_bytes(), key)
74
+ if private is None:
75
+ target = cmac_digest(signature.modulus, key)
76
+ new_modulus, new_private = produce_colliding_key(target, cmac_key=key)
77
+ else:
78
+ new_modulus = modulus if modulus is not None else signature.modulus
79
+ new_private = private
80
+
81
+ signature.modulus = new_modulus
82
+ signature.check = montgomery_n_prime(new_modulus)
83
+ signature.signature = sign_pkcs1_v15_payload(digest, new_modulus, new_private)
84
+
85
+ out_path = _write_target(path, output)
86
+ out_path.write_bytes(patch.to_bytes())
87
+ return out_path, digest.hex()
88
+
89
+
90
+ def resign(args, console) -> int:
91
+ """
92
+ Process the ``resign`` command: re-sign edited patches, in place by default.
93
+ Returns a process exit code (non-zero if any file failed or the arguments
94
+ were invalid).
95
+ """
96
+ if args.output is not None and len(args.files) != 1:
97
+ console.log("Error: --output is only allowed with a single input file")
98
+ return 1
99
+ if args.modulus is not None and args.private is None:
100
+ console.log("Error: --modulus requires --private")
101
+ return 1
102
+
103
+ exit_code = 0
104
+ for path in expand_paths(args.files):
105
+ try:
106
+ target, digest = _resign_one(
107
+ path,
108
+ args.key,
109
+ args.private,
110
+ args.modulus,
111
+ args.output,
112
+ )
113
+ console.print(f"[green]resigned[/green] {target} digest={digest}")
114
+ except Exception as e:
115
+ console.log(f"Error resigning {path}: {e}")
116
+ exit_code = 1
117
+ return exit_code
118
+
119
+
120
+ def main():
121
+ console = Console()
122
+ console.print(BANNER, highlight=False)
123
+
124
+ parser = argparse.ArgumentParser(
125
+ description="Inspect, verify, sign and resign AMD microcode patch signatures.",
126
+ epilog="The published Zen 1-4 CMAC key is 2b7e151628aed2a6abf7158809cf4f3c.",
127
+ )
128
+
129
+ subparsers = parser.add_subparsers(dest="command")
130
+
131
+ verify_parser = subparsers.add_parser("verify", help="Inspect signatures and verify with -k when provided")
132
+ verify_parser.add_argument("files", nargs="+", help="Patch files to inspect")
133
+ verify_parser.add_argument("-k", "--key", type=parse_key, default=None, help="AES-128 CMAC key as hex (32 hex chars); enables verification")
134
+
135
+ resign_parser = subparsers.add_parser("resign", help="Re-sign edited patches in-place (zentool-style)")
136
+ resign_parser.add_argument("files", nargs="+", help="Patch files to resign")
137
+ resign_parser.add_argument("-k", "--key", type=parse_key, required=True, help="AES-128 CMAC key as hex (default: published Zen 1-4 key)")
138
+ resign_parser.add_argument("-d", "--private", type=parse_private, default=None, help="RSA private exponent as 512 hex chars (256 bytes). Optional.")
139
+ resign_parser.add_argument("-m", "--modulus", type=parse_modulus, default=None, help="RSA modulus as 512 hex chars (256 bytes). Used with --private.")
140
+ resign_parser.add_argument("-o", "--output", type=Path, default=None, help="Output file (single-input only). Defaults to in-place rewrite")
141
+
142
+ args = parser.parse_args()
143
+
144
+ if args.command == "verify":
145
+ verify(args, console)
146
+ elif args.command == "resign":
147
+ sys.exit(resign(args, console))
148
+ else:
149
+ console.log(f"Error: unknown command {args.command!r}")
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ from .structures.patch import Patch
5
+
6
+ def ucode_patch_name(patch: Patch, enc_override=None) -> str:
7
+ # Format should be:
8
+ # family<family>_cpuid<cpuid>_rev<revision>_date<yyyymmdd>_enc<ee>_sha<hash12>.bin
9
+ # The _enc<ee> suffix is only present when a verified header exists.
10
+ cpuid = patch.header.cpuid
11
+ name = f"family{cpuid.family:02x}_cpuid{cpuid.cpuid_signature:08X}_rev{patch.header.patch_level}_date{patch.header.date.year:04}{patch.header.date.month:02}{patch.header.date.day:02}"
12
+ if patch.body.verified_header is not None:
13
+ enc = patch.body.verified_header.encrypted if enc_override is None else enc_override
14
+ name += f"_enc{enc:02}"
15
+ hash12 = patch.sha256_hex()[:12]
16
+ name += f"_sha{hash12}"
17
+ name += ".bin"
18
+ return name
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ from pathlib import Path
5
+ from .structures.patch import Patch
6
+
7
+ def ucode_patch_parse(file: Path) -> Patch:
8
+ with file.open("rb") as f:
9
+ return Patch.from_bytes(f.read())
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ from dataclasses import dataclass
5
+ from .verified_header import VerifiedHeader
6
+
7
+
8
+ #: Family at/after which the body carries a verified header (options + rev).
9
+ #: This is the Zen boundary; kept independent of the signature gating.
10
+ VERIFIED_HEADER_MIN_FAMILY = 0x17
11
+
12
+
13
+ @dataclass
14
+ class Body:
15
+ """
16
+ The signed portion of an AMD microcode patch -- the region the RSA signature
17
+ in :class:`PatchHeader` covers (through the AES-CMAC).
18
+
19
+ On Zen (signed) patches it is the ``options``/``rev`` block
20
+ (:class:`VerifiedHeader`) followed by the match registers and the (encrypted)
21
+ microcode opquads. On pre-Zen patches there is no verified header and the
22
+ whole thing is the raw, unsigned microcode.
23
+ """
24
+
25
+ verified_header: VerifiedHeader | None
26
+ data: bytes
27
+
28
+ @staticmethod
29
+ def from_bytes(buf: bytes, family: int | None = None) -> "Body":
30
+ """
31
+ Parse the body. The verified header (options + rev) is present only on
32
+ Zen patches (family >= ``VERIFIED_HEADER_MIN_FAMILY``); ``family`` selects
33
+ that. When ``family`` is unknown the body is treated as having none.
34
+ """
35
+ if family is not None and family >= VERIFIED_HEADER_MIN_FAMILY:
36
+ verified_header = VerifiedHeader.from_bytes(buf, family=family)
37
+ data = buf[VerifiedHeader.SIZE:]
38
+ else:
39
+ verified_header = None
40
+ data = buf
41
+ return Body(verified_header=verified_header, data=data)
42
+
43
+ def to_bytes(self) -> bytes:
44
+ prefix = self.verified_header.to_bytes() if self.verified_header is not None else b""
45
+ return prefix + self.data
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ import hashlib
5
+ from dataclasses import dataclass
6
+ from .patch_header import PatchHeader
7
+ from .body import Body
8
+
9
+
10
+ @dataclass
11
+ class Patch:
12
+ header: PatchHeader
13
+ body: Body
14
+
15
+
16
+ @staticmethod
17
+ def from_bytes(buf: bytes) -> "Patch":
18
+ header = PatchHeader.from_bytes(buf)
19
+ # Everything after the header is the signed body: the verified header
20
+ # (options + rev, Zen only) plus the microcode itself.
21
+ body = Body.from_bytes(buf[header.size:], family=header.cpuid.family)
22
+ return Patch(header=header, body=body)
23
+
24
+ def to_bytes(self) -> bytes:
25
+ """The whole patch encoding: the header (with signature block, if any)
26
+ followed by the signed body."""
27
+ return self.header.to_bytes() + self.body.to_bytes()
28
+
29
+ def signature_verifies(self, cmac_key: bytes) -> bool | None:
30
+ """
31
+ Whether the patch signature verifies against its embedded public key,
32
+ using the provided CMAC key (see :data:`Signature.verify`).
33
+
34
+ ``None`` for unsigned (pre-Zen) patches. A ``False`` result does not by
35
+ itself mean the patch is forged: patches signed with a CMAC key this
36
+ library does not ship -- Zen 5, and AMD's post-EntrySign patches that
37
+ rotated the key -- also report ``False`` even though their RSA signature
38
+ is structurally valid against the embedded modulus.
39
+ """
40
+ if self.header.signature is None:
41
+ return None
42
+ return self.header.signature.verify(self.body.to_bytes(), cmac_key)
43
+
44
+ def sha256(self) -> bytes:
45
+ """SHA-256 digest of the whole patch (header + body)."""
46
+ return hashlib.sha256(self.to_bytes()).digest()
47
+
48
+ def sha256_hex(self) -> str:
49
+ """SHA-256 digest of the whole patch (header + body) as a hex string."""
50
+ return hashlib.sha256(self.to_bytes()).hexdigest()
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ import struct
5
+ from dataclasses import dataclass
6
+
7
+ from amd_ucode_patch.utils.bcd import decode_bcd, encode_bcd, is_bcd
8
+
9
+
10
+ @dataclass
11
+ class PatchDate:
12
+ """
13
+ Build date stored in the first 32-bit word of an AMD microcode patch header.
14
+
15
+ The field is a little-endian, packed binary-coded-decimal value laid out as
16
+ 0xMMDDYYYY: the high byte holds the month, the next byte the day, and the low
17
+ 16 bits the four-digit year (e.g. 0x12282021 decodes to 2021-12-28).
18
+ """
19
+
20
+ FMT = "<HBB" # year (u16), day (u8), month (u8)
21
+ SIZE = struct.calcsize(FMT)
22
+
23
+ YEAR_NIBBLES = 4
24
+ DAY_NIBBLES = 2
25
+ MONTH_NIBBLES = 2
26
+ NIBBLE_COUNT = YEAR_NIBBLES + DAY_NIBBLES + MONTH_NIBBLES
27
+
28
+ # Loose calendar bounds, used only to reject obviously bogus values when
29
+ # probing a file.
30
+ MIN_YEAR = 1990
31
+ MAX_YEAR = 2099
32
+
33
+ # Known AMD patches that ship an out-of-range date but are otherwise
34
+ # legitimate. Treated as plausible despite failing the calendar checks.
35
+ # 2011-13-09 -> family12_cpuid00300F10_rev03000027 (month 13).
36
+ KNOWN_EXCEPTIONS = frozenset({(2011, 13, 9)})
37
+
38
+ year: int
39
+ month: int
40
+ day: int
41
+
42
+ @staticmethod
43
+ def from_bytes(buf: bytes) -> "PatchDate":
44
+ if len(buf) < PatchDate.SIZE:
45
+ raise ValueError("not enough bytes for AMD patch date")
46
+ raw_year, raw_day, raw_month = struct.unpack(PatchDate.FMT, buf[0:PatchDate.SIZE])
47
+ return PatchDate(
48
+ year=decode_bcd(raw_year, PatchDate.YEAR_NIBBLES),
49
+ day=decode_bcd(raw_day, PatchDate.DAY_NIBBLES),
50
+ month=decode_bcd(raw_month, PatchDate.MONTH_NIBBLES),
51
+ )
52
+
53
+ def to_bytes(self) -> bytes:
54
+ return struct.pack(
55
+ PatchDate.FMT,
56
+ encode_bcd(self.year, PatchDate.YEAR_NIBBLES),
57
+ encode_bcd(self.day, PatchDate.DAY_NIBBLES),
58
+ encode_bcd(self.month, PatchDate.MONTH_NIBBLES),
59
+ )
60
+
61
+ @staticmethod
62
+ def is_valid_encoding(buf: bytes) -> bool:
63
+ """
64
+ Return ``True`` if ``buf`` holds well-formed packed BCD that decodes to a
65
+ plausible calendar date. Intended as a lightweight signature check while
66
+ deciding whether a file looks like a microcode patch.
67
+ """
68
+ if len(buf) < PatchDate.SIZE:
69
+ return False
70
+ raw_year, raw_day, raw_month = struct.unpack(PatchDate.FMT, buf[0:PatchDate.SIZE])
71
+ if not (
72
+ is_bcd(raw_year, PatchDate.YEAR_NIBBLES)
73
+ and is_bcd(raw_day, PatchDate.DAY_NIBBLES)
74
+ and is_bcd(raw_month, PatchDate.MONTH_NIBBLES)
75
+ ):
76
+ return False
77
+ return PatchDate.from_bytes(buf).is_plausible()
78
+
79
+ def is_plausible(self) -> bool:
80
+ """Return ``True`` if the components fall within sane calendar ranges."""
81
+ if (self.year, self.month, self.day) in PatchDate.KNOWN_EXCEPTIONS:
82
+ return True
83
+ return (
84
+ 1 <= self.month <= 12
85
+ and 1 <= self.day <= 31
86
+ and PatchDate.MIN_YEAR <= self.year <= PatchDate.MAX_YEAR
87
+ )
88
+
89
+ def __str__(self) -> str:
90
+ return f"{self.year:04d}-{self.month:02d}-{self.day:02d}"