Skip to content

Commit 3d97d90

Browse files
committed
feat: compose session source backup io
1 parent f551de0 commit 3d97d90

7 files changed

Lines changed: 189 additions & 2 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,11 @@ hardware readiness can differ, final vault behavior must converge.
128128
`nsec`-equivalent sources in the same RAM-only import/review/keyring
129129
boundary. Host-core now also models the separate danger-zone backup review
130130
that can reveal BIP-39 words/SeedQR or NIP-19 `nsec` recovery payloads only
131-
after final-page local approval. Hardware RNG wiring, physical backup
132-
display/output acceptance, and physical acceptance remain pending.
131+
after final-page local approval. A host-core backup/output harness displays
132+
bounded danger-zone frames before local button reads and emits the recovery
133+
payload to an injected output sink only after final-page approval. Hardware
134+
RNG wiring, physical backup display/output acceptance, and physical
135+
acceptance remain pending.
133136
- Host-core QR response-envelope encoding for already-produced response JSON.
134137
Static `nsealr1:` and animated `nsealr1a:` output match the shared signed
135138
response vector. A host-core response-display harness now chooses static

docs/architecture.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ The first firmware foundation is host-buildable C++ under
161161
RAM-only QR-vault source and reveals BIP-39 words/SeedQR or NIP-19 `nsec`
162162
payloads only after final-page local approval. It is a recovery ceremony,
163163
not import approval, not signing approval, and not persistent storage.
164+
`run_session_source_backup_io_flow` is the future adapter-facing boundary:
165+
it renders bounded backup review frames before every local button read and
166+
emits the recovery payload to an injected output sink only after final-page
167+
approval. Rejection, early approval, and bounded non-terminal button streams
168+
emit nothing.
164169
- `session_source_qr`: normalizes decoded QR session-source inputs for future
165170
camera adapters. It maps canonical NIP-19 `nsec`, plain BIP-39 English
166171
mnemonic QR text, SeedSigner Standard SeedQR digit streams, and CompactSeedQR

docs/roadmap.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,14 @@ reaches the final page and is approved. Hardware RNG wiring, physical backup
719719
display/output acceptance, response QR output, reset-loss evidence, and real
720720
signing remain pending.
721721

722+
Status note, 2026-05-20: the same backup/export ceremony now has a host-core
723+
display/button/output harness for future ESP32 QR vault adapters. It renders
724+
bounded danger-zone review frames before each local button read and emits the
725+
recovery payload to an injected output sink only after final-page approval.
726+
Rejection, early approval, and bounded non-terminal button streams emit
727+
nothing. This is not physical display/output acceptance, persistence, policy
728+
automation, or signing.
729+
722730
Status note, 2026-05-19: host-core now has a decoded session-source QR parser
723731
for future ESP32 QR vault camera adapters. Canonical NIP-19 `nsec`, plain
724732
BIP-39 English mnemonic QR text, SeedSigner Standard SeedQR digit streams, and

docs/testing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ tests with strict C++ warnings.
132132
SeedQR, and CompactSeedQR inputs load the RAM-only keyring only after local
133133
import-review final-page approval, while rejection and invalid decoded QR
134134
inputs leave the keyring empty.
135+
- Host-core session-source backup/output tests prove bounded danger-zone
136+
review frames are displayed before local button reads, approved flows emit
137+
the BIP-39/SeedQR or NIP-19 `nsec` recovery payload only after final-page
138+
approval, and rejection, early approval, or bounded non-terminal button
139+
streams emit nothing.
135140
- Host-core session-account selection tests prove an ESP32 QR vault account
136141
descriptor binds a RAM-only BIP-39 or standalone `nsec` source to a public
137142
signer identity used by trusted review, while rejecting wrong routes,

firmware/host_core/include/nsealr/session_source_backup.hpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <string>
77
#include <vector>
88

9+
#include "nsealr/review_display.hpp"
910
#include "nsealr/review_controls.hpp"
1011
#include "nsealr/session_keyring.hpp"
1112
#include "nsealr/trusted_review.hpp"
@@ -46,11 +47,25 @@ struct SessionSourceBackupFlowResult {
4647
std::vector<SessionSourceBackupTranscriptStep> transcript;
4748
};
4849

50+
class SessionSourceBackupIo {
51+
public:
52+
virtual ~SessionSourceBackupIo() = default;
53+
54+
virtual void show_backup_review_frame(const ReviewDisplayFrame& frame) = 0;
55+
virtual ReviewButton read_backup_review_button() = 0;
56+
virtual void emit_backup_payload(const SessionSourceBackupPayload& payload) = 0;
57+
};
58+
4959
[[nodiscard]] SessionSourceBackupPayload session_source_backup_payload(const SessionKeySource& source);
5060
[[nodiscard]] SessionSourceBackupReview build_session_source_backup_review(const SessionKeySource& source);
5161
[[nodiscard]] SessionSourceBackupFlowResult run_session_source_backup_flow(
5262
const SessionKeySource& source,
5363
const std::vector<ReviewButton>& buttons,
5464
std::size_t max_button_steps = 32U);
65+
[[nodiscard]] SessionSourceBackupFlowResult run_session_source_backup_io_flow(
66+
const SessionKeySource& source,
67+
SessionSourceBackupIo& io,
68+
ReviewDisplayLimits limits = {},
69+
std::size_t max_button_steps = 32U);
5570

5671
} // namespace nsealr

firmware/host_core/src/session_source_backup.cpp

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
#include <iomanip>
44
#include <sstream>
5+
#include <string_view>
6+
#include <utility>
57

68
#include "nsealr/bip39_english.hpp"
79
#include "nsealr/nip19_nsec.hpp"
@@ -75,6 +77,21 @@ std::string backup_approval_digest(
7577
return sha256_hex(material);
7678
}
7779

80+
ReviewPage backup_review_page_for_display(const TrustedReviewPage& page) {
81+
std::vector<std::string_view> lines;
82+
lines.reserve(page.lines.size());
83+
for (const std::string& line : page.lines) {
84+
lines.push_back(line);
85+
}
86+
return ReviewPage{
87+
page.title,
88+
std::move(lines),
89+
page.action,
90+
page.page_indicator,
91+
page.body_line_styles,
92+
};
93+
}
94+
7895
} // namespace
7996

8097
SessionSourceBackupPayload session_source_backup_payload(const SessionKeySource& source) {
@@ -180,4 +197,46 @@ SessionSourceBackupFlowResult run_session_source_backup_flow(
180197
throw SessionSourceBackupError("session source backup review did not reach approval or rejection");
181198
}
182199

200+
SessionSourceBackupFlowResult run_session_source_backup_io_flow(
201+
const SessionKeySource& source,
202+
SessionSourceBackupIo& io,
203+
ReviewDisplayLimits limits,
204+
std::size_t max_button_steps) {
205+
if (max_button_steps == 0U) {
206+
throw SessionSourceBackupError("session source backup flow max button steps must be positive");
207+
}
208+
209+
SessionSourceBackupFlowResult result;
210+
result.review = build_session_source_backup_review(source);
211+
ReviewControlSession controls(result.review.pages.size());
212+
213+
for (std::size_t step_count = 0; step_count < max_button_steps; ++step_count) {
214+
const std::size_t page_index = controls.current_page_index();
215+
const ReviewPage page = backup_review_page_for_display(result.review.pages.at(page_index));
216+
io.show_backup_review_frame(render_review_page(page, page_index, result.review.pages.size(), limits));
217+
218+
const ReviewButton button = io.read_backup_review_button();
219+
const std::optional<bool> decision = controls.handle_button(button);
220+
const bool revealed = decision.has_value() && *decision;
221+
result.transcript.push_back(SessionSourceBackupTranscriptStep{
222+
page_index,
223+
button,
224+
decision,
225+
revealed,
226+
});
227+
228+
if (decision.has_value()) {
229+
result.approved = *decision;
230+
result.revealed = revealed;
231+
if (revealed) {
232+
result.backup_payload = session_source_backup_payload(source);
233+
io.emit_backup_payload(*result.backup_payload);
234+
}
235+
return result;
236+
}
237+
}
238+
239+
throw SessionSourceBackupError("session source backup review exceeded max button steps");
240+
}
241+
183242
} // namespace nsealr

firmware/host_core/tests/test_host_core.cpp

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,37 @@ class RecordingQrResponseDisplayIo : public nsealr::QrResponseDisplayIo {
316316
std::vector<nsealr::QrResponseDisplayFrame> frames;
317317
};
318318

319+
class RecordingSessionSourceBackupIo : public nsealr::SessionSourceBackupIo {
320+
public:
321+
explicit RecordingSessionSourceBackupIo(std::vector<nsealr::ReviewButton> buttons)
322+
: buttons_(std::move(buttons)) {}
323+
324+
void show_backup_review_frame(const nsealr::ReviewDisplayFrame& frame) override {
325+
frames.push_back(frame);
326+
events.push_back("frame:" + frame.title);
327+
}
328+
329+
nsealr::ReviewButton read_backup_review_button() override {
330+
assert(!buttons_.empty());
331+
const nsealr::ReviewButton button = buttons_.front();
332+
buttons_.erase(buttons_.begin());
333+
events.push_back("button");
334+
return button;
335+
}
336+
337+
void emit_backup_payload(const nsealr::SessionSourceBackupPayload& payload) override {
338+
payloads.push_back(payload);
339+
events.push_back("payload");
340+
}
341+
342+
std::vector<nsealr::ReviewDisplayFrame> frames;
343+
std::vector<nsealr::SessionSourceBackupPayload> payloads;
344+
std::vector<std::string> events;
345+
346+
private:
347+
std::vector<nsealr::ReviewButton> buttons_;
348+
};
349+
319350
class NextOnlyQrReviewIo : public nsealr::QrReviewIo {
320351
public:
321352
std::string scan_request_qr() override {
@@ -1081,6 +1112,65 @@ void test_session_source_backup_flow_reveals_only_after_local_approval() {
10811112
});
10821113
}
10831114

1115+
void test_session_source_backup_io_reveals_only_after_displayed_approval() {
1116+
nsealr::StatelessSessionKeyring keyring;
1117+
keyring.add_nsec("nsec test vector", nsealr::decode_nsec_secret_key(nsealr::test_vectors::kNip19NsecTestKey1));
1118+
RecordingSessionSourceBackupIo io{{nsealr::ReviewButton::Next, nsealr::ReviewButton::Approve}};
1119+
1120+
const nsealr::SessionSourceBackupFlowResult result =
1121+
nsealr::run_session_source_backup_io_flow(keyring.source_at(0), io);
1122+
1123+
assert(result.approved);
1124+
assert(result.revealed);
1125+
assert(result.backup_payload.has_value());
1126+
assert(result.backup_payload->nsec == std::string(nsealr::test_vectors::kNip19NsecTestKey1));
1127+
assert(io.payloads.size() == 1U);
1128+
assert(io.payloads[0].nsec == std::string(nsealr::test_vectors::kNip19NsecTestKey1));
1129+
assert(io.frames.size() == 2U);
1130+
assert(io.frames[0].title == "Backup source");
1131+
assert(io.frames[0].page_indicator == "Page 1/2");
1132+
assert(lines_contain(io.frames[0].body_lines, "Danger: secret export"));
1133+
assert(!lines_contain(io.frames[0].body_lines, nsealr::test_vectors::kNip19NsecTestKey1));
1134+
assert(!lines_contain(io.frames[0].body_lines, nsealr::test_vectors::kNip19NsecTestKey1SecretKey));
1135+
assert(io.frames[1].title == "Show secret?");
1136+
assert(io.frames[1].page_indicator == "Page 2/2");
1137+
assert((io.events == std::vector<std::string>{
1138+
"frame:Backup source",
1139+
"button",
1140+
"frame:Show secret?",
1141+
"button",
1142+
"payload",
1143+
}));
1144+
}
1145+
1146+
void test_session_source_backup_io_rejection_or_timeout_does_not_emit_payload() {
1147+
nsealr::StatelessSessionKeyring keyring;
1148+
keyring.add_nsec("nsec test vector", nsealr::decode_nsec_secret_key(nsealr::test_vectors::kNip19NsecTestKey1));
1149+
1150+
RecordingSessionSourceBackupIo rejected_io{{nsealr::ReviewButton::Reject}};
1151+
const nsealr::SessionSourceBackupFlowResult rejected =
1152+
nsealr::run_session_source_backup_io_flow(keyring.source_at(0), rejected_io);
1153+
assert(!rejected.approved);
1154+
assert(!rejected.revealed);
1155+
assert(!rejected.backup_payload.has_value());
1156+
assert(rejected_io.payloads.empty());
1157+
assert(rejected_io.frames.size() == 1U);
1158+
1159+
RecordingSessionSourceBackupIo timeout_io{{nsealr::ReviewButton::Next, nsealr::ReviewButton::Back}};
1160+
expect_throw("max button steps", [&] {
1161+
(void)nsealr::run_session_source_backup_io_flow(keyring.source_at(0), timeout_io, {}, 1U);
1162+
});
1163+
assert(timeout_io.payloads.empty());
1164+
assert(timeout_io.frames.size() == 1U);
1165+
1166+
RecordingSessionSourceBackupIo early_approval_io{{nsealr::ReviewButton::Approve}};
1167+
expect_throw("approval requires viewing every review page", [&] {
1168+
(void)nsealr::run_session_source_backup_io_flow(keyring.source_at(0), early_approval_io);
1169+
});
1170+
assert(early_approval_io.payloads.empty());
1171+
assert(early_approval_io.frames.size() == 1U);
1172+
}
1173+
10841174
void test_policy_change_review_matches_shared_vector() {
10851175
const nsealr::test_vectors::PolicyChangeReviewVector vector =
10861176
policy_change_review_vector_by_name("esp32-usb-enable-kind-1-automation");
@@ -3319,6 +3409,8 @@ int main() {
33193409
test_session_source_backup_review_matches_shared_danger_zone_vectors();
33203410
test_session_source_backup_payload_matches_shared_secret_payloads();
33213411
test_session_source_backup_flow_reveals_only_after_local_approval();
3412+
test_session_source_backup_io_reveals_only_after_displayed_approval();
3413+
test_session_source_backup_io_rejection_or_timeout_does_not_emit_payload();
33223414
test_policy_change_review_matches_shared_vector();
33233415
test_policy_change_review_flow_requires_device_approval();
33243416
test_policy_change_review_rejects_companion_authority_or_secret_material();

0 commit comments

Comments
 (0)