Skip to content

Commit d3ae185

Browse files
prerna1001srpatcha
andauthored
Implement authenticated recovery log retrieval (#31)
Co-authored-by: Srikanth Patchava <srikanth.patchava@outlook.com>
1 parent 5364fd3 commit d3ae185

6 files changed

Lines changed: 376 additions & 50 deletions

File tree

core/recovery.c

Lines changed: 132 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
#define RCVR_MAX_AUTH_FAILS 5
4545
#define RCVR_BACKOFF_BASE_MS 1000
4646

47+
#define RCVR_LOG_MAX_ENTRIES 8
48+
4749
/* Recovery protocol packet header */
4850
#ifdef _MSC_VER
4951
#pragma pack(push, 1)
@@ -80,6 +82,8 @@ extern int eos_slot_erase(eos_slot_t slot);
8082

8183
/* Forward declarations from boot_log */
8284
extern void eos_boot_log_append(uint32_t event, uint32_t slot, uint32_t detail);
85+
extern int eos_boot_log_read(uint32_t index, eos_boot_log_entry_t *out);
86+
extern uint32_t eos_boot_log_get_head(void);
8387

8488
static int recovery_send_ack(void)
8589
{
@@ -96,19 +100,20 @@ static int recovery_send_nack(void)
96100
/**
97101
* @brief Check if a command requires authentication.
98102
*/
99-
static bool cmd_requires_auth(uint8_t cmd)
100-
{
101-
switch (cmd) {
102-
case RCVR_CMD_ERASE:
103-
case RCVR_CMD_WRITE:
104-
case RCVR_CMD_VERIFY:
105-
case RCVR_CMD_BOOT:
106-
case RCVR_CMD_FACTORY:
107-
return true;
108-
default:
109-
return false;
110-
}
111-
}
103+
static bool cmd_requires_auth(uint8_t cmd)
104+
{
105+
switch (cmd) {
106+
case RCVR_CMD_ERASE:
107+
case RCVR_CMD_WRITE:
108+
case RCVR_CMD_VERIFY:
109+
case RCVR_CMD_BOOT:
110+
case RCVR_CMD_FACTORY:
111+
case RCVR_CMD_LOG:
112+
return true;
113+
default:
114+
return false;
115+
}
116+
}
112117

113118
/**
114119
* @brief Handle authentication challenge-response.
@@ -169,18 +174,18 @@ static int recovery_handle_auth(void)
169174
eos_sha256_init(&ctx);
170175
eos_sha256_update(&ctx, challenge, RCVR_CHALLENGE_SIZE);
171176

172-
/* Read shared secret from OTP */
173-
uint8_t shared_secret[32];
174-
rc = eos_hal_otp_read(0x180, shared_secret, sizeof(shared_secret));
175-
if (rc != EOS_OK) {
176-
/* Fail authentication if OTP secret is unreadable */
177-
auth_fail_count++;
178-
auth_state = RCVR_AUTH_NONE;
179-
eos_boot_log_append(0x21, EOS_SLOT_NONE, auth_fail_count); /* AUTH_FAIL */
180-
return recovery_send_nack();
181-
}
182-
183-
eos_sha256_update(&ctx, shared_secret, sizeof(shared_secret));
177+
/* Read shared secret from OTP */
178+
uint8_t shared_secret[32];
179+
rc = eos_hal_otp_read(0x180, shared_secret, sizeof(shared_secret));
180+
if (rc != EOS_OK) {
181+
/* Fail authentication if OTP secret is unreadable */
182+
auth_fail_count++;
183+
auth_state = RCVR_AUTH_NONE;
184+
eos_boot_log_append(0x21, EOS_SLOT_NONE, auth_fail_count); /* AUTH_FAIL */
185+
return recovery_send_nack();
186+
}
187+
188+
eos_sha256_update(&ctx, shared_secret, sizeof(shared_secret));
184189
eos_sha256_final(&ctx, expected);
185190

186191
/* Securely zero the secret */
@@ -303,16 +308,16 @@ static int recovery_handle_verify(eos_slot_t slot)
303308
if (rc != EOS_OK)
304309
return recovery_send_nack();
305310

306-
/* eos_image_verify_integrity() adds hdr_size internally — pass base addr only */
307-
rc = eos_image_verify_integrity(&hdr, addr);
308-
if (rc != EOS_OK)
309-
return recovery_send_nack();
310-
311-
rc = eos_image_verify_signature(&hdr);
312-
if (rc != EOS_OK)
313-
return recovery_send_nack();
314-
315-
return recovery_send_ack();
311+
/* eos_image_verify_integrity() adds hdr_size internally — pass base addr only */
312+
rc = eos_image_verify_integrity(&hdr, addr);
313+
if (rc != EOS_OK)
314+
return recovery_send_nack();
315+
316+
rc = eos_image_verify_signature(&hdr);
317+
if (rc != EOS_OK)
318+
return recovery_send_nack();
319+
320+
return recovery_send_ack();
316321
}
317322

318323
static int recovery_handle_boot(eos_slot_t slot, eos_bootctl_t *bctl)
@@ -335,19 +340,98 @@ static int recovery_handle_boot(eos_slot_t slot, eos_bootctl_t *bctl)
335340
return EOS_OK;
336341
}
337342

338-
static int recovery_handle_factory_reset(eos_bootctl_t *bctl)
343+
static int recovery_handle_factory_reset(eos_bootctl_t *bctl)
344+
{
345+
int rc1 = eos_slot_erase(EOS_SLOT_A);
346+
int rc2 = eos_slot_erase(EOS_SLOT_B);
347+
eos_bootctl_init_defaults(bctl);
348+
int rc3 = eos_bootctl_save(bctl);
349+
350+
if (rc1 != EOS_OK || rc2 != EOS_OK || rc3 != EOS_OK) {
351+
return recovery_send_nack();
352+
}
353+
354+
eos_boot_log_append(EOS_LOG_FACTORY_RESET, EOS_SLOT_NONE, 0);
355+
return recovery_send_ack();
356+
}
357+
358+
static int recovery_collect_boot_log_entries(
359+
eos_boot_log_entry_t *entries_out,
360+
uint16_t *entry_count_out
361+
)
339362
{
340-
int rc1 = eos_slot_erase(EOS_SLOT_A);
341-
int rc2 = eos_slot_erase(EOS_SLOT_B);
342-
eos_bootctl_init_defaults(bctl);
343-
int rc3 = eos_bootctl_save(bctl);
344-
345-
if (rc1 != EOS_OK || rc2 != EOS_OK || rc3 != EOS_OK) {
363+
eos_boot_log_entry_t raw_entries[EOS_BOOT_LOG_MAX];
364+
uint32_t valid_entry_count = 0;
365+
uint32_t log_head;
366+
uint16_t written_count = 0;
367+
368+
if (!entries_out || !entry_count_out)
369+
return EOS_ERR_INVALID;
370+
371+
for (uint32_t i = 0; i < EOS_BOOT_LOG_MAX; i++) {
372+
int rc = eos_boot_log_read(i, &raw_entries[i]);
373+
if (rc != EOS_OK)
374+
return rc;
375+
376+
if (raw_entries[i].event != 0)
377+
valid_entry_count++;
378+
}
379+
380+
log_head = eos_boot_log_get_head() % EOS_BOOT_LOG_MAX;
381+
382+
for (uint32_t i = 0; i < valid_entry_count; i++) {
383+
uint32_t entry_index = (valid_entry_count == EOS_BOOT_LOG_MAX) ?
384+
((log_head + i) % EOS_BOOT_LOG_MAX) : i;
385+
386+
if (raw_entries[entry_index].event == 0)
387+
continue;
388+
389+
entries_out[written_count++] = raw_entries[entry_index];
390+
}
391+
392+
*entry_count_out = written_count;
393+
return EOS_OK;
394+
}
395+
396+
static int recovery_handle_boot_log(uint32_t start_index, uint16_t requested_count)
397+
{
398+
eos_boot_log_entry_t boot_log_entries[EOS_BOOT_LOG_MAX];
399+
uint16_t total_entries = 0;
400+
uint16_t response_entry_count;
401+
int rc;
402+
403+
if (requested_count == 0)
404+
requested_count = RCVR_LOG_MAX_ENTRIES;
405+
if (requested_count > RCVR_LOG_MAX_ENTRIES)
406+
requested_count = RCVR_LOG_MAX_ENTRIES;
407+
408+
rc = recovery_collect_boot_log_entries(boot_log_entries, &total_entries);
409+
if (rc != EOS_OK)
346410
return recovery_send_nack();
411+
412+
if (start_index >= total_entries) {
413+
uint8_t empty_response_header[3] = { RCVR_ACK, 0, 0 };
414+
return eos_hal_uart_send(empty_response_header, sizeof(empty_response_header));
347415
}
416+
417+
response_entry_count = (uint16_t)(total_entries - start_index);
418+
if (response_entry_count > requested_count)
419+
response_entry_count = requested_count;
420+
421+
uint8_t response_header[3] = {
422+
RCVR_ACK,
423+
(uint8_t)(response_entry_count & 0xFF),
424+
(uint8_t)((response_entry_count >> 8) & 0xFF)
425+
};
426+
427+
rc = eos_hal_uart_send(response_header, sizeof(response_header));
428+
if (rc != EOS_OK)
429+
return rc;
348430

349-
eos_boot_log_append(EOS_LOG_FACTORY_RESET, EOS_SLOT_NONE, 0);
350-
return recovery_send_ack();
431+
return eos_hal_uart_send(
432+
&boot_log_entries[start_index],
433+
response_entry_count * sizeof(boot_log_entries[0])
434+
);
351435
}
352436

353437
int eos_recovery_enter(eos_bootctl_t *bctl)
@@ -415,6 +499,10 @@ int eos_recovery_enter(eos_bootctl_t *bctl)
415499
recovery_handle_factory_reset(bctl);
416500
break;
417501

502+
case RCVR_CMD_LOG:
503+
recovery_handle_boot_log(pkt.offset, pkt.len);
504+
break;
505+
418506
default:
419507
recovery_send_nack();
420508
break;

docs/installation_usage.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,20 @@ void main(void) {
155155
### Step 5: Pack and sign firmware image
156156
157157
```bash
158+
# Install Python tool dependencies
159+
python3 -m pip install -r requirements.txt
160+
158161
# Pack firmware with eboot image header
159162
python tools/imgpack.py firmware.bin --output firmware.packed.bin
160163
161164
# Sign the image
162165
python tools/sign_image.py firmware.packed.bin --key signing_key.pem
163166
164-
# Flash to board
165-
python tools/uart_recovery.py --port /dev/ttyUSB0 firmware.packed.bin
167+
# Set the recovery shared secret used by the UART tool
168+
export EBOOT_RECOVERY_SECRET_HEX=<64-hex-char-secret>
169+
170+
# Upload the image to slot B over UART recovery
171+
python tools/uart_recovery.py --port /dev/ttyUSB0 upload --slot B --image firmware.packed.bin
166172
```
167173

168174
### Step 6: Confirm boot (in your app)

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
pyyaml>=6.0
2+
pytest>=9.0
3+
pyserial>=3.5

run_all_tests.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,23 @@
1212

1313
def run_tests():
1414
print("=== Running all production-ready tests via pytest ===")
15+
result = subprocess.run(
16+
[
17+
sys.executable,
18+
"-m",
19+
"pytest",
20+
"tests/unit",
21+
"tests/functional",
22+
"tests/performance",
23+
"tests/simulation",
24+
"-v"
25+
],
26+
capture_output=False
27+
)
28+
sys.exit(result.returncode)
1529
command = [sys.executable, "-m", "pytest", *TEST_PATHS, "-v"]
1630
result = subprocess.run(command, capture_output=False)
1731
return result.returncode
18-
19-
32+
2033
if __name__ == "__main__":
2134
raise SystemExit(run_tests())

tests/unit/test_uart_recovery.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import hashlib
2+
import importlib.util
3+
import struct
4+
import sys
5+
import types
6+
import uuid
7+
from pathlib import Path
8+
9+
10+
UART_RECOVERY_PATH = Path(__file__).resolve().parents[2] / "tools" / "uart_recovery.py"
11+
12+
13+
class FakeSerial:
14+
def __init__(self, incoming=b""):
15+
self.incoming = bytearray(incoming)
16+
self.writes = []
17+
self.closed = False
18+
19+
def read(self, size):
20+
chunk = bytes(self.incoming[:size])
21+
del self.incoming[:size]
22+
return chunk
23+
24+
def write(self, data):
25+
self.writes.append(bytes(data))
26+
return len(data)
27+
28+
def close(self):
29+
self.closed = True
30+
31+
32+
def load_uart_recovery_module(monkeypatch, fake_serial):
33+
serial_module = types.ModuleType("serial")
34+
serial_module.Serial = lambda *args, **kwargs: fake_serial
35+
monkeypatch.setitem(sys.modules, "serial", serial_module)
36+
37+
module_name = f"uart_recovery_test_{uuid.uuid4().hex}"
38+
spec = importlib.util.spec_from_file_location(module_name, UART_RECOVERY_PATH)
39+
module = importlib.util.module_from_spec(spec)
40+
assert spec.loader is not None
41+
spec.loader.exec_module(module)
42+
monkeypatch.setattr(module.time, "sleep", lambda _: None)
43+
return module
44+
45+
46+
def test_auth_success_sends_expected_digest(monkeypatch):
47+
secret = bytes(range(32))
48+
challenge = bytes(range(32, 64))
49+
fake_serial = FakeSerial(bytes([0xAA]) + challenge + bytes([0xAA]))
50+
uart_recovery = load_uart_recovery_module(monkeypatch, fake_serial)
51+
52+
client = uart_recovery.RecoveryClient("dummy-port")
53+
54+
assert client.authenticate(secret.hex()) is True
55+
assert fake_serial.writes[0] == struct.pack(
56+
"<BBHI", uart_recovery.CMD_AUTH, 0, 0, 0
57+
)
58+
assert fake_serial.writes[1] == hashlib.sha256(challenge + secret).digest()
59+
60+
61+
def test_auth_rejects_invalid_secret_length(monkeypatch, capsys):
62+
fake_serial = FakeSerial()
63+
uart_recovery = load_uart_recovery_module(monkeypatch, fake_serial)
64+
65+
client = uart_recovery.RecoveryClient("dummy-port")
66+
67+
assert client.authenticate("00") is False
68+
assert fake_serial.writes == []
69+
assert "exactly 32 bytes" in capsys.readouterr().out
70+
71+
72+
def test_log_decodes_readable_entries(monkeypatch, capsys):
73+
payload = b"".join(
74+
[
75+
struct.pack("<IIII", 100, 0x01, 0, 0),
76+
struct.pack("<IIII", 200, 0x21, 0xFF, 3),
77+
]
78+
)
79+
header = bytes([0xAA]) + struct.pack("<H", 2)
80+
fake_serial = FakeSerial(header + payload)
81+
uart_recovery = load_uart_recovery_module(monkeypatch, fake_serial)
82+
83+
client = uart_recovery.RecoveryClient("dummy-port")
84+
85+
assert client.read_boot_log(0, 2) is True
86+
output = capsys.readouterr().out
87+
assert "BOOT_START" in output
88+
assert "AUTH_FAIL" in output
89+
assert "slot=A" in output
90+
assert "slot=NONE" in output
91+
92+
93+
def test_log_rejects_incomplete_payload(monkeypatch, capsys):
94+
payload = struct.pack("<IIII", 100, 0x01, 0, 0)
95+
header = bytes([0xAA]) + struct.pack("<H", 2)
96+
fake_serial = FakeSerial(header + payload)
97+
uart_recovery = load_uart_recovery_module(monkeypatch, fake_serial)
98+
99+
client = uart_recovery.RecoveryClient("dummy-port")
100+
101+
assert client.read_boot_log(0, 2) is False
102+
assert "Incomplete log response" in capsys.readouterr().out

0 commit comments

Comments
 (0)