Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 2 additions & 3 deletions ToDo.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@

# New features
- firmware download from git repository where releases are generated
- define how firmware should be downloaded, identified, etc.
- add option to download firmware the legacy way but with ability to specify server, password, login and define the structure with device id
- firmware download from git repository where releases are generated (define how firmware should be downloaded, identified, etc.)

13 changes: 6 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,13 @@ Five modules encapsulating all business logic.

Key objects:
- `HEADER_SIZE = 48` — total header size on disk.
- `DEVICE_HEADER_SIZE = 44` — header size sent to the device
(without the `prevAppVersion` field).
- `DEVICE_HEADER_SIZE = 48` — header size sent to the device (identical to the file header).
- `FirmwareHeader` — dataclass with fields and helpers (`format_product_id`,
`license_id`, `unique_id`, `payload_size`).
- `parse_header(bytes) -> FirmwareHeader` — parses the first 48 bytes.
- `load_firmware(path) -> (FirmwareHeader, bytes)` — parses from file.
- `build_device_header(bytes) -> bytes` — strips `prevAppVersion` before
transmission.
- `build_device_header(bytes) -> bytes` — returns the 48-byte wire header
(identical to the file header).
- `split_pages(payload, page_size) -> list[bytes]` — splits payload into
equal pages; an incomplete final page is dropped (matching C++ behaviour).

Expand Down Expand Up @@ -407,15 +406,15 @@ offset size field
4 4 productId (MSB)
8 4 productId (LSB)
12 4 appVersion
16 4 prevAppVersion ← stripped before sending to device
16 4 prevAppVersion
20 4 pageCount
24 4 flashPageSize
28 16 IV
44 4 crc32
48 … encrypted pages
```

Wire header = bytes `[0:16] + [20:48]` = 44 B (sent with the `START` command).
Wire header = bytes `[0:48]` = 48 B (sent with the `START` command, identical to the file header).

### Product ID Convention

Expand Down Expand Up @@ -459,7 +458,7 @@ operations (compatibility check, device matching) the full 64-bit value is used.
After ACK for `GET_VERSION` the device sends 16 bytes of device info
(`u32 bootloaderVersion`, `u64 productId`, `u32 flashPageSize`).

The host sends `START` (`0x02`) immediately followed by the 44 B wire header
The host sends `START` (`0x02`) immediately followed by the 48 B wire header
as a single transmission. The device responds ACK/NAK. Then for each page the
host sends `NEXT_PAGE` (`0x03`) + `flashPageSize` bytes, and the device ACKs.

Expand Down
30 changes: 14 additions & 16 deletions docs/FIRMWARE_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Host implementation: [`src/secure_loader/core/firmware.py`](https://github.com/n
2. [Header Layout (48 B)](#-header-layout-48-b)
3. [Field Semantics](#-field-semantics)
4. [productId Derived Fields](#-productid-derived-fields)
5. [Wire Header (44 B)](#-wire-header-44-b)
5. [Wire Header (48 B)](#-wire-header-48-b)
6. [Payload](#-payload)
7. [Host Parsing API](#-host-parsing-api)

Expand Down Expand Up @@ -89,7 +89,7 @@ informational purposes. Not validated by the bootloader protocol.
Version of the previous application image. Used by the host to request an
older firmware from the HTTP source (`fetch_previous`).
GitHub Releases support is planned — see [Roadmap](GITHUB_SOURCE_MIGRATION.md).
This field is **not** sent to the device — see [Wire Header](#-wire-header-44-b).
This field is transmitted to the device as part of the 48-byte wire header — see [Wire Header](#-wire-header-48-b).

### `pageCount` (u32)

Expand Down Expand Up @@ -148,38 +148,36 @@ for the full fetch workflow.

---

## 📡 Wire Header (44 B)
## 📡 Wire Header (48 B)

The header that the device receives during a firmware update is **not** the
full 48-byte disk header. The `prevAppVersion` field (bytes 16–19) is
stripped before transmission.
The header transmitted to the device during a firmware update is identical to
the full 48-byte file header. All fields — including `prevAppVersion` — are
sent as-is.

```mermaid
flowchart LR
subgraph disk["Disk header — 48 B"]
subgraph disk["File header — 48 B"]
A["bytes 0–15<br/>protocolVersion<br/>productId MSB + LSB<br/>appVersion"]
B["bytes 16–19<br/>prevAppVersion"]
C["bytes 20–47<br/>pageCount · flashPageSize<br/>IV · crc32"]
end
subgraph wire["Wire header — 44 B (sent to device)"]
subgraph wire["Wire header — 48 B (sent to device)"]
W["START command payload"]
end
A -- "copied" --> W
B -- "copied" --> W
C -- "copied" --> W
B -. "❌ stripped" .- W

style B fill:#ffe0e0,stroke:#cc6666
```

Expressed as a slice operation:

```
wire_header = disk_header[0:16] + disk_header[20:48]
wire_header = disk_header[0:48]
= protocolVersion + productId (MSB+LSB) + appVersion
+ pageCount + flashPageSize + IV + crc32
+ prevAppVersion + pageCount + flashPageSize + IV + crc32
```

This 44-byte block is the payload of the `START` command.
This 48-byte block is the payload of the `START` command.

---

Expand All @@ -205,7 +203,7 @@ flowchart LR
file["📄 .bin file"] --> lf["load_firmware(path)"]
lf --> hdr["FirmwareHeader\n(frozen dataclass)"]
lf --> raw["raw bytes"]
raw --> bdh["build_device_header(raw)\n→ 44 B wire header"]
raw --> bdh["build_device_header(raw)\n→ 48 B wire header"]
raw --> sp["split_pages(payload, page_size)\n→ list[bytes]"]
hdr --> fields["license_id · unique_id\npayload_size\nformat_product_id() · …"]
```
Expand All @@ -214,7 +212,7 @@ flowchart LR
from secure_loader.core.firmware import (
parse_header, # parse_header(data: bytes) -> FirmwareHeader
load_firmware, # load_firmware(path) -> (FirmwareHeader, bytes)
build_device_header, # build_device_header(raw: bytes) -> bytes (44 B)
build_device_header, # build_device_header(raw: bytes) -> bytes (48 B)
split_pages, # split_pages(payload, page_size) -> list[bytes]
)
```
Expand Down
10 changes: 5 additions & 5 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ delimiter.
| Name | Code | Payload | Meaning |
|------|------|---------|---------|
| `GET_VERSION` | `0x01` | — | Request device info |
| `START` | `0x02` | 44 B wire header | Begin firmware transfer |
| `START` | `0x02` | 48 B wire header | Begin firmware transfer |
| `NEXT_PAGE` | `0x03` | `flashPageSize` B | One payload page |
| `RESET` | `0x04` | — | Soft-reset the device |
| `NONE` | `0x00` | — | Reserved, unused |
Expand Down Expand Up @@ -100,8 +100,8 @@ One rule covers all commands — no translation table needed on either side.

### `START` (`0x02`)

**Host sends:** `0x02`, then the **44-byte wire header**
(see [Firmware Format — Wire Header](FIRMWARE_FORMAT.md#-wire-header-44-b)).
**Host sends:** `0x02`, then the **48-byte wire header**
(see [Firmware Format — Wire Header](FIRMWARE_FORMAT.md#-wire-header-48-b)).

**Device responds:**

Expand Down Expand Up @@ -157,7 +157,7 @@ sequenceDiagram

Note over H: validates compatibility<br/>(bootloaderVersion == protocolVersion, productId match)

H->>D: 0x02 START + 44 B wire header
H->>D: 0x02 START + 48 B wire header
D->>H: 0x42 ACK (flash erased, decryption ready)

loop pageCount pages
Expand Down Expand Up @@ -272,7 +272,7 @@ stateDiagram-v2
[*] --> WAIT_CMD : power on / reset

WAIT_CMD --> WAIT_CMD : 0x01 GET_VERSION → reply 0x41 + 16 B info
WAIT_CMD --> READ_HEADER : 0x02 START → read 44 B wire header
WAIT_CMD --> READ_HEADER : 0x02 START → read 48 B wire header
WAIT_CMD --> [*] : 0x04 RESET → reply 0x44 · reset

READ_HEADER --> WAIT_CMD : header invalid → 0x82 NAK
Expand Down
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ The command sequence:
1. Opens the serial port and polls `GET_VERSION` every 500 ms until the bootloader responds.
2. Reads device info (bootloader version, product ID, page size).
3. Checks compatibility — product ID and protocol version must match the firmware header (unless `--force`).
4. Sends `START` with the 44-byte wire header.
4. Sends `START` with the 48-byte wire header.
5. Streams all `pageCount` pages, reporting progress to stdout.
6. Reports success or failure.

Expand Down
27 changes: 9 additions & 18 deletions src/secure_loader/core/firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@

The 64-bit productId is reconstructed as ``(MSB << 32) | LSB``.

The header that the device actually receives during a firmware update does
**not** contain ``prevAppVersion`` — that field is stripped before transmission
(see :class:`DeviceHeader`). ``prevAppVersion`` is only used by the host-side
downloader to request the previous version from a remote source.
The wire header sent to the device during a firmware update is identical to
the full 48-byte file header. ``prevAppVersion`` is transmitted and the
bootloader struct now includes it (bootloader v1.2+).
"""

from __future__ import annotations
Expand All @@ -38,8 +37,8 @@
HEADER_SIZE: int = 48
"""Total size of the firmware header in bytes."""

DEVICE_HEADER_SIZE: int = 44
"""Size of the header sent to the device (header without ``prevAppVersion``)."""
DEVICE_HEADER_SIZE: int = HEADER_SIZE
"""Size of the header sent to the device (identical to the file header, 48 bytes)."""

IV_SIZE: int = 16
"""Size of the initialization vector in bytes."""
Expand Down Expand Up @@ -214,24 +213,16 @@ def load_firmware(path: str | Path) -> tuple[FirmwareHeader, bytes]:


def build_device_header(raw: bytes | bytearray) -> bytes:
"""Build the header that is actually transmitted to the device.
"""Return the 48-byte wire header transmitted to the device during CMD_START.

Wire header = bytes ``[0:16]`` (protocol + productId + appVersion)
concatenated with bytes ``[20:48]`` (pageCount + pageLen + IV + CRC),
dropping the 4-byte ``prevAppVersion`` field.
The wire header is identical to the file header — all fields including
``prevAppVersion`` are transmitted (bootloader struct is 48 bytes).
"""
if len(raw) < HEADER_SIZE:
raise FirmwareFormatError(
f"firmware too short: need at least {HEADER_SIZE} bytes, got {len(raw)}"
)
first = bytes(raw[0:16])
second = bytes(raw[20:HEADER_SIZE])
wire = first + second
if len(wire) != DEVICE_HEADER_SIZE:
raise FirmwareFormatError(
f"built device header is {len(wire)} bytes, expected {DEVICE_HEADER_SIZE}"
)
return wire
return bytes(raw[0:HEADER_SIZE])


def split_pages(payload: bytes, page_size: int) -> list[bytes]:
Expand Down
16 changes: 11 additions & 5 deletions tests/test_firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,19 @@ def test_accepts_extra_trailing_bytes(self, sample_header_bytes: bytes) -> None:


class TestBuildDeviceHeader:
def test_strips_prev_app_version_field(self, sample_firmware: bytes) -> None:
def test_wire_header_is_full_48_byte_file_header(self, sample_firmware: bytes) -> None:
wire = build_device_header(sample_firmware)
assert len(wire) == 48
assert len(wire) == DEVICE_HEADER_SIZE
# First 16 bytes of the file are transmitted verbatim.
assert wire[:16] == sample_firmware[:16]
# Then comes [20:48] — the 4 bytes at [16:20] (prevAppVersion) are skipped.
assert wire[16:] == sample_firmware[20:HEADER_SIZE]
# Wire header is identical to the first HEADER_SIZE bytes of the file.
assert wire == sample_firmware[:HEADER_SIZE]

def test_prev_app_version_present_at_offset_16(self, sample_firmware: bytes) -> None:
wire = build_device_header(sample_firmware)
# prevAppVersion is at bytes [16:20] in both the file and the wire header.
assert wire[16:20] == sample_firmware[16:20]
# Confirm the value is non-zero (matches the fixture's 0x01020300).
assert wire[16:20] != b"\x00\x00\x00\x00"

def test_rejects_short_blobs(self) -> None:
with pytest.raises(FirmwareFormatError):
Expand Down
Loading