Skip to content

A-1209000154249630: passkey authentication - #256

Open
owlsua wants to merge 7 commits into
mainfrom
A-1209000154249630
Open

A-1209000154249630: passkey authentication#256
owlsua wants to merge 7 commits into
mainfrom
A-1209000154249630

Conversation

@owlsua

@owlsua owlsua commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

💬 Description

Unlock a wallet with Touch ID, Windows Hello or a hardware key instead of typing the password. The password keeps working and stays the only way to recover the wallet

Accounts moved to a v4 key envelope: a random 32-byte DEK encrypts the content, and each credential wraps its own copy of that DEK - the password through PBKDF2, each passkey through the WebAuthn PRF output. Enrolling adds 92 bytes and never re-encrypts the seed. A passkey unlock runs no PBKDF2 at all

Every AES-GCM blob is bound with additionalData, so it cannot be moved into another field

Existing wallets keep working. v1–v3 still open, and migrate to v4 on a successful unlock

More

  • Bugs fixes and improvement
  • Removed the legacy add-wallet flow (Mintlayer is created by default now)
  • Removed the wasm mock - tests run the real Mintlayer wasm
  • Add new and fixing existing unit tests

📷 Screenshots

Screenshot 2026-08-24 at 00 03 34 Screenshot 2026-08-24 at 00 02 03

📋 Checklist:

  • I have named my branch as A-[id of Asana task]
  • I have set the title of my PR as A-[id of Asana task]: [short description]
  • My changes passed successfully by prettier check
  • My changes passed successfully by lint check
  • My changes kept the previous test coverage rate
  • I have added enough tests for my new feature/bugfix
  • I have set at least one person to review this PR
  • I have set myself as the assignee of this PR
  • I have set at least one label to this PR

owlsua added 7 commits August 23, 2026 23:05
The suite resolved every wasm_wrappers import to a hand-written mock, so
nothing exercised the real bindings and several suites had to stay disabled.

- load the real module through src/tests/helpers/initWasm.js
- stub import.meta in the test env only, guarded so new.target still works
- map bare "buffer" to the npm polyfill; Buffer instances from node's builtin
  belong to another realm and the wasm bindings reject them
- polyfill structuredClone for fake-indexeddb
- delete the 1627-line mock, now unreferenced
Mintlayer is created by default for every new account, so the "Add wallet"
tile only ever appeared for records predating that default. The flow asked
for the seed phrase again and re-derived the mintlayer keys from it.

Removes the component, the connect item that opened it, and the four pieces
of dashboard state that fed it. An account left without mintlayer keys is
fixed by recreating it.
The migrations were fired un-awaited from onupgradeneeded and each re-opened
the database from inside the versionchange transaction. They therefore
resolved after the first account had already been saved and overwrote it.
Each also issued its own getAll() before any of them wrote, so a later
migration clobbered an earlier one's work.

- migrations become pure account -> account transforms
- they run inside event.target.transaction over a single read
- oldVersion === 0 returns early; a brand-new database has nothing to migrate
- only records the transforms actually changed are written back
- SCHEMAVERSION derives from ACCOUNT_MIGRATIONS.length so the ladder and the
  version cannot drift apart
A worker reported failure by posting { error }, and the caller resolved with
that object instead of rejecting. Mintlayer API payloads legitimately carry
an "error" field, so the marker is now __workerError, and an unknown job
answers instead of leaving the promise pending forever.

runJob also forwards the whole payload to the worker rather than re-listing
the fields it expects. Re-listing is how an argument gets silently dropped,
and the direct-import path used by jest never sees it.
A random 32-byte DEK encrypts the content. Each credential that may open the
wallet wraps that DEK separately: the password through PBKDF2-SHA512, and
each passkey through a WebAuthn PRF output run into HKDF-SHA256. Enrolling a
credential adds 92 bytes and never touches the seed.

Every AES-GCM operation is bound with additionalData, so a blob cannot be
moved into another field, a password wrapper cannot be opened as a passkey
wrapper, and an externally supplied HTLC hash cannot collide with an internal
field name. Those strings and the KDF parameters are a storage format and are
pinned by known-answer tests.

Versions 1 to 3 keep opening: getContentKey returns the password key and the
aad helper returns undefined below version 4. Migration happens on a
successful unlock, under the account lock, with the version re-checked, and
is swallowed on failure - an outdated format beats a wallet that will not
open. Accounts without mintlayer keys no longer fail to unlock.
Adds the Passkeys section to Settings - enrol, list and remove - and a second
button on the unlock screen that appears only when the account has a passkey.

Enrolling asks for the password, because that is the only way to obtain the
DEK to wrap. Removing an entry stops that authenticator opening this wallet;
the copy says plainly that backups exported earlier still accept it.

A passkey unlock runs no PBKDF2 at all, so it returns as fast as the
authenticator answers.
Covers the things that cost time to rediscover: the packing step that creates
popup.html, the three places path aliases are declared, why the worker path is
invisible to jest, that the AAD strings are a storage format, and that the
account lock only serialises within one document.
@owlsua owlsua self-assigned this Aug 23, 2026
@owlsua owlsua added the enhancement New feature or request label Aug 23, 2026
@owlsua
owlsua requested a review from anyxem August 23, 2026 21:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant