Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions actions/sign-firmware/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: "Sign Firmware (Ed25519)"
description: |
Sign an Intel HEX firmware image with Ed25519 and patch the 64-byte
signature into a fixed region at app_end - 68. Does NOT compute CRC --
run the existing CRC step AFTER this action.

inputs:
hex-path:
description: "Path to the input Intel HEX file"
required: true
app-start:
description: "App slot start address (e.g. 0x08020000)"
required: true
app-end:
description: "App slot end address, exclusive (e.g. 0x08040000)"
required: true
output-path:
description: "Output HEX path (defaults to in-place modification of hex-path)"
required: false
default: ""
private-key:
description: "PEM-encoded Ed25519 private key contents (pass secrets.FIRMWARE_SIGNING_PRIVATE_KEY)"
required: true

runs:
using: "composite"
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: Install signing dependencies
shell: bash
run: |
set -euo pipefail
python -m pip install --upgrade pip
python -m pip install "cryptography>=41" "intelhex>=2.3"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

find . -name "action.yml" -type f | head -20

Repository: minimalx/.github

Length of output: 543


🏁 Script executed:

cat -n ./actions/sign-firmware/action.yml

Repository: minimalx/.github

Length of output: 2081


🏁 Script executed:

ls -la ./actions/sign-firmware/

Repository: minimalx/.github

Length of output: 358


🏁 Script executed:

find . -name "requirements*.txt" -o -name "setup.py" -o -name "pyproject.toml" | grep -v node_modules

Repository: minimalx/.github

Length of output: 42


🏁 Script executed:

rg "actions/setup-python" --type yaml | head -10

Repository: minimalx/.github

Length of output: 274


🏁 Script executed:

cat ./actions/sign-firmware/sign_firmware.py | head -50

Repository: minimalx/.github

Length of output: 1514


🏁 Script executed:

rg "cryptography|intelhex" ./actions/sign-firmware/ -A 2 -B 2

Repository: minimalx/.github

Length of output: 1612


🌐 Web query:

GitHub Actions SHA pinning setup-python commit reference syntax best practices

💡 Result:

To set up SHA pinning for the setup-python action in GitHub Actions, use the full commit SHA instead of a tag like v4 or v5 in the 'uses' statement. This is the recommended best practice for security to prevent supply chain attacks where tags could be rewritten or repositories compromised [1][2][3]. Syntax: uses: actions/setup-python@ # vX.Y.Z Example for a pinned version (replace with current SHA): - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v4.0.0 [67] with: python-version: '3.13' cache: 'pip' Note: This SHA is from an older example; always verify the latest [4]. How to find the SHA for a tag: 1. Go to https://github.com/actions/setup-python/releases/tag/v6.2.0 (latest as of 2026-01-22 [5]). 2. Click the commit SHA next to the tag (or use git ls-remote --tags https://github.com/actions/setup-python | grep v6.2.0) [6][7]. 3. Copy the full 40-character SHA. 4. Verify it's from the official repo, not a fork [2]. 5. Add a comment like # v6.2.0 for readability [8][7]. Best practices: - Pin to full SHA (not short SHA, insecure) [2][3]. - Use tools like Hyland/github-actions-ensure-sha-pinned-actions to automate [8]. - Enable org/repo policies to enforce SHA pinning [1]. - Update periodically via Dependabot or manual checks, testing workflows after [9][7]. - For python-version input, specify explicitly (e.g., '3.13') rather than relying on PATH [10][11]. Official docs emphasize pinning actions to SHA for immutability [2][3]. Current latest is v6.2.0 [4][5].

Citations:


🌐 Web query:

pip install --require-hashes requirements.txt Python dependency locking best practices

💡 Result:

Using pip install --require-hashes -r requirements.txt is a best practice for secure, reproducible Python dependency installations. It enables pip's hash-checking mode, which verifies cryptographic hashes (typically SHA256) for every package and its transitive dependencies before installation, protecting against tampering, supply chain attacks, network corruption, or malicious changes on PyPI [1][2][3]. This mode is all-or-nothing: all requirements must be pinned (using ==, URL, or path) and have hashes; missing any triggers an error [1][3]. To create a suitable requirements.txt: 1. List direct dependencies (with version ranges if desired) in requirements.in, e.g.: requests>=2.31.0 flask==3.0.0 2. Install pip-tools (pip install pip-tools) [4][5][6]. 3. Generate the locked file: pip-compile --generate-hashes -o requirements.txt requirements.in. This resolves all transitive dependencies, pins exact versions, adds multiple hashes per package (for different wheels), and includes # via comments showing dependency trees [4][7][8][5][9][6][10]. Example output line: requests==2.32.3 \ --hash=sha256:abc... \ --hash=sha256:def... # via -r requirements.in For installation in production/CI: pip install --require-hashes --no-deps -r requirements.txt - --no-deps prevents fetching unlisted dependencies [1][2][11]. - Optionally add --only-binary :all: to avoid source builds [1][11]. Workflow: - Edit requirements.in for changes. - Re-run pip-compile --generate-hashes (add --upgrade to update all, or --upgrade-package=pkg for specific) [5][6][12]. - Commit both .in and .txt files [13][4][14]. - Use pip-sync requirements.txt (from pip-tools) to sync environments exactly [5][12]. This ensures reproducibility across machines, detects tampering (hash mismatch errors) [15], and is recommended for deployments over plain pinning [2][7]. Avoid pip freeze > requirements.txt as it includes unrelated packages and lacks hashes [13][4]. For modern projects, consider tools like Poetry or PDM, but pip-tools remains lightweight for requirements.txt workflows [16][17][18].

Citations:


Pin GitHub Actions and Python dependencies to ensure firmware-signing reproducibility and meet supply-chain security standards.

Floating action versions (@v5) and Python package ranges (>=41, >=2.3) can silently change the signing runtime across CI runs. Following established security practices, immutable pinning is essential for firmware provenance and traceability. Pin the action to its full commit SHA and lock Python dependencies with verified hashes.

Suggested approach
-      uses: actions/setup-python@v5
+      uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.x.x

Create $GITHUB_ACTION_PATH/requirements.txt with pinned versions and SHA256 hashes (using pip-tools):

-        python -m pip install "cryptography>=41" "intelhex>=2.3"
+        python -m pip install --require-hashes -r "$GITHUB_ACTION_PATH/requirements.txt"

Generate hashes with: pip-compile --generate-hashes requirements.in -o requirements.txt

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@actions/sign-firmware/action.yml` around lines 29 - 39, Replace the floating
action and loose package installs in the "Install signing dependencies" step by
pinning the GitHub Action to its full commit SHA (instead of
actions/setup-python@v5) and move the pip installs (the lines calling "python -m
pip install --upgrade pip" and "python -m pip install "cryptography>=41"
"intelhex>=2.3"") into a requirements.txt with exact versions and SHA256 hashes;
generate that requirements.txt with pip-compile --generate-hashes from a
requirements.in and update the workflow to run "python -m pip install -r
$GITHUB_ACTION_PATH/requirements.txt" so the signing runtime is reproducible and
verifiable.

- name: Sign firmware
shell: bash
env:
FIRMWARE_SIGNING_PRIVATE_KEY: ${{ inputs.private-key }}
run: |
set -euo pipefail
args=(
--hex "${{ inputs.hex-path }}"
--app-start "${{ inputs.app-start }}"
--app-end "${{ inputs.app-end }}"
)
if [ -n "${{ inputs.output-path }}" ]; then
args+=(--output "${{ inputs.output-path }}")
Comment on lines +40 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid direct expression interpolation inside shell commands.

Line 40–Line 45 interpolate ${{ inputs.* }} directly into bash source. That pattern is fragile and can become command/script injection if inputs are ever derived from untrusted workflow data. Map inputs to env vars first, then reference normal shell variables.

Suggested hardening
     - name: Sign firmware
       shell: bash
       env:
         FIRMWARE_SIGNING_PRIVATE_KEY: ${{ inputs.private-key }}
+        HEX_PATH: ${{ inputs.hex-path }}
+        APP_START: ${{ inputs.app-start }}
+        APP_END: ${{ inputs.app-end }}
+        OUTPUT_PATH: ${{ inputs.output-path }}
       run: |
         set -euo pipefail
         args=(
-          --hex "${{ inputs.hex-path }}"
-          --app-start "${{ inputs.app-start }}"
-          --app-end "${{ inputs.app-end }}"
+          --hex "$HEX_PATH"
+          --app-start "$APP_START"
+          --app-end "$APP_END"
         )
-        if [ -n "${{ inputs.output-path }}" ]; then
-          args+=(--output "${{ inputs.output-path }}")
+        if [ -n "$OUTPUT_PATH" ]; then
+          args+=(--output "$OUTPUT_PATH")
         fi
         sh "$GITHUB_ACTION_PATH/run.sh" "${args[@]}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@actions/sign-firmware/action.yml` around lines 40 - 45, The workflow
currently interpolates expressions like `${{ inputs.hex-path }}`, `${{
inputs.app-start }}`, `${{ inputs.app-end }}`, and `${{ inputs.output-path }}`
directly into the shell command in action.yml (see the args+=(...) lines);
replace these direct interpolations by first mapping each input to an
environment variable (e.g., export HEX_PATH="${{ inputs.hex-path }}" etc.) in
the step's env or by using the action's env mapping, then update the shell
invocation to reference the safe shell variables (e.g., "$HEX_PATH") when
building args (e.g., args+=(--hex "$HEX_PATH") and args+=(--output
"$OUTPUT_PATH")), and audit other occurrences in action.yml for similar direct
`${{ inputs.* }}` usage to harden against injection.

fi
python "$GITHUB_ACTION_PATH/sign_firmware.py" "${args[@]}"
184 changes: 184 additions & 0 deletions actions/sign-firmware/sign_firmware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Sign an Intel HEX firmware image with Ed25519.

Layout (fixed):
sig region: 64 bytes at app_end - 68
crc region: 4 bytes at app_end - 4 (NOT touched here; computed by a later step)
sig covers: bytes [app_start, app_end - 68), gaps padded with 0xFF

Key resolution (in order):
1. FIRMWARE_SIGNING_PRIVATE_KEY env var (PEM contents) -- CI path
2. --private-key-file FILE -- local explicit path
3. --allow-dummy-key -- uses tests/dummy_private_key.pem next to this script
(committed, non-secret, for local pipeline smoke tests only)

Usage:
python sign_firmware.py \\
--hex firmware.hex \\
--app-start 0x08020000 \\
--app-end 0x08040000 \\
[--output firmware_signed.hex] \\
[--private-key-file key.pem | --allow-dummy-key]
"""
from __future__ import annotations

import argparse
import os
import sys
from pathlib import Path

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from intelhex import IntelHex

SIG_LEN = 64
CRC_LEN = 4
TRAILER_LEN = SIG_LEN + CRC_LEN # 68 bytes reserved at end of app slot
PAD_BYTE = 0xFF # erased flash state on STM32


class SigningError(RuntimeError):
pass


def parse_address(value: str) -> int:
text = value.strip().replace("_", "")
try:
return int(text, 16) if text.lower().startswith("0x") else int(text, 0)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"Invalid address {value!r}") from exc


def load_private_key(args: argparse.Namespace) -> Ed25519PrivateKey:
pem = os.environ.get("FIRMWARE_SIGNING_PRIVATE_KEY")
source = "FIRMWARE_SIGNING_PRIVATE_KEY env"

if not pem and args.private_key_file:
pem = Path(args.private_key_file).read_text()
source = f"file {args.private_key_file}"
Comment on lines +61 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cd actions/sign-firmware && wc -l sign_firmware.py

Repository: minimalx/.github

Length of output: 81


🏁 Script executed:

cd actions/sign-firmware && head -80 sign_firmware.py | tail -30 && echo "---" && sed -n '60,85p' sign_firmware.py && echo "---" && sed -n '170,185p' sign_firmware.py

Repository: minimalx/.github

Length of output: 2672


🏁 Script executed:

cd actions/sign-firmware && cat -n sign_firmware.py | grep -A5 -B5 "def main\|SigningError\|load_pem_private_key" | head -80

Repository: minimalx/.github

Length of output: 3286


🏁 Script executed:

cd actions/sign-firmware && cat -n sign_firmware.py

Repository: minimalx/.github

Length of output: 7548


🏁 Script executed:

python3 << 'EOF'
# Check what exceptions load_pem_private_key can raise
from cryptography.hazmat.primitives import serialization

# Test invalid PEM
test_cases = [
    ("invalid_pem", "not a valid PEM"),
    ("empty_pem", ""),
    ("wrong_format", "-----BEGIN RSA PRIVATE KEY-----\ninvalid\n-----END RSA PRIVATE KEY-----"),
]

exception_types = set()
for name, pem in test_cases:
    try:
        serialization.load_pem_private_key(pem.encode(), password=None)
    except Exception as e:
        exception_types.add(type(e).__name__)
        print(f"{name}: {type(e).__module__}.{type(e).__name__}: {e}")

print(f"\nException types raised: {exception_types}")
EOF

Repository: minimalx/.github

Length of output: 614


🏁 Script executed:

python3 << 'EOF'
# Check what Path.read_text can raise
from pathlib import Path
import tempfile
import os

# Test 1: Non-existent file
try:
    Path("/nonexistent/file.txt").read_text()
except Exception as e:
    print(f"Non-existent: {type(e).__module__}.{type(e).__name__}")

# Test 2: Permission denied
with tempfile.TemporaryDirectory() as tmpdir:
    test_file = Path(tmpdir) / "test.txt"
    test_file.write_text("test")
    os.chmod(test_file, 0o000)
    try:
        test_file.read_text()
    except Exception as e:
        print(f"Permission denied: {type(e).__module__}.{type(e).__name__}")
    finally:
        os.chmod(test_file, 0o644)

# Test 3: Invalid UTF-8
with tempfile.TemporaryDirectory() as tmpdir:
    test_file = Path(tmpdir) / "invalid.txt"
    test_file.write_bytes(b'\x80\x81\x82')
    try:
        test_file.read_text()
    except Exception as e:
        print(f"Invalid UTF-8: {type(e).__module__}.{type(e).__name__}")

print("\nAll above inherit from OSError or ValueError")
EOF

Repository: minimalx/.github

Length of output: 234


🏁 Script executed:

rg "UnsupportedAlgorithm" --type py

Repository: minimalx/.github

Length of output: 42


🏁 Script executed:

grep -n "read_text\|load_pem_private_key" actions/sign-firmware/sign_firmware.py

Repository: minimalx/.github

Length of output: 229


Wrap key-loading failures in SigningError for consistent CLI error handling.

Currently, Path.read_text() at lines 62 and 68 can raise FileNotFoundError, PermissionError, or UnicodeDecodeError (all OSError subclasses). Similarly, serialization.load_pem_private_key() at line 82 can raise ValueError, TypeError, or UnsupportedAlgorithm. These exceptions bypass the SigningError handler in main() (lines 177–179) and escape as raw tracebacks rather than the intended sign-firmware: ... format. Converting them to SigningError ensures deterministic, user-friendly error messages and maintains a robust error contract.

Proposed fix
 from cryptography.exceptions import InvalidSignature
+from cryptography.exceptions import UnsupportedAlgorithm
@@
     if not pem and args.private_key_file:
-        pem = Path(args.private_key_file).read_text()
+        try:
+            pem = Path(args.private_key_file).read_text(encoding="utf-8")
+        except OSError as exc:
+            raise SigningError(
+                f"Unable to read private key file {args.private_key_file}: {exc}"
+            ) from exc
         source = f"file {args.private_key_file}"
@@
     elif not pem and args.allow_dummy_key:
         dummy = Path(__file__).parent / "tests" / "dummy_private_key.pem"
         if not dummy.exists():
             raise SigningError(f"Dummy key not found at {dummy}")
-        pem = dummy.read_text()
+        try:
+            pem = dummy.read_text(encoding="utf-8")
+        except OSError as exc:
+            raise SigningError(
+                f"Unable to read dummy private key: {exc}"
+            ) from exc
@@
-    key = serialization.load_pem_private_key(pem.encode(), password=None)
+    try:
+        key = serialization.load_pem_private_key(pem.encode(), password=None)
+    except (ValueError, TypeError, UnsupportedAlgorithm) as exc:
+        raise SigningError(
+            "Invalid private key PEM. Provide an unencrypted Ed25519 private key."
+        ) from exc

Also applies to: line 68 (dummy key read).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@actions/sign-firmware/sign_firmware.py` around lines 61 - 63, Wrap file reads
that assign pem (the blocks reading Path(args.private_key_file).read_text() and
the dummy key read) in try/except catching OSError (FileNotFoundError,
PermissionError, UnicodeDecodeError) and re-raise as SigningError with a clear
message including the source variable (e.g., args.private_key_file or the dummy
key source). Likewise wrap the call to serialization.load_pem_private_key(...)
in a try/except catching ValueError, TypeError, and
cryptography.exceptions.UnsupportedAlgorithm and re-raise a SigningError with
context about which key was being loaded (use the same source name). Ensure you
reference the existing SigningError type so the main() handler will format the
error consistently.

elif not pem and args.allow_dummy_key:
dummy = Path(__file__).parent / "tests" / "dummy_private_key.pem"
if not dummy.exists():
raise SigningError(f"Dummy key not found at {dummy}")
pem = dummy.read_text()
source = "DUMMY KEY (--allow-dummy-key)"
print(
"WARNING: signing with the committed dummy key. "
"DO NOT USE IN PRODUCTION.",
file=sys.stderr,
)

if not pem:
raise SigningError(
"No private key provided. Set FIRMWARE_SIGNING_PRIVATE_KEY env, "
"pass --private-key-file, or use --allow-dummy-key for local testing."
)

key = serialization.load_pem_private_key(pem.encode(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise SigningError(
f"Expected an Ed25519 private key, got {type(key).__name__}"
)
print(f"Using private key from: {source}", file=sys.stderr)
return key


def extract_payload(ih: IntelHex, app_start: int, sig_offset: int) -> bytes:
if app_start >= sig_offset:
raise SigningError(
f"app_start (0x{app_start:08X}) must be below sig_offset (0x{sig_offset:08X})"
)
ih.padding = PAD_BYTE
return bytes(ih.tobinarray(start=app_start, size=sig_offset - app_start))


def patch_signature(ih: IntelHex, sig_offset: int, signature: bytes) -> None:
if len(signature) != SIG_LEN:
raise SigningError(f"Signature must be {SIG_LEN} bytes, got {len(signature)}")
for i, byte in enumerate(signature):
ih[sig_offset + i] = byte


def sign_hex(
*,
hex_path: Path,
app_start: int,
app_end: int,
private_key: Ed25519PrivateKey,
output_path: Path | None = None,
) -> Path:
if app_end - app_start <= TRAILER_LEN:
raise SigningError(
f"App slot too small: end-start = {app_end - app_start} bytes, "
f"need > {TRAILER_LEN}"
)
sig_offset = app_end - TRAILER_LEN

ih = IntelHex(str(hex_path))
payload = extract_payload(ih, app_start, sig_offset)
signature = private_key.sign(payload)
patch_signature(ih, sig_offset, signature)

out = output_path or hex_path
ih.write_hex_file(str(out))
print(
f"Signed {len(payload)} bytes from 0x{app_start:08X} to 0x{sig_offset:08X}; "
f"signature written at 0x{sig_offset:08X} in {out}",
file=sys.stderr,
)
return out


def verify_signed_hex(
*,
hex_path: Path,
app_start: int,
app_end: int,
public_key: Ed25519PublicKey,
) -> bool:
sig_offset = app_end - TRAILER_LEN
ih = IntelHex(str(hex_path))
payload = extract_payload(ih, app_start, sig_offset)
signature = bytes(ih.tobinarray(start=sig_offset, size=SIG_LEN))
try:
public_key.verify(signature, payload)
return True
except InvalidSignature:
return False


def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--hex", required=True, type=Path, help="Input Intel HEX file")
parser.add_argument("--app-start", required=True, type=parse_address, help="App slot start address")
parser.add_argument("--app-end", required=True, type=parse_address, help="App slot end address (exclusive)")
parser.add_argument("--output", type=Path, default=None, help="Output hex (default: in-place)")
parser.add_argument("--private-key-file", type=Path, default=None, help="Path to PEM private key")
parser.add_argument("--allow-dummy-key", action="store_true", help="Use committed dummy key for local testing")
return parser.parse_args(argv)


def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
priv = load_private_key(args)
sign_hex(
hex_path=args.hex,
app_start=args.app_start,
app_end=args.app_end,
private_key=priv,
output_path=args.output,
)
except SigningError as exc:
print(f"sign-firmware: {exc}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
3 changes: 3 additions & 0 deletions actions/sign-firmware/tests/dummy_private_key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 Gitleaks has detected a secret with rule-id private-key in commit 6f4c6ff.
If this secret is a true positive, please rotate the secret ASAP.

If this secret is a false positive, you can add the fingerprint below to your .gitleaksignore file and commit the change to this branch.

echo 6f4c6ffaf316f20ecc539f8e437ea9ce629e5cda:actions/sign-firmware/tests/dummy_private_key.pem:private-key:1 >> .gitleaksignore

MC4CAQAwBQYDK2VwBCIEIONneOHFBbq8a5/pmSWd0Ol3A5kOOHz8jW/5T5RkR+Sr
-----END PRIVATE KEY-----
3 changes: 3 additions & 0 deletions actions/sign-firmware/tests/dummy_public_key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAulpl/okOwDXiz5hkCyp1fXSY2vPMOiF0dxjhFMSKIT0=
-----END PUBLIC KEY-----
Loading
Loading