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
16 changes: 13 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.3.3] - 2026-06-18
## [1.3.3] - 2026-06-19

### Fix

- WUI: saving a backup no longer fails with a `SecurityError` — the save dialog now opens on click,
before the on-device approval.
- Restore no longer corrupts password lists larger than ~255 bytes.
- Demo passwords from the developer `POPULATE` build are seeded only once (on first storage initialization).
- Use correct `printf` format specifiers for `size_t` values (CodeQL).

### Change

- Rebuild the backup Web UI on Vite + React 18 with Ledger's lumen design system; built with pnpm on Node 22.
- Publish the Web UI through GitHub's native Pages workflow.
- Reword the device confirmation prompts for the Backup/Restore actions.
- Web UI: save the backup through the browser's native save dialog
- Web UI: drop the (constant) storage-size row from the device card.
- Only compile the test APDU handler when built with `TESTING=1`, so production builds contain no test code.

### Add

- Unit tests for the metadata write offset and capacity limits
- ragger tests for the backup/restore confirmation screens.
- ragger test checking that showing a password located beyond the first list page selects the right entry on touch devices.

## [1.3.2] - 2026-06-05

Expand Down
6 changes: 3 additions & 3 deletions clients/wui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ For more information on the device application itself, see the
- Click **Connect** — on success the Backup / Restore actions appear.
(Connection trouble? See
[Fix connection issues](https://support.ledger.com/article/115005165269-zd).)
- **Backup** prompts "Transfer metadatas?" on the device, then saves the file.
- **Restore** asks for a previous backup file, then prompts "Overwrite
metadatas?" on the device.
- **Backup** prompts "Backup password list?" on the device, then saves the file.
- **Restore** asks for a previous backup file, then prompts "Restore password
list?" on the device.

## Develop

Expand Down
116 changes: 67 additions & 49 deletions clients/wui/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,50 +26,48 @@ import packageJson from "../package.json";
const passwords = new PasswordsManager();
listen((log) => console.log(log));

// showSaveFilePicker() requires transient user activation (a recent gesture),
// so it must be called synchronously from the click handler — before the
// device exchange, which takes too long and would otherwise let the activation
// expire ("Must be handling a user gesture"). This picks the destination up
// front and returns a target the caller writes to once the data is ready.
// Falls back to a Blob/anchor download when the File System Access API is
// unavailable or refuses (returns { cancelled } if the user dismisses it).
async function pickSaveTarget(suggestedName) {
// Save the backup, preferring the native "Save As" dialog so the user can
// choose the file name/location. showSaveFilePicker requires transient user
// activation, so this MUST be called from its own click handler — not from the
// async continuation of the device exchange (the original activation would have
// expired -> "Must be handling a user gesture"). Hence the two-step flow:
// backup first, then a separate "Save" click. Returns false if the user
// cancels the dialog (so the caller can keep the data for a retry). Falls back
// to a plain anchor download where the File System Access API is unavailable.
async function saveBackupFile(payload, filename) {
const text = JSON.stringify(payload, null, 4);

if (window.showSaveFilePicker) {
let handle = null;
try {
const handle = await window.showSaveFilePicker({
suggestedName,
handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [
{ description: "Passwords backup", accept: { "application/json": [".json"] } },
],
});
return { handle, suggestedName };
} catch (error) {
if (error.name === "AbortError") return { cancelled: true };
// SecurityError / NotAllowedError / unsupported: fall back to anchor.
if (error.name === "AbortError") return false; // user dismissed the dialog
// Other errors (unsupported / SecurityError): fall through to the anchor.
}
if (handle) {
const writable = await handle.createWritable();
await writable.write(text);
await writable.close();
return true;
}
}
return { suggestedName };
}

async function writeJSON(target, payload) {
const text = JSON.stringify(payload, null, 4);

if (target.handle) {
const writable = await target.handle.createWritable();
await writable.write(text);
await writable.close();
return;
}

const blob = new Blob([text], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = target.suggestedName;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return true;
}

export default function App() {
Expand All @@ -80,8 +78,11 @@ export default function App() {
const [busy, setBusy] = useState(false);
const [connected, setConnected] = useState(mock);
const [version, setVersion] = useState(mock ? "1.0.0" : null);
const [storageSize, setStorageSize] = useState(mock ? 4096 : null);
const [notice, setNotice] = useState(null);
// Backup data read from the card, waiting for the user to pick a file. The
// save dialog needs its own click (transient activation), so it can't be
// chained onto the device exchange.
const [pendingBackup, setPendingBackup] = useState(null);

const fileInput = useRef(null);

Expand All @@ -90,7 +91,6 @@ export default function App() {
setConnected(false);
setBusy(false);
setVersion(null);
setStorageSize(null);
setNotice({
appearance: "warning",
title: "Device disconnected",
Expand All @@ -107,14 +107,12 @@ export default function App() {
try {
await passwords.connect();
setVersion(passwords.version);
setStorageSize(passwords.storage_size);
setConnected(true);
setNotice({ appearance: "success", title: "Device connected" });
} catch (error) {
await passwords.disconnect();
setConnected(false);
setVersion(null);
setStorageSize(null);
setNotice({ appearance: "error", title: "Connection failed", description: String(error) });
} finally {
setBusy(false);
Expand All @@ -125,40 +123,52 @@ export default function App() {
await passwords.disconnect();
setConnected(false);
setVersion(null);
setStorageSize(null);
setNotice({ appearance: "info", title: "Disconnected" });
}

async function onBackup() {
// Pick the destination first, while the click's user activation is still
// valid (the device exchange below is too slow for showSaveFilePicker).
const target = await pickSaveTarget("passwords_backup.json");
if (target.cancelled) {
setNotice({ appearance: "info", title: "Backup cancelled" });
return;
}

setBusy(true);
setNotice({ appearance: "info", title: 'Approve "Transfer metadatas?" on your device' });
setPendingBackup(null);
setNotice({ appearance: "info", title: 'Approve "Backup password list?" on your device' });
try {
// Read the card first. The file dialog can't be opened here (the device
// approval consumed the click's activation), so hold the data and let the
// user trigger the save with a fresh click.
const payload = await passwords.dump_metadatas();
await writeJSON(target, payload);
setNotice({ appearance: "success", title: "Backup saved" });
setPendingBackup(payload);
setNotice({
appearance: "success",
title: "Backup ready",
description: "Click Save to choose where to store the file.",
});
} catch (error) {
setNotice({ appearance: "error", title: "Backup failed", description: String(error) });
} finally {
setBusy(false);
}
}

async function onSaveBackup() {
try {
const saved = await saveBackupFile(pendingBackup, "passwords_backup.json");
if (saved) {
setPendingBackup(null);
setNotice({ appearance: "success", title: "Backup saved" });
}
// If cancelled, keep pendingBackup so the user can try saving again.
} catch (error) {
setNotice({ appearance: "error", title: "Save failed", description: String(error) });
}
}

function onPickRestoreFile(event) {
const file = event.target.files[0];
event.target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
setBusy(true);
setNotice({ appearance: "info", title: 'Approve "Overwrite metadatas?" on your device' });
setNotice({ appearance: "info", title: 'Approve "Restore password list?" on your device' });
try {
await passwords.load_metadatas(reader.result);
setNotice({ appearance: "success", title: "Restore complete" });
Expand Down Expand Up @@ -196,6 +206,20 @@ export default function App() {
/>
)}

{/* Backup ready: the save dialog needs its own click. */}
{pendingBackup && (
<Banner
appearance="info"
title="Backup ready to save"
description="Choose where to store your backup file."
primaryAction={
<Button appearance="accent" size="sm" icon={CloudDownload} onClick={onSaveBackup}>
Save…
</Button>
}
/>
)}

{/* Device card */}
<div className="bg-base border border-base rounded-lg p-20">
<div className="flex items-center justify-between">
Expand All @@ -222,12 +246,6 @@ export default function App() {
<span className="body-3 text-muted">Version</span>
<Tag appearance="gray" label={version || "—"} />
</div>
<div className="flex items-center justify-between">
<span className="body-3 text-muted">Storage</span>
<span className="body-3 text-base">
{storageSize ? `${storageSize} bytes` : "—"}
</span>
</div>
</div>
</>
)}
Expand Down
4 changes: 2 additions & 2 deletions clients/wui/src/components/Faq.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const ITEMS = [
a:
"* Connect your Ledger device to your computer and open the `Passwords app`.\n" +
"* Click on the big `Connect` button — on success the `Backup` and `Restore` buttons appear. If you have trouble with this step, have a look [here](https://support.ledger.com/article/115005165269-zd).\n" +
"* `Backup` will prompt a screen requesting your approval on your device (\"Transfer metadatas?\"), then save a backup file. This backup is not confidential, so you can for instance e-mail it to yourself to never lose it.\n" +
"* `Restore` will prompt a file input dialog where you should indicate a previous backup file. A prompt (\"Overwrite metadatas?\") will then request your approval on your device.",
"* `Backup` will prompt a screen requesting your approval on your device (\"Backup password list?\"), then save a backup file. This backup is not confidential, so you can for instance e-mail it to yourself to never lose it.\n" +
"* `Restore` will prompt a file input dialog where you should indicate a previous backup file. A prompt (\"Restore password list?\") will then request your approval on your device.",
},
{
q: "Which web browsers and operating systems are supported?",
Expand Down
6 changes: 5 additions & 1 deletion src/apdu_handlers/dump_metadatas.c
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
int dump_metadatas() {
if (app_state.user_approval == false) {
app_state.bytes_transferred = 0;
message_pair_t msg = {"Transfer", "metadatas ?"};
#ifdef SCREEN_SIZE_WALLET
message_pair_t msg = {"Backup", "password list?"};
#else
message_pair_t msg = {"Backup", "password list"};
#endif
ui_request_user_approval(&msg);
return 0;
}
Expand Down
6 changes: 5 additions & 1 deletion src/apdu_handlers/load_metadatas.c
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ int load_metadatas(uint8_t p1, uint8_t p2, const buf_t *input) {
}
if (app_state.user_approval == false) {
app_state.bytes_transferred = 0;
message_pair_t msg = {"Overwrite", "metadatas ?"};
#ifdef SCREEN_SIZE_WALLET
message_pair_t msg = {"Restore", "password list?"};
#else
message_pair_t msg = {"Restore", "password list"};
#endif
ui_request_user_approval(&msg);
return 0;
}
Expand Down
2 changes: 2 additions & 0 deletions src/dispatcher.c
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
#include "dispatcher.h"
#include "error.h"
#include "globals.h"
#ifdef TESTING
#include "tests.h"
#endif
#include "types.h"

int dispatch() {
Expand Down
2 changes: 1 addition & 1 deletion src/metadata.c
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ error_type_t write_metadata(uint8_t *data, uint8_t dataSize) {
return OK;
}

void override_metadatas(uint8_t offset, void *ptr, size_t size) {
void override_metadatas(size_t offset, void *ptr, size_t size) {
nvm_write((void *) &N_storage.metadatas[offset], ptr, size);
}

Expand Down
2 changes: 1 addition & 1 deletion src/metadata.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ error_type_t write_metadata(uint8_t *data, uint8_t dataSize);
* Write a given amount of data on metadatas, at the given offset
* Used to load metadata from APDUs
*/
void override_metadatas(uint8_t offset, void *ptr, size_t size);
void override_metadatas(size_t offset, void *ptr, size_t size);

void reset_metadatas(void);
error_type_t erase_metadata(uint32_t offset);
Expand Down
6 changes: 6 additions & 0 deletions src/tests/tests.c
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
#include "password_typing.h"
#include "tests.h"

// Test-only APDU handler: compiled into nothing unless built with TESTING=1,
// matching the `#ifdef TESTING` guard on the dispatch site in dispatcher.c.
#ifdef TESTING

/* Takes a metadata as an input (charset + seed) and returns a 20 char password*/
int test_generate_password(const buf_t *input) {
uint8_t enabledSets = input->bytes[0];
Expand Down Expand Up @@ -37,3 +41,5 @@ int test_dispatcher(uint8_t p1, __attribute__((unused)) uint8_t p2, const buf_t
return io_send_sw(SWO_INVALID_INS + 1);
}
}

#endif // TESTING
14 changes: 12 additions & 2 deletions tests/functional/nano/navigator.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,11 @@ def __init__(self, backend: BackendInterface, device: Device, golden_run: bool =
# paginates before the "Confirm" action page, so step forward until
# that page is on screen and only then commit.
CustomNavInsID.CONFIRM_YES: self._confirm_yes,
# APDU approval prompt
# Generic select/validate (settings switches, BARS_LIST entries).
CustomNavInsID.BUTTON_APPROVE: self._both,
# APDU approval prompt (nbgl_useCaseConfirm): message page first,
# the "Approve" action sits on the next page (right then both).
CustomNavInsID.APDU_APPROVE: self._approve,
# Keyboard (nbgl_useCaseKeyboard with MODE_NONE on Nano).
CustomNavInsID.KEYBOARD_WRITE: self._write,
CustomNavInsID.KEYBOARD_TO_CONFIRM: self._keyboard_confirm,
Expand All @@ -106,13 +109,20 @@ def _left(self):
def _both(self):
self._backend.both_click()

def _approve(self):
# nbgl_useCaseConfirm shows the message first; the "Approve" action is
# reached with one right_click, then validated with both_click. Matches
# PasswordsManagerCommand.approve() for Nano.
self._right()
self._both()

def _navigate_until_text(self, text: str, max_steps: int = 12) -> None:
"""Press right_click until `text` appears on screen, then return."""
for _ in range(max_steps):
try:
self._backend.wait_for_text_on_screen(text, timeout=0.5)
return
except Exception:
except TimeoutError:
self._backend.right_click()
raise RuntimeError(f"Could not find text '{text}' on screen after {max_steps} steps")

Expand Down
Loading
Loading