Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## [Unreleased]

### Security
- **UART recovery issues no challenge on a board without an entropy source.** When `eos_hal_rng_get()` failed, `recovery_handle_auth()` filled the 32-byte challenge from a linear congruential generator seeded with `eos_hal_get_tick_ms()`. The challenge is the only thing that stops a captured `(challenge, response)` pair from being replayed, and with that fallback it was a function of the millisecond at which the AUTH command was handled -- a few thousand values on a freshly reset board -- which the client sees before it has to answer and can retry after a reset at no cost in failure count. The AUTH is now refused outright (NACK, event `EOS_LOG_AUTH_NO_ENTROPY` = `0x23`, which `tools/uart_recovery.py` decodes; counted like any other failure so the backoff applies). No board port provides `rng_get` (0 of the 83 ops tables under `boards/`), so this was the challenge on every board; none provides `otp_read` either, so no board authenticates anyone today and the defect was latent. Pinned by `test_auth_refuses_when_the_board_has_no_entropy_source` in `tests/unit/test_recovery.c`.
- **UART recovery refuses an unprovisioned secret, and a write into an unmapped slot.** `recovery_handle_auth()` compared the client's response against `SHA-256(challenge || secret)` for whatever the OTP held at the secret offset -- and unprogrammed fuses read back as all zeros or all ones, both public, so a device whose recovery secret was never provisioned authenticated any client that knew that. Both patterns are now refused before the comparison (logged as event `0x22`), the way the keystore already refuses an all-zero key. `recovery_handle_write()` had stopped calling `eos_recovery_write_in_range()` -- the rule the unit tests exercise -- for an inline check that never looked at the slot base, so a slot the board leaves unmapped (base 0) turned "write at offset" into "write at flash address". The handler uses the shared rule again -- which also refuses a `WRITE` with `len == 0`, where the inline check had ACKed it and written nothing; a recovery client sees a NACK there now. No shipped board port provides `otp_read` today, so the first defect was latent (authentication always failed); both are pinned by `tests/unit/test_recovery.c`. The authentication outcomes are logged as `EOS_LOG_AUTH_SUCCESS` / `EOS_LOG_AUTH_FAIL` / `EOS_LOG_AUTH_UNPROVISIONED` (`0x20`-`0x22`, now defined in `include/eos_types.h` beside the other `EOS_LOG_*` codes instead of as bare hex), `tools/uart_recovery.py` decodes all three, and `tests/unit/test_boot_log_event_names.py` keeps the firmware, the header and the client's table in step.
- **The production-key gate covers every release-shaped build, and the key it accepts is checked against the curve.** The gate matched the literal string `Release` and nothing else, so `MinSizeRel`, `RelWithDebInfo`, `release`, `RELEASE` and an unset build type all configured a real board on the development anchor. It now exempts only `Debug`. Separately, `EBLDR_PRODUCTION_KEY` was checked for length and hex-ness but never for being a point on edwards25519 — the defect the development key itself had — so one mistyped hex digit in the release secret would have shipped a fleet that refuses every image, with a green build. `tools/check_production_key.py` applies the verifier's own rule (`[L]P == identity`, `P != identity`, not the development key) in pure Python; `cmake/ProductionKey.cmake` runs it at configure when python3 is present and warns in so many words when it is not, and `release.yml` runs it on the secret before any board is configured.
- **A release can no longer be built on the development trust anchor.** `EBLDR_PRODUCTION_KEY` had no CMake option, nothing set it, and its `#else` branch declared an `extern` nothing defined -- so every firmware job in `release.yml` compiled the `#ifndef` branch and shipped the RFC 8032 test key as the anchor for every board without OTP, which is every board under `boards/`. The only guard was a `#warning`. Now: `-DEBLDR_PRODUCTION_KEY=<64 hex characters>` generates `ebldr_production_key[]` (`cmake/ProductionKey.cmake`, declared in `include/eos_production_key.h`) and selects the production branch of `core/keystore.c`; a Release build of a real board refuses to configure without it unless it says `-DEBLDR_ALLOW_DEV_KEY=ON`; the development key and malformed values are refused as production keys; `release.yml` passes the `EBLDR_PRODUCTION_KEY_HEX` secret on every board configure (empty until a maintainer provides it, so a release fails closed) and scans every built `.elf`/`.bin`/`.a`/`.o` for the development key's bytes. `tests/unit/test_keystore_production.c` compiles the production branch with a fixture key on every host build; `tests/unit/test_production_key_gate.py` and `test_release_workflow_production_key.py` pin the gate and the workflow.
- **The compiled-in development trust anchor was not a point on the curve.** `core/keystore.c` `default_dev_key` was described as the RFC 8032 section 7.1 TEST 1 public key, but it diverged from that key at byte 21 and the bytes it held did not decode to a point on the curve, so no signature could ever verify against it. It is now the RFC key, and `tests/unit/test_keystore.c` checks that the anchor verifies the RFC's own signature. Behaviour change: a board with no OTP and no `EBLDR_PRODUCTION_KEY` goes from refusing every image (since #104 made signature verification unconditional at install) to accepting images signed with the public RFC test key -- the documented development intent of the `#warning` in `core/keystore.c`, and a key that must never reach a device. #120 tracks making that structural.
Expand Down
116 changes: 87 additions & 29 deletions core/recovery.c
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
/* Recovery protocol commands */
#define RCVR_CMD_PING 0x01
#define RCVR_CMD_INFO 0x02
/* Bits of the caps byte in the INFO response. */
#define RCVR_CAP_RNG 0x01 /* board has an entropy source: AUTH can issue a challenge */
#define RCVR_CAP_OTP 0x02 /* board has OTP: a shared secret can be provisioned */
/* INFO response: ack(1) + five little-endian uint32 (20) + caps(1). */
#define RCVR_INFO_LEN 22
#define RCVR_CMD_ERASE 0x03
#define RCVR_CMD_WRITE 0x04
#define RCVR_CMD_VERIFY 0x05
Expand Down Expand Up @@ -137,15 +142,19 @@ static int recovery_handle_auth(void)
}

if (auth_state == RCVR_AUTH_NONE) {
/* Generate challenge using RNG */
/* The challenge is the only thing that stops a captured response
* from being replayed, so it has to come from an entropy source.
* A board without one gets no challenge at all: a fallback seeded
* from the millisecond tick lets a client reset the board and
* retry, at no cost, until a challenge it already holds an answer
* for comes back. Refusing here is fail-closed in the same way as
* an unreadable or unprovisioned secret below. */
int rc = eos_hal_rng_get(challenge, RCVR_CHALLENGE_SIZE);
if (rc != EOS_OK) {
/* Fallback: use tick-based pseudo-random */
uint32_t seed = eos_hal_get_tick_ms();
for (int i = 0; i < RCVR_CHALLENGE_SIZE; i++) {
seed = seed * 1103515245 + 12345;
challenge[i] = (uint8_t)(seed >> 16);
}
auth_fail_count++;
auth_state = RCVR_AUTH_NONE;
eos_boot_log_append(EOS_LOG_AUTH_NO_ENTROPY, EOS_SLOT_NONE, auth_fail_count);
return recovery_send_nack();
}

/* Send challenge to client */
Expand Down Expand Up @@ -182,7 +191,24 @@ static int recovery_handle_auth(void)
/* Fail authentication if OTP secret is unreadable */
auth_fail_count++;
auth_state = RCVR_AUTH_NONE;
eos_boot_log_append(0x21, EOS_SLOT_NONE, auth_fail_count); /* AUTH_FAIL */
eos_boot_log_append(EOS_LOG_AUTH_FAIL, EOS_SLOT_NONE, auth_fail_count);
return recovery_send_nack();
}

/* Unprogrammed fuses read back as all zeros or all ones, and both
* are public. A board whose recovery secret was never provisioned
* must not authenticate anyone: the keystore already refuses an
* all-zero key for the same reason. Branch-free so the check does
* not leak which value the fuses hold. */
uint8_t all_zero = 0, all_ones = 0xFF;
for (size_t i = 0; i < sizeof(shared_secret); i++) {
all_zero |= shared_secret[i];
all_ones &= shared_secret[i];
}
if (all_zero == 0 || all_ones == 0xFF) {
auth_fail_count++;
auth_state = RCVR_AUTH_NONE;
eos_boot_log_append(EOS_LOG_AUTH_UNPROVISIONED, EOS_SLOT_NONE, auth_fail_count);
return recovery_send_nack();
}

Expand All @@ -199,12 +225,12 @@ static int recovery_handle_auth(void)
EOS_SHA256_DIGEST_SIZE) == 0) {
auth_state = RCVR_AUTH_AUTHENTICATED;
auth_fail_count = 0;
eos_boot_log_append(0x20, EOS_SLOT_NONE, 0); /* AUTH_SUCCESS */
eos_boot_log_append(EOS_LOG_AUTH_SUCCESS, EOS_SLOT_NONE, 0);
return recovery_send_ack();
} else {
auth_fail_count++;
auth_state = RCVR_AUTH_NONE;
eos_boot_log_append(0x21, EOS_SLOT_NONE, auth_fail_count); /* AUTH_FAIL */
eos_boot_log_append(EOS_LOG_AUTH_FAIL, EOS_SLOT_NONE, auth_fail_count);
return recovery_send_nack();
}
}
Expand All @@ -218,29 +244,58 @@ static int recovery_handle_ping(void)
return eos_hal_uart_send(response, sizeof(response));
}

/* Little-endian store: the recovery protocol's byte order, independent of
* the target's. Returns the next offset. */
static size_t rcvr_put_le32(uint8_t *buf, size_t at, uint32_t v)
{
buf[at + 0] = (uint8_t)(v & 0xFF);
buf[at + 1] = (uint8_t)((v >> 8) & 0xFF);
buf[at + 2] = (uint8_t)((v >> 16) & 0xFF);
buf[at + 3] = (uint8_t)((v >> 24) & 0xFF);
return at + 4;
}

static int recovery_handle_info(void)
{
const eos_board_ops_t *ops = eos_hal_get_ops();
if (!ops)
return recovery_send_nack();

struct {
uint8_t ack;
uint32_t flash_size;
uint32_t slot_a_addr;
uint32_t slot_a_size;
uint32_t slot_b_addr;
uint32_t slot_b_size;
} info;

info.ack = RCVR_ACK;
info.flash_size = ops->flash_size;
info.slot_a_addr = ops->slot_a_addr;
info.slot_a_size = ops->slot_a_size;
info.slot_b_addr = ops->slot_b_addr;
info.slot_b_size = ops->slot_b_size;

return eos_hal_uart_send(&info, sizeof(info));
/* The INFO response is a wire format, so it is built as bytes, not as a
* struct. Before this it was an unpacked struct -- 24 bytes, three of
* padding after ack that nothing wrote -- and sizeof(info) sent all 24:
* an unauthenticated caller (INFO needs no auth) received three bytes of
* whatever the previous call had left on the stack, and the repo's own
* client, which has always parsed the packed layout, printed them as the
* flash size. A byte buffer cannot have padding on any toolchain, needs
* no packing attribute or pragma to stay 22 bytes, and puts the five
* words on the wire little-endian regardless of the target's byte order
* -- which is what tools/uart_recovery.py has always assumed ('<IIIII').
* Layout: ack | flash_size | slot_a_addr | slot_a_size | slot_b_addr |
* slot_b_size | caps, uint32s little-endian. See docs/architecture.md.
*
* caps says what the board can do, so an integrator on a board with no
* entropy source learns that here, before authenticating -- which on
* such a board is impossible by construction, and cost 15 s of backoff
* to discover. It reveals nothing an attacker could not learn by trying
* (ADR-021).
*/
uint8_t info[RCVR_INFO_LEN];
size_t i = 0;
memset(info, 0, sizeof(info));
info[i++] = RCVR_ACK;
i = rcvr_put_le32(info, i, ops->flash_size);
i = rcvr_put_le32(info, i, ops->slot_a_addr);
i = rcvr_put_le32(info, i, ops->slot_a_size);
i = rcvr_put_le32(info, i, ops->slot_b_addr);
i = rcvr_put_le32(info, i, ops->slot_b_size);
info[i++] = (uint8_t)((ops->rng_get ? RCVR_CAP_RNG : 0) |
(ops->otp_read ? RCVR_CAP_OTP : 0));
/* Every byte accounted for: the length is the contract, not a sizeof. */
if (i != RCVR_INFO_LEN)
return recovery_send_nack();

return eos_hal_uart_send(info, sizeof(info));
}

static int recovery_handle_erase(eos_slot_t slot)
Expand Down Expand Up @@ -286,8 +341,11 @@ static int recovery_handle_write(eos_slot_t slot, uint32_t offset, uint16_t len)

/* offset/len come straight from the wire; without this check a
* recovery client can write past the slot boundary into the other
* slot, boot-control blocks, or the boot log. */
if (slot_size == 0 || (uint64_t)offset + len > (uint64_t)slot_size)
* slot, boot-control blocks, or the boot log. The rule lives in
* eos_recovery_write_in_range() -- the function the unit tests drive --
* and it also refuses a slot the board leaves unmapped (base 0), which
* would otherwise turn "write at offset" into "write at address". */
if (eos_recovery_write_in_range(base, slot_size, offset, len) != EOS_OK)
return recovery_send_nack();

recovery_send_ack();
Expand Down
70 changes: 70 additions & 0 deletions docs/adr/ADR-021-recovery-info-stays-unauthenticated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
adr: 21
title: Recovery INFO stays unauthenticated and carries a capability byte; LOG does not
status: Proposed
date: 2026-09-15
deciders: Architecture Council, eBoot maintainers
source: docs/threat_model.md T-404; the automated review of #131 (finding 2) and #139 (finding 4); .ai/security.md on documented weaker postures
---

# ADR-021 — Recovery INFO stays unauthenticated and carries a capability byte; LOG does not

## Context

`docs/threat_model.md` T-404 records the recovery `INFO` and `LOG` commands
as an information-disclosure surface, with the planned mitigation "Restrict
INFO/LOG to authenticated sessions (Phase 3)".

#131 makes authenticated recovery unreachable *by construction* on a board
with no entropy source: `AUTH` refuses to issue a challenge it cannot make
random. The event it logs, `EOS_LOG_AUTH_NO_ENTROPY`, is readable only over
`LOG`, which requires the authentication the missing entropy prevents. What an
integrator on such a board observed was a bare `NACK` — indistinguishable from
a wrong secret, an unreadable OTP, or an unprovisioned one — after
`0+1+2+4+8 = 15 s` of backoff across five attempts.

#139 answers that by adding one byte, `caps`, to the `INFO` response:
`RCVR_CAP_RNG` and `RCVR_CAP_OTP`, derived from the board's ops table. The
question "why does AUTH always NACK on this board?" is then answered before
the first `AUTH` is sent. That design depends on `INFO` being reachable
*before* authentication, which is exactly what T-404's planned mitigation
would remove. The two cannot both stand.

## Decision

1. **`INFO` remains unauthenticated, deliberately.** Its response is flash
geometry (five `uint32`) and the capability byte. Every one of those
values is something an attacker with UART access learns anyway — geometry
from the board's public documentation or from the first `ERASE` that
NACKs out of range, capabilities by attempting `AUTH` once. Disclosing
them costs nothing the attacker did not already have and buys the
integrator a diagnosis that otherwise costs 15 s and a wrong conclusion.

2. **`LOG` remains authenticated.** Boot history — which slot booted, why the
previous boot failed, recovery entry and exit reasons — is the material
T-404 is right to protect, and nothing in #131 or #139 needs it before
authentication.

3. **The `INFO` response is a wire contract:** 22 bytes, packed — `ack`,
five little-endian `uint32`, `caps` — defined in `core/recovery.c` with a
`_Static_assert` on its size, documented in `docs/architecture.md`, and
parsed by `tools/uart_recovery.py`, which refuses the 24-byte unpacked
response of pre-fix firmware rather than decoding it. Adding a field means
changing all three and the assert.

## Consequences

- T-404's mitigation narrows from "INFO/LOG" to "LOG", and its disclosure
column records `caps`. The `INFO` half is closed by this decision rather
than by Phase 3.
- A future field in `INFO` must pass the same test as `caps`: would an
attacker learn it by trying anyway? If not, it belongs behind `AUTH`, or in
`LOG`.
- The three bytes of uninitialised stack that `INFO` used to send (#140) are
a separate defect — a leak of *unintended* data — and are fixed regardless
of this decision.

## Note on numbering

ADR-020 is the highest number in use; this is 021. It stands alone rather
than extending ADR-020, which concerns the install path.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ superseded by a later record that names it.
| ADR | Title | Status |
|---|---|---|
| 020 | [Install path verifies the signature before anti-rollback](ADR-020-install-path-verifies-signature-before-anti-rollback.md) | Proposed |
| 021 | [Recovery INFO stays unauthenticated and carries a capability byte; LOG does not](ADR-021-recovery-info-stays-unauthenticated.md) | Proposed |

## Note on numbering

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Reset_Handler()
| Command | Code | Description |
|-----------|------|---------------------------------|
| PING | 0x01 | Identity check — returns "EOS" |
| INFO | 0x02 | Flash layout and slot addresses |
| INFO | 0x02 | Flash layout, slot addresses and capabilities. Unauthenticated (ADR-021). Response: 22 bytes packed -- `ack`, `flash_size`, `slot_a_addr`, `slot_a_size`, `slot_b_addr`, `slot_b_size` (LE `uint32`), `caps` (bit 0 RNG, bit 1 OTP) |
| ERASE | 0x03 | Erase target slot |
| WRITE | 0x04 | Write chunk to slot |
| VERIFY | 0x05 | Verify slot image integrity |
Expand Down
2 changes: 1 addition & 1 deletion docs/threat_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ External boundaries:
| T-401 | Spoofing | Unauthorized party connects to UART and replaces firmware | Medium | Critical | High | Recovery authentication — challenge-response or shared secret (Phase 3) | Planned |
| T-402 | Tampering | Malformed UART packets corrupt flash or boot state | Medium | High | High | Packet validation; length bounds checking; CRC on write data | Partial |
| T-403 | Denial of Service | Flood UART with garbage to prevent legitimate recovery | Low | Medium | Low | Command timeout; watchdog feed during recovery; rate limiting | Partial |
| T-404 | Information Disclosure | INFO and LOG commands leak device configuration and boot history | Low | Medium | Low | Restrict INFO/LOG to authenticated sessions (Phase 3) | Planned |
| T-404 | Information Disclosure | INFO discloses flash geometry and, since #139, a capability byte (`caps`: has RNG, has OTP); LOG discloses boot history | Low | Medium | Low | LOG restricted to authenticated sessions (Phase 3). INFO is deliberately unauthenticated per ADR-021: everything it discloses an attacker learns by trying, and the integrator needs it before AUTH to diagnose a board with no entropy source | Partial (INFO decided; LOG planned) |
| T-405 | Repudiation | Recovery operations not logged | Medium | Medium | Medium | Boot log records recovery entry, commands executed, exit reason | Planned |
| T-406 | Elevation of Privilege | FACTORY command erases all data without authorization | Medium | Critical | High | Require authentication before FACTORY; confirmation sequence | Planned |

Expand Down
Loading
Loading