diff --git a/.gitignore b/.gitignore index 4cb1ded..4175c94 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ yarn-error.log* # Playwright (e2e/ itself is tracked - only its artifacts are ignored) /playwright-report/ /test-results/ +/.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md index f9f713f..4525d18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ npm run cap:sync # build + cap sync android - NixOS: use the flake dev shell (nodejs_22 + chromium for e2e). `sass-embedded` is aliased to pure-JS `sass` via npm overrides. -- `lnurlcash-kit@0.1.1` and `lnurlcash-conformance@0.1.1` are exact npm +- `lnurlcash-kit@0.8.0` and `lnurlcash-conformance@0.7.0` are exact npm registry packages. Keep their manifest pins, registry tarball URLs, and lock integrity values intact. - Credential and authorization records are bound to the canonical owner ID diff --git a/CHANGELOG.md b/CHANGELOG.md index 3266d3a..79a9886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,108 +2,102 @@ ## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31) - ### Features -* **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400)) -* **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb)) -* **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2)) -* **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf)) -* **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed)) -* **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c)) -* support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5)) - +- **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400)) +- **lnurlcash:** claim mint outputs named by a wallet-chosen secret ([58268b2](https://github.com/tompro/sattle/commit/58268b2921d05c29ed4df98891e5ab5069617dfb)) +- **lnurlcash:** probe would-be outputs before believing a mutation refusal ([48be23d](https://github.com/tompro/sattle/commit/48be23dceb940b28166b242ff308600864b87dd2)) +- **lnurlcash:** transfer to mints with wallet-named outputs ([67b2122](https://github.com/tompro/sattle/commit/67b2122e52399b85e4829a0b3b61224252ae8ebf)) +- **nwc:** make_invoice against mints with wallet-named outputs ([ae5064b](https://github.com/tompro/sattle/commit/ae5064b69a545a9ade671b5d701d5399fe3517ed)) +- **receive:** accept named mints that serve no verify URL ([39a144c](https://github.com/tompro/sattle/commit/39a144cf50d621908b79cc27c7c664f6f9fc369c)) +- support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5)) ### Bug Fixes -* commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62)) -* commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306)) -* **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927)) -* **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86)) -* **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df)) -* replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7)) +- commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([09f7fe0](https://github.com/tompro/sattle/commit/09f7fe02872838253882451ce7ed1f818f4ace62)) +- commit send, pay and move carves at the checkpoint ([#6](https://github.com/tompro/sattle/issues/6)) ([9e37fee](https://github.com/tompro/sattle/commit/9e37fee841ab1c8ecb6bf7e3d00dfd61414ce306)) +- **lnurlcash:** assert named claims land at the wallet's own secret ([6bb07ff](https://github.com/tompro/sattle/commit/6bb07ff9ec5c69b01c1822471c24312b44707927)) +- **lnurlcash:** commit carves before the settlement wait ([#6](https://github.com/tompro/sattle/issues/6)) ([587dd11](https://github.com/tompro/sattle/commit/587dd11ba8a413c12dde134b11b2040c9d383c86)) +- **lnurlcash:** rescue landed mutations from retry refusals ([50b2427](https://github.com/tompro/sattle/commit/50b2427b8db07692a590ff2602a034385bbac7df)) +- replace forgesworn with moneyer in the public mint suggestions ([4cc8e0a](https://github.com/tompro/sattle/commit/4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7)) ## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24) - ### Features -* activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc)) -* add persisted owner guards and events ([69fbdb6](https://github.com/tompro/sattle/commit/69fbdb68ee3edccc7561fe4c201ef4497443d331)) -* apply owner-aware wallet backups ([7607e47](https://github.com/tompro/sattle/commit/7607e47358fe4d9736c04853b784e05b2bef20c1)) -* apply wallet fund changes atomically ([5b3a69e](https://github.com/tompro/sattle/commit/5b3a69e91055082e9c8cabb09d3c02302db05a37)) -* await owner-aware Nostr restores ([f55f208](https://github.com/tompro/sattle/commit/f55f20883671ccd0fd696f3696a76fa739738fa6)) -* await trusted mint management actions ([3c2a2cc](https://github.com/tompro/sattle/commit/3c2a2cc4a580126e8c199268aa543719ed771c2e)) -* bind saved keys to versioned wallet owners ([acfab83](https://github.com/tompro/sattle/commit/acfab8343897f69dd714474724f419eef569fe7a)) -* commit bearer changesets in one write ([586d12b](https://github.com/tompro/sattle/commit/586d12b666e13f040254ffbc1c618859cf7659b4)) -* converge trusted mints on storage events ([83c9e5b](https://github.com/tompro/sattle/commit/83c9e5b95ff6d7189534437180141bcfd86d282c)) -* create owner-bound NWC connections ([228a92b](https://github.com/tompro/sattle/commit/228a92b0f0f3238bee0cf0bcf7315f6b88dbae30)) -* drain tracked NWC handlers on stop ([3ed214c](https://github.com/tompro/sattle/commit/3ed214c157743dc6ba2c28be25022bf65765a904)) -* expose fund ownership assertions ([845ec41](https://github.com/tompro/sattle/commit/845ec410e506f1ba6a1c9c2666a9e6282d391755)) -* fence wallet mutations by lifecycle owner ([9fa4abd](https://github.com/tompro/sattle/commit/9fa4abdaf3f775512e6e9c1caed72bb0ade76544)) -* harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039)) -* invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8)) -* preserve trusted mint rekey transitions ([d890a69](https://github.com/tompro/sattle/commit/d890a69c406b28a7f18bd6b356d9ad7eaab66341)) -* restore Nostr backups through wallet lifecycle ([3f6010b](https://github.com/tompro/sattle/commit/3f6010b31262f3ea7ee5ac65c1dd7bcf6fa15635)) -* scope NWC storage to wallet owners ([9f6bcad](https://github.com/tompro/sattle/commit/9f6bcad706760cd428e462ae28557f28362fddcf)) -* scope passkey slots to proven owners ([12e4ec9](https://github.com/tompro/sattle/commit/12e4ec9199bfd78b741ae38d0fd49135594d85eb)) -* scope trusted mint state in Pinia ([81c28ec](https://github.com/tompro/sattle/commit/81c28ec16dee42b7705ac97dc047a3486045c9ab)) -* serialize trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4)) -* serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e)) -* validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2)) - +- activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc)) +- add persisted owner guards and events ([69fbdb6](https://github.com/tompro/sattle/commit/69fbdb68ee3edccc7561fe4c201ef4497443d331)) +- apply owner-aware wallet backups ([7607e47](https://github.com/tompro/sattle/commit/7607e47358fe4d9736c04853b784e05b2bef20c1)) +- apply wallet fund changes atomically ([5b3a69e](https://github.com/tompro/sattle/commit/5b3a69e91055082e9c8cabb09d3c02302db05a37)) +- await owner-aware Nostr restores ([f55f208](https://github.com/tompro/sattle/commit/f55f20883671ccd0fd696f3696a76fa739738fa6)) +- await trusted mint management actions ([3c2a2cc](https://github.com/tompro/sattle/commit/3c2a2cc4a580126e8c199268aa543719ed771c2e)) +- bind saved keys to versioned wallet owners ([acfab83](https://github.com/tompro/sattle/commit/acfab8343897f69dd714474724f419eef569fe7a)) +- commit bearer changesets in one write ([586d12b](https://github.com/tompro/sattle/commit/586d12b666e13f040254ffbc1c618859cf7659b4)) +- converge trusted mints on storage events ([83c9e5b](https://github.com/tompro/sattle/commit/83c9e5b95ff6d7189534437180141bcfd86d282c)) +- create owner-bound NWC connections ([228a92b](https://github.com/tompro/sattle/commit/228a92b0f0f3238bee0cf0bcf7315f6b88dbae30)) +- drain tracked NWC handlers on stop ([3ed214c](https://github.com/tompro/sattle/commit/3ed214c157743dc6ba2c28be25022bf65765a904)) +- expose fund ownership assertions ([845ec41](https://github.com/tompro/sattle/commit/845ec410e506f1ba6a1c9c2666a9e6282d391755)) +- fence wallet mutations by lifecycle owner ([9fa4abd](https://github.com/tompro/sattle/commit/9fa4abdaf3f775512e6e9c1caed72bb0ade76544)) +- harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039)) +- invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8)) +- preserve trusted mint rekey transitions ([d890a69](https://github.com/tompro/sattle/commit/d890a69c406b28a7f18bd6b356d9ad7eaab66341)) +- restore Nostr backups through wallet lifecycle ([3f6010b](https://github.com/tompro/sattle/commit/3f6010b31262f3ea7ee5ac65c1dd7bcf6fa15635)) +- scope NWC storage to wallet owners ([9f6bcad](https://github.com/tompro/sattle/commit/9f6bcad706760cd428e462ae28557f28362fddcf)) +- scope passkey slots to proven owners ([12e4ec9](https://github.com/tompro/sattle/commit/12e4ec9199bfd78b741ae38d0fd49135594d85eb)) +- scope trusted mint state in Pinia ([81c28ec](https://github.com/tompro/sattle/commit/81c28ec16dee42b7705ac97dc047a3486045c9ab)) +- serialize trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4)) +- serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e)) +- validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2)) ### Bug Fixes -* await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece)) -* await wallet commits in the NWC store ([99e6ae1](https://github.com/tompro/sattle/commit/99e6ae1b12de9f269c456ef015774cbe64c2b205)) -* clear runtime state after lifecycle failures ([3acc63e](https://github.com/tompro/sattle/commit/3acc63e96866bd8c7f690499322f6cc631c0e329)) -* commit carved wallet funds atomically ([c59521a](https://github.com/tompro/sattle/commit/c59521a421a3a8b40afd9d3e6088a2c36bc56354)) -* consume owner monitor transition failures ([e892a33](https://github.com/tompro/sattle/commit/e892a330fc386e68f5ff11237cd3fd54534d68c6)) -* drain accepted NWC work before wallet lock ([e983734](https://github.com/tompro/sattle/commit/e98373491fe7295cfc2a817d64ebdab0b1895a18)) -* fence carve operations before mutation ([6fa4283](https://github.com/tompro/sattle/commit/6fa4283ed65cd3943beb4486aee8e876732b73c5)) -* fence inter-mint transfer operations ([02f901c](https://github.com/tompro/sattle/commit/02f901c87c0f983b67ea50619e0f8a42585acf0a)) -* fence invoice payment operations ([c4c9d8a](https://github.com/tompro/sattle/commit/c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60)) -* fence mint and receive operations ([7f69f78](https://github.com/tompro/sattle/commit/7f69f781f55239de5502d8c17954d582a0ee45e6)) -* fence send and pay flows ([401a26d](https://github.com/tompro/sattle/commit/401a26dc6b1221641bcdcbc1aa1e5464025ec41c)) -* hide invoice settlement until commit ([513c67f](https://github.com/tompro/sattle/commit/513c67fc143d27c1e57c3c2c6771a0f622152382)) -* mirror trusted mint commits across tabs ([e9bbb35](https://github.com/tompro/sattle/commit/e9bbb358aae79d3ce1fa6690749e9d63d3c748ff)) -* normalize storage lock failures ([1dbf578](https://github.com/tompro/sattle/commit/1dbf5788f9b82162122eea205ea3d6e9c5badf1a)) -* preserve conservative NWC budget debits ([fff24a7](https://github.com/tompro/sattle/commit/fff24a75b24f7a80e4f3f517525c7eb0c2d93155)) -* publish activity only after persistence ([54031dc](https://github.com/tompro/sattle/commit/54031dc8de03448aa3cee6c2a8a9d035d92c752c)) -* surface receive post-commit trust failures ([f087ebe](https://github.com/tompro/sattle/commit/f087ebe2f2e28bbd0e4c3cfe3407e77c0a78a7aa)) -* validate auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19)) -* validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504)) -* **wallet:** enforce owner-bound integrity and durable commits ([338b281](https://github.com/tompro/sattle/commit/338b2812e357f01027800985051e62dea6c207d8)) +- await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece)) +- await wallet commits in the NWC store ([99e6ae1](https://github.com/tompro/sattle/commit/99e6ae1b12de9f269c456ef015774cbe64c2b205)) +- clear runtime state after lifecycle failures ([3acc63e](https://github.com/tompro/sattle/commit/3acc63e96866bd8c7f690499322f6cc631c0e329)) +- commit carved wallet funds atomically ([c59521a](https://github.com/tompro/sattle/commit/c59521a421a3a8b40afd9d3e6088a2c36bc56354)) +- consume owner monitor transition failures ([e892a33](https://github.com/tompro/sattle/commit/e892a330fc386e68f5ff11237cd3fd54534d68c6)) +- drain accepted NWC work before wallet lock ([e983734](https://github.com/tompro/sattle/commit/e98373491fe7295cfc2a817d64ebdab0b1895a18)) +- fence carve operations before mutation ([6fa4283](https://github.com/tompro/sattle/commit/6fa4283ed65cd3943beb4486aee8e876732b73c5)) +- fence inter-mint transfer operations ([02f901c](https://github.com/tompro/sattle/commit/02f901c87c0f983b67ea50619e0f8a42585acf0a)) +- fence invoice payment operations ([c4c9d8a](https://github.com/tompro/sattle/commit/c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60)) +- fence mint and receive operations ([7f69f78](https://github.com/tompro/sattle/commit/7f69f781f55239de5502d8c17954d582a0ee45e6)) +- fence send and pay flows ([401a26d](https://github.com/tompro/sattle/commit/401a26dc6b1221641bcdcbc1aa1e5464025ec41c)) +- hide invoice settlement until commit ([513c67f](https://github.com/tompro/sattle/commit/513c67fc143d27c1e57c3c2c6771a0f622152382)) +- mirror trusted mint commits across tabs ([e9bbb35](https://github.com/tompro/sattle/commit/e9bbb358aae79d3ce1fa6690749e9d63d3c748ff)) +- normalize storage lock failures ([1dbf578](https://github.com/tompro/sattle/commit/1dbf5788f9b82162122eea205ea3d6e9c5badf1a)) +- preserve conservative NWC budget debits ([fff24a7](https://github.com/tompro/sattle/commit/fff24a75b24f7a80e4f3f517525c7eb0c2d93155)) +- publish activity only after persistence ([54031dc](https://github.com/tompro/sattle/commit/54031dc8de03448aa3cee6c2a8a9d035d92c752c)) +- surface receive post-commit trust failures ([f087ebe](https://github.com/tompro/sattle/commit/f087ebe2f2e28bbd0e4c3cfe3407e77c0a78a7aa)) +- validate auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19)) +- validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504)) +- **wallet:** enforce owner-bound integrity and durable commits ([338b281](https://github.com/tompro/sattle/commit/338b2812e357f01027800985051e62dea6c207d8)) ## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20) - ### Features -* backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09)) -* capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece)) -* capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c)) -* deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd)) -* inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6)) -* manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607)) -* native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7)) -* nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f)) -* nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0)) -* nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a)) -* pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2)) -* reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d)) -* sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0)) -* send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f)) -* wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898)) -* wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178)) -* webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815)) - +- backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09)) +- capability layer for clipboard, share and deep links with tests ([f525b06](https://github.com/tompro/sattle/commit/f525b069664cc34fbac3c00f5101aaabd774fece)) +- capacitor android platform project ([3f22534](https://github.com/tompro/sattle/commit/3f22534cf0f30f1a2bc1ab1aa645517e85a7949c)) +- deep link routing for lightning and lnurl schemes plus association files ([229e9fc](https://github.com/tompro/sattle/commit/229e9fcf38addc843a81a4b57e1f70cec76f36fd)) +- inter-mint transfer op with fee quote and ambiguity-safe outcomes ([142734b](https://github.com/tompro/sattle/commit/142734b903e932a36db2d0e62a4c66ceb9ee9fd6)) +- manage mints and move funds settings pages ([f631c7c](https://github.com/tompro/sattle/commit/f631c7c264fe962b33ba2c8ea1cc3f8974cc6607)) +- native biometric unlock via biometric-gated wrap of the linking key ([e59b722](https://github.com/tompro/sattle/commit/e59b722b5e9093e34b336f0cb0446e34383d4df7)) +- nip-47 nwc wallet service engine with per-connection budgets ([33318a5](https://github.com/tompro/sattle/commit/33318a5150afd55b5580dd5a15052434aefde90f)) +- nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore ([7ecade3](https://github.com/tompro/sattle/commit/7ecade32eeb277d9bd6df734ddc76232f06bdee0)) +- nwc connections settings page with one-time connection strings and service lifecycle ([5221127](https://github.com/tompro/sattle/commit/52211273b15e0eb503d5466a250f756a3ed3548a)) +- pinia wallet, mints and activity stores with wallet boot ([fdd12dc](https://github.com/tompro/sattle/commit/fdd12dcc1eff116bf335673744e519975b3f3ef2)) +- reusable mint fee math with cached quotes, fee-aware Max in move funds ([f4b9377](https://github.com/tompro/sattle/commit/f4b9377d17a2736c429a7272fac8b1742098a60d)) +- sattle branding, logo assets and generated app icons ([48e4c84](https://github.com/tompro/sattle/commit/48e4c840929e68cd1740669379b7ed5ed37d32b0)) +- send/receive dialogs, qr scanner, history and unlock components ([48fffff](https://github.com/tompro/sattle/commit/48fffff02a17c303dbf807de140d56af26cae88f)) +- wallet operations engine and encrypted storage with tests ([1c9e716](https://github.com/tompro/sattle/commit/1c9e7165e4269996891e9112951c3ec75a6e6898)) +- wallet screens, branded header layout and mobile-first main view ([1fe1453](https://github.com/tompro/sattle/commit/1fe1453745f3ab9132decb984242b586588c3178)) +- webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815)) ### Bug Fixes -* bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792)) -* discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592)) +- bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792)) +- discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592)) ## Changelog diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..d88f071 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,40 @@ +/// +import { chromium } from '@playwright/test'; +import { execSync } from 'child_process'; + +// Warms the freshly booted dev server before any test runs. `quasar dev` +// answers HTTP the moment its port is up, but vite still transforms the +// module graph on the first page load - a window in which the first test's +// multi-step onboarding flow can race lazy compilation (observed as the +// create flow never reaching /#/). One full load plus a reload completes +// that work before the suite starts. Uses the same system-chromium +// resolution as the main config (CI falls back to the bundled browser). +const findSystemChromium = (): string | undefined => { + try { + return execSync('command -v chromium').toString().trim() || undefined; + } catch { + return undefined; + } +}; + +const globalSetup = async (): Promise => { + const executablePath = process.env.CHROMIUM_PATH || findSystemChromium(); + const browser = await chromium.launch({ + ...(executablePath ? { executablePath } : {}), + }); + try { + const page = await (await browser.newContext({ baseURL: 'http://localhost:9333' })).newPage(); + await page.goto('/'); + await page + .getByRole('button', { name: 'Get started' }) + .waitFor({ state: 'visible', timeout: 60_000 }); + await page.reload(); + await page + .getByRole('button', { name: 'Get started' }) + .waitFor({ state: 'visible', timeout: 60_000 }); + } finally { + await browser.close(); + } +}; + +export default globalSetup; diff --git a/e2e/helpers/MintMocker.ts b/e2e/helpers/MintMocker.ts index e46a39d..0594393 100644 --- a/e2e/helpers/MintMocker.ts +++ b/e2e/helpers/MintMocker.ts @@ -1,13 +1,26 @@ import type { Page, Route } from '@playwright/test'; +import { hashK1, noteSignatureDigestForHash } from 'lnurlcash-kit'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { utf8ToBytes } from '@noble/hashes/utils.js'; // The mock mint lives on a .test origin that never resolves - every request // to it is intercepted by Playwright and fulfilled in-process, so the specs // never touch the network. Protocol shapes mirror lnurlcash-kit's client: // the informational GET on the note URL (fetchNoteInfo) expects an LUD-03 // withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the -// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same -// callback shape also answers a melt (meltNote sends k1 + pr to the same -// callback and only requires status "OK"). +// mutating GET on the callback (rotate/split/merge/melt) expects +// {status: "OK"} plus the LUD-25 signature(s) over every minted output - +// the app forces the kit's requireSignatures policy, so an unsigned +// mutation answer is not a success the wallet accepts quietly. +// +// Signing: each origin has a stable secp256k1 signing key derived from its +// origin string, so a spec can pin the advertised mintPubkey via +// signingPubkey(origin). `invalidSignatures = true` signs every output with +// a foreign key instead - the notes are real mint-side, but the offline +// proof verifies against no key the mint advertises, which is exactly the +// "landed but unverifiable" case the wallet must refuse to mark spendable. // // Every method takes an optional origin, so a spec can stand up a SECOND // mint (e.g. MINT2_ORIGIN) for inter-mint transfers. @@ -29,13 +42,28 @@ const fulfillJson = async (route: Route, body: unknown): Promise => { }); }; +// deterministic per-origin signing keys: the mock mints are stable across +// tests, and the key derivation needs nothing but the origin string +const signingKeyFor = (origin: string): Uint8Array => + sha256(utf8ToBytes(`sattle-e2e mint signing key: ${origin}`)); + +// the key every output is signed with in invalid-signature mode: a real +// secp256k1 key, just never advertised by any mock mint +const FOREIGN_SIGNING_KEY = sha256(utf8ToBytes('sattle-e2e foreign signing key')); + +// a melt's settle proof reveals a payment receipt - derived from the paid +// invoice so it is stable per payment and never collides with a note secret +const preimageFor = (pr: string): string => bytesToHex(sha256(utf8ToBytes(`preimage: ${pr}`))); + interface MockNoteInfoOptions { // note value in msat, reported as maxWithdrawable amountMsat?: number; // when set, the mint answers the ERROR envelope with this reason instead // (a reason matching /spent/i surfaces the "already been spent" UI) spentReason?: string; - // the mint's signing pubkey, advertised as mintPubkey in responses + // the mint's signing pubkey, advertised as mintPubkey in responses - + // defaults to the mock origin's real signing key (kit 0.8 refuses a + // withdrawRequest that publishes none, so every mock mint has one) mintPubkey?: string; } @@ -43,14 +71,14 @@ interface MockTargetMintOptions { // the mint's signing pubkey (66 hex chars), advertised on the mint-address // (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest: // LUD-25 announces it on the withdraw side only, and a too-generous mock - // once masked a real bug - mintPubkey: string; + // once masked a real bug. Defaults to the origin's real signing key. + mintPubkey?: string; // the invoice this mint hands out (and reports back as settled) - keep it // amount-less (decodeBolt11AmountMsat returns null) so the kit skips the // amount cross-check invoice: string; - // the payment preimage a settled verify reveals - 64 hex chars (it IS the - // claimed note's secret) + // the payment preimage a settled verify reveals - a receipt distinct from + // the wallet-chosen secret committed in the invoice comment preimage: string; // value of the note a claim mints, in msat noteAmountMsat: number; @@ -62,10 +90,69 @@ interface MockTargetMintOptions { export class MintMocker { constructor(private page: Page) {} + // when true, every minted output is signed with a foreign key: the + // mutation lands mint-side, but the signature verifies against no key + // this mint advertises - the wallet must keep the outputs unverified + invalidSignatures = false; + + // every signature this mock issued, in order - lets a spec assert the + // mint actually signed (the kit's requireSignatures policy makes an + // unsigned success impossible to confuse with a signed one) + readonly signaturesIssued: { origin: string; h: string; amountMsat: number }[] = []; + + // the mint's advertised signing pubkey for an origin (compressed, hex) + signingPubkey(origin = MINT_ORIGIN): string { + return bytesToHex(secp256k1.getPublicKey(signingKeyFor(origin), true)); + } + + // note id (sha256 of the secret) -> outstanding value in msat, per origin. + // A real mint keys its ledger by the hash; the mock does the same so a + // mutation can sign the output with the amount the inputs actually held. + private readonly outstanding = new Map(); + private readonly burned = new Set(); + // invoices this mint reports as settled on its verify endpoint, per origin + private readonly settledInvoices = new Map(); + + private noteKey(origin: string, noteId: string): string { + return `${origin}|${noteId}`; + } + + private sign(origin: string, h: string, amountMsat: number): string { + const key = this.invalidSignatures ? FOREIGN_SIGNING_KEY : signingKeyFor(origin); + // noble's 'recovered' layout is [recovery, r, s]; the wire layout is + // r || s || recovery (the kit accepts either, the trailing layout is + // what lnurl-mint emits) + const signature = secp256k1.sign(noteSignatureDigestForHash(h, amountMsat), key, { + format: 'recovered', + prehash: false, + }); + this.signaturesIssued.push({ origin, h, amountMsat }); + return bytesToHex(new Uint8Array([...signature.subarray(1), signature[0]])); + } + + // mint outputs at the given hashes, burning the inputs - the ledger + // transition every successful mutation makes + private remint( + origin: string, + inputIds: string[], + outputs: [h: string, amountMsat: number][], + ): void { + for (const id of inputIds) { + this.outstanding.delete(this.noteKey(origin, id)); + this.burned.add(this.noteKey(origin, id)); + } + for (const [h, amountMsat] of outputs) { + this.outstanding.set(this.noteKey(origin, h), amountMsat); + } + } + // The note's informational GET: a spec-shaped withdrawRequest, or the - // ERROR envelope for a note the mint considers spent. The kit validates - // that the service echoes back the queried k1, so read it off the request. + // ERROR envelope for a note the mint considers spent or never issued. The + // kit validates that the service echoes back the queried k1, so read it + // off the request. A first-seen k1 registers at the configured amount - + // the mock's way of "issuing" the note a spec hands to the wallet. async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise { + const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin); await this.page.route( new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`), async (route: Route) => { @@ -74,27 +161,147 @@ export class MintMocker { return; } const k1 = new URL(route.request().url()).searchParams.get('k1') ?? ''; + if (!/^[0-9a-f]{64}$/i.test(k1)) { + // a by-hash lookup (h=) or a malformed k1 - this mock answers + // secret-keyed info only + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + const id = this.noteKey(origin, hashK1(k1)); + if (this.burned.has(id)) { + await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' }); + return; + } + if (!this.outstanding.has(id)) { + this.outstanding.set(id, options.amountMsat ?? 0); + } + const amountMsat = this.outstanding.get(id) ?? 0; await fulfillJson(route, { tag: 'withdrawRequest', callback: `${origin}${CALLBACK_PATH}`, k1, - minWithdrawable: options.amountMsat ?? 0, - maxWithdrawable: options.amountMsat ?? 0, + minWithdrawable: amountMsat, + maxWithdrawable: amountMsat, defaultDescription: 'mock mint note', - ...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}), + mintPubkey, }); }, ); } - // The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr - // params) both expect a plain {status: "OK"} confirmation. No sig is - // returned - a plain LUD-03-style service that does not sign notes. - async mockRotateOk(origin = MINT_ORIGIN): Promise { + // The settle proof a melt's verify URL serves: settled once the melt + // landed, carrying the paid invoice and its preimage receipt. + private async mockVerify(origin: string): Promise { + await this.page.route( + new RegExp(`^${escapeRegExp(`${origin}/verify`)}`), + async (route: Route) => { + const pr = new URL(route.request().url()).searchParams.get('pr') ?? ''; + const settled = (this.settledInvoices.get(origin) ?? []).includes(pr); + await fulfillJson(route, { + settled, + ...(settled ? { preimage: preimageFor(pr) } : {}), + pr, + }); + }, + ); + } + + // The mutating callback GET, signed per LUD-25: + // - melt (k1 + pr): burns the input, answers OK with a settle-proof URL + // - rotate (k1 + h): burns the input, mints at h for its full value + // - split (k1... + amount + h + h2): carves amount off the inputs' total, + // minting h for it and h2 for the change + // - merge (k1... + h): burns all inputs, mints h for their total + // Every minted output's signature rides in the response (sig / sig2), and + // a mutation naming an unknown or burned input is refused like a real + // mint's. A byte-identical replay gets the same success - the mutations + // are deterministic in their inputs, so the replayed answer re-derives + // the same signatures. + async mockMutationEndpoint(origin = MINT_ORIGIN): Promise { + await this.mockVerify(origin); await this.page.route( new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`), async (route: Route) => { - await fulfillJson(route, { status: 'OK' }); + const url = new URL(route.request().url()); + const k1s = url.searchParams.getAll('k1'); + if (k1s.some((k1) => !/^[0-9a-f]{64}$/i.test(k1))) { + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + const pr = url.searchParams.get('pr'); + const h = url.searchParams.get('h'); + const h2 = url.searchParams.get('h2'); + const amountRaw = url.searchParams.get('amount'); + const inputIds = k1s.map((k1) => hashK1(k1)); + const lookup = (id: string): number | 'unknown' | 'burned' => { + const key = this.noteKey(origin, id); + if (this.burned.has(key)) return 'burned'; + return this.outstanding.get(key) ?? 'unknown'; + }; + + // melt: the note burns once the payment settles; the mock answers + // OK (payment in flight) and the verify endpoint reports it settled + if (pr !== null) { + for (const id of inputIds) { + const state = lookup(id); + if (state === 'burned') { + await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' }); + return; + } + if (state === 'unknown') { + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + } + this.remint(origin, inputIds, []); + const paid = this.settledInvoices.get(origin) ?? []; + paid.push(pr); + this.settledInvoices.set(origin, paid); + await fulfillJson(route, { + status: 'OK', + verify: `${origin}/verify?pr=${encodeURIComponent(pr)}`, + }); + return; + } + + // every other mutation mints outputs whose signatures the wallet + // must be able to verify offline + const total = inputIds.reduce((sum, id) => { + const state = lookup(id); + return typeof state === 'number' ? sum + state : sum; + }, 0); + for (const id of inputIds) { + const state = lookup(id); + if (state === 'burned') { + await fulfillJson(route, { status: 'ERROR', reason: 'Note is spent' }); + return; + } + if (state === 'unknown') { + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + } + if (h !== null && h2 !== null && amountRaw !== null) { + // split: amount carved off, the rest is change + const amountMsat = Number(amountRaw); + this.remint(origin, inputIds, [ + [h, amountMsat], + [h2, total - amountMsat], + ]); + await fulfillJson(route, { + status: 'OK', + sig: this.sign(origin, h, amountMsat), + sig2: this.sign(origin, h2, total - amountMsat), + }); + return; + } + if (h !== null) { + // rotate (one input) or merge (several): full value at h + this.remint(origin, inputIds, [[h, total]]); + await fulfillJson(route, { status: 'OK', sig: this.sign(origin, h, total) }); + return; + } + await fulfillJson(route, { status: 'ERROR', reason: 'Malformed mutation request' }); }, ); } @@ -105,7 +312,7 @@ export class MintMocker { // nodeCapacityMsat, which is exactly the 0.1.0 spread bug this exercises. // The payLink points back at the lnurlp route below, as prepareMint treats // it as the authoritative place to read the payRequest from. - private async mockMintAddress(options: MockTargetMintOptions, origin: string): Promise { + private async mockMintAddress(origin: string, mintPubkey: string): Promise { await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`), async (route: Route) => { @@ -114,7 +321,7 @@ export class MintMocker { callback: `${origin}${CALLBACK_PATH}`, minWithdrawable: 1000, maxWithdrawable: 100_000_000_000, - mintPubkey: options.mintPubkey, + mintPubkey, payLink: `${origin}/.well-known/lnurlp/mint`, nodeCapacity: 500_000_000, nodeNumChannels: 4, @@ -127,15 +334,33 @@ export class MintMocker { // Everything the target side of a transfer (or a Lightning receive) // needs: the mint-address discovery endpoint carrying the mint metadata, // the payRequest at the standard mint@ address, the invoice callback, an - // immediately-settled verify endpoint revealing the preimage, and the - // note info + rotate the claim then performs. + // immediately-settled verify endpoint revealing the payment receipt, + // and note info at the wallet-chosen committed secret. async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise { - await this.mockMintAddress(options, origin); - await this.mockNoteInfo( - { amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey }, - origin, + const mintPubkey = options.mintPubkey ?? this.signingPubkey(origin); + let stagedHash: string | null = null; + let settled = false; + await this.mockMintAddress(origin, mintPubkey); + await this.mockMutationEndpoint(origin); + await this.page.route( + new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`), + async (route: Route) => { + const k1 = new URL(route.request().url()).searchParams.get('k1') ?? ''; + if (!/^[0-9a-f]{64}$/i.test(k1) || !settled || hashK1(k1) !== stagedHash) { + await fulfillJson(route, { status: 'ERROR', reason: 'Note not found' }); + return; + } + await fulfillJson(route, { + tag: 'withdrawRequest', + callback: `${origin}${CALLBACK_PATH}`, + k1, + minWithdrawable: options.noteAmountMsat, + maxWithdrawable: options.noteAmountMsat, + defaultDescription: 'mock minted note', + mintPubkey, + }); + }, ); - await this.mockRotateOk(origin); await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`), async (route: Route) => { @@ -144,6 +369,7 @@ export class MintMocker { callback: `${origin}/pay`, minSendable: 1000, maxSendable: 100_000_000_000, + commentAllowed: 64, withdrawLink: `${origin}${NOTE_PATH}`, metadata: options.mintFeeMetadata ?? '[]', }); @@ -152,18 +378,38 @@ export class MintMocker { await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`), async (route: Route) => { + const request = new URL(route.request().url()); + const comment = request.searchParams.get('comment'); + const commitment = request.searchParams.get('h'); + if (!comment || !/^[0-9a-f]{64}$/i.test(comment) || commitment !== comment) { + await fulfillJson(route, { status: 'ERROR', reason: 'Missing note commitment' }); + return; + } + stagedHash = comment.toLowerCase(); await fulfillJson(route, { pr: options.invoice, verify: `${origin}/verify`, }); }, ); + await this.page.route( + new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`), + async (route: Route) => { + const paidInvoice = new URL(route.request().url()).searchParams.get('pr'); + if (paidInvoice !== options.invoice) { + await route.fallback(); + return; + } + settled = true; + await fulfillJson(route, { status: 'OK' }); + }, + ); await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/verify`)}`), async (route: Route) => { await fulfillJson(route, { - settled: true, - preimage: options.preimage, + settled, + ...(settled ? { preimage: options.preimage } : {}), pr: options.invoice, }); }, diff --git a/e2e/helpers/wallet.ts b/e2e/helpers/wallet.ts index fd708d8..0d74605 100644 --- a/e2e/helpers/wallet.ts +++ b/e2e/helpers/wallet.ts @@ -1,4 +1,8 @@ import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit'; +import { MINT_ORIGIN, NOTE_PATH } from './MintMocker'; +import type { MintMocker } from './MintMocker'; // Drives onboarding end to end on a fresh browser context: /#/welcome -> // "Create wallet" (empty password = stored unencrypted) -> confirm the @@ -10,3 +14,34 @@ export const createFreshWallet = async (page: Page): Promise => { await page.getByRole('button', { name: 'Continue' }).click(); await page.waitForURL(/#\/$/); }; + +// Give a fresh wallet one verified, spendable note at the mock mint: the +// mock answers the note's informational GET and signs every mutation, and +// the receive's first-contact trust prompt is confirmed, pinning the mint's +// signing key so the wallet can verify what this mint mints. +export const fundMockMintWallet = async ( + page: Page, + mint: MintMocker, + amountMsat: number, +): Promise => { + await mint.mockNoteInfo({ amountMsat }); + await mint.mockMutationEndpoint(); + await createFreshWallet(page); + + await page.getByRole('button', { name: 'Receive' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); + await dialog + .locator('textarea') + .fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), amountMsat)); + await dialog.getByRole('button', { name: 'Receive', exact: true }).click(); + await expect(dialog.getByText(`Received ${Math.floor(amountMsat / 1000)} sats`)).toBeVisible(); + // the note advertises the mint's key, so the first-contact trust prompt + // opens as its own dialog - trust it (that also pins the signing key) + await page + .locator('.q-dialog', { hasText: 'New mint' }) + .getByRole('button', { name: 'Trust this mint' }) + .click(); + await dialog.getByRole('button', { name: 'Done' }).click(); +}; diff --git a/e2e/specs/backup-security.spec.ts b/e2e/specs/backup-security.spec.ts index 6aed51e..897ec09 100644 --- a/e2e/specs/backup-security.spec.ts +++ b/e2e/specs/backup-security.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from '../fixtures'; import { createFreshWallet } from '../helpers/wallet'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { utf8ToBytes } from '@noble/hashes/utils.js'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; // M4 backup & security surfaces. No real WebAuthn: headless Chromium has no // platform authenticator, so the security page is asserted in its honest @@ -12,6 +16,72 @@ const VALID_PHRASE = const readSettings = async (page: import('@playwright/test').Page) => JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}'); +// A v2 encrypted wallet-material record built with the app's own wrap +// (keys.ts/passwordWrap.ts: PBKDF2-SHA256 210k -> AES-GCM), for specs that +// need a saved, locked wallet without driving the onboarding UI. +const PASSWORD = 'correct horse battery staple'; +const PBKDF2_ITERATIONS = 210_000; + +const encryptedWalletMaterialRecord = async ( + password: string, +): Promise<{ + enc: true; + salt: string; + iv: string; + ciphertext: string; + materialHash: string; + ownerId: string; + version: 2; +}> => { + const linkingKeyHex = '07'.repeat(32); + const material = { + version: 2, + linkingKeyHex, + cashRootHex: '08'.repeat(64), + }; + const serialized = JSON.stringify(material); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const baseKey = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveKey'], + ); + const aesKey = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'], + ); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + aesKey, + new TextEncoder().encode(serialized), + ), + ); + return { + enc: true, + salt: bytesToHex(salt), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + materialHash: bytesToHex(sha256(utf8ToBytes(serialized))), + ownerId: bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(linkingKeyHex), true)), + version: 2, + }; +}; + +const hexToBytesLocal = (hex: string): Uint8Array => { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +}; + test.describe('Backup page', () => { test('renders all sections and exports a JSON backup file', async ({ page }) => { await createFreshWallet(page); @@ -93,19 +163,22 @@ test.describe('Security page', () => { }); await createFreshWallet(page); await page.evaluate(() => { - const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}'); + const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}'); if ( typeof saved !== 'object' || saved === null || !('ownerId' in saved) || - typeof saved.ownerId !== 'string' + typeof saved.ownerId !== 'string' || + !('materialHash' in saved) || + typeof saved.materialHash !== 'string' ) { throw new Error('expected saved wallet owner'); } const wrap = { hkdfSalt: '11'.repeat(16), iv: '22'.repeat(12), - wrappedKey: '33'.repeat(48), + materialHash: saved.materialHash, + wrappedMaterial: '33'.repeat(48), createdAt: 1, }; localStorage.setItem( @@ -116,14 +189,14 @@ test.describe('Security page', () => { credentialId: '44'.repeat(16), name: 'Current wallet passkey', ownerId: saved.ownerId, - version: 1, + version: 2, }, { ...wrap, credentialId: '55'.repeat(16), name: 'Foreign wallet passkey', ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967', - version: 1, + version: 2, }, ]), ); @@ -137,36 +210,39 @@ test.describe('Security page', () => { await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0); }); - test('hides passkey-first unlock for a markerless saved wallet', async ({ page }) => { - // Given an encrypted saved key and passkey slot without owner markers - await page.addInitScript(() => { - localStorage.setItem( - 'sattle_linking_key', - JSON.stringify({ - enc: true, - salt: '11'.repeat(16), - iv: '22'.repeat(12), - ciphertext: '33'.repeat(48), - }), - ); - localStorage.setItem( - 'sattle_passkey_slots', - JSON.stringify([ - { - credentialId: '44'.repeat(16), - hkdfSalt: '55'.repeat(16), - iv: '66'.repeat(12), - wrappedKey: '77'.repeat(48), - createdAt: 1, - }, - ]), - ); - }); + test('hides passkey-first unlock when no passkey belongs to the saved wallet', async ({ + page, + }) => { + // Given an encrypted v2 saved wallet whose only passkey slot belongs to + // a DIFFERENT owner (a foreign wallet's residue on the same device) + const record = await encryptedWalletMaterialRecord(PASSWORD); + await page.addInitScript( + ({ storedRecord }) => { + localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(storedRecord)); + localStorage.setItem( + 'sattle_passkey_slots', + JSON.stringify([ + { + credentialId: '44'.repeat(16), + hkdfSalt: '55'.repeat(16), + iv: '66'.repeat(12), + materialHash: '77'.repeat(32), + wrappedMaterial: '88'.repeat(48), + createdAt: 1, + ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967', + version: 2, + }, + ]), + ); + }, + { storedRecord: record }, + ); // When the locked wallet renders await page.goto('/'); - // Then passkey-first unlock is unavailable until another owner proof + // Then passkey-first unlock is unavailable - the one slot on this + // device is not this wallet's await expect(page.getByText('Wallet locked')).toBeVisible(); await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0); }); diff --git a/e2e/specs/manage-mints.spec.ts b/e2e/specs/manage-mints.spec.ts index d9dc426..2f987b8 100644 --- a/e2e/specs/manage-mints.spec.ts +++ b/e2e/specs/manage-mints.spec.ts @@ -38,6 +38,7 @@ const mockSuggestionMint = async (page: Page): Promise => { callback: 'https://mint.600.wtf/pay', minSendable: 1000, maxSendable: 100_000_000_000, + commentAllowed: 64, withdrawLink: 'https://mint.600.wtf/note', metadata: '[]', }), @@ -87,8 +88,8 @@ test.describe('Manage mints', () => { test('a mint with held notes cannot be removed', async ({ page, mint }) => { // hold a 21-sat note from the mock mint - holding funds locks the mint // against removal - await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY }); - await mint.mockRotateOk(); + await mint.mockNoteInfo({ amountMsat: 21_000 }); + await mint.mockMutationEndpoint(); await createFreshWallet(page); await page.getByRole('button', { name: 'Receive' }).click(); diff --git a/e2e/specs/move-funds.spec.ts b/e2e/specs/move-funds.spec.ts index 5a0cd7a..0adcb52 100644 --- a/e2e/specs/move-funds.spec.ts +++ b/e2e/specs/move-funds.spec.ts @@ -1,13 +1,10 @@ import { test, expect } from '../fixtures'; import type { Page } from '@playwright/test'; -import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit'; -import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker'; -import { createFreshWallet } from '../helpers/wallet'; +import { MINT2_ORIGIN } from '../helpers/MintMocker'; +import { fundMockMintWallet } from '../helpers/wallet'; const AMOUNT_MSAT = 50_000; // 50 sats -const MINT_PUBKEY = `02${'ab'.repeat(32)}`; -const TARGET_PUBKEY = `03${'cd'.repeat(32)}`; -// 64 hex chars - a valid preimage (it becomes the claimed note's secret) +// 64 hex chars - a valid payment receipt, distinct from the claimed note secret const PREIMAGE = 'ef'.repeat(32); // amount-less by decodeBolt11AmountMsat, so the kit skips its invoice // amount cross-check against the requested value @@ -15,26 +12,7 @@ const INVOICE = 'lnmock1transfer'; // give the wallet a verified, spendable 50-sat note at the source mint const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => { - await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); - await mint.mockRotateOk(); - await createFreshWallet(page); - - await page.getByRole('button', { name: 'Receive' }).click(); - const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' }); - await chooser.getByRole('button', { name: 'Bearer note' }).click(); - const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); - await dialog - .locator('textarea') - .fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT)); - await dialog.getByRole('button', { name: 'Receive', exact: true }).click(); - await expect(dialog.getByText('Received 50 sats')).toBeVisible(); - // the note advertises a mint key, so the first-contact trust prompt opens - // as its own dialog - trust it (that also locks the mint) - await page - .locator('.q-dialog', { hasText: 'New mint' }) - .getByRole('button', { name: 'Trust this mint' }) - .click(); - await dialog.getByRole('button', { name: 'Done' }).click(); + await fundMockMintWallet(page, mint, AMOUNT_MSAT); }; // pick an option from a Quasar select identified by its label @@ -78,7 +56,6 @@ test.describe('Move funds', () => { // the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm) await mint.mockTargetMint( { - mintPubkey: TARGET_PUBKEY, invoice: INVOICE, preimage: PREIMAGE, noteAmountMsat: AMOUNT_MSAT, @@ -100,11 +77,10 @@ test.describe('Move funds', () => { test('a two-mint transfer moves the balance', async ({ page, mint }) => { await fundSourceMint(page, mint); - // the target mint: hands out the invoice, reports it settled with the - // preimage, and answers the claim's note info + rotation + // the target mint commits the wallet-chosen secret, hands out the invoice, + // and exposes that note only after the source-mint melt pays it await mint.mockTargetMint( { - mintPubkey: TARGET_PUBKEY, invoice: INVOICE, preimage: PREIMAGE, noteAmountMsat: AMOUNT_MSAT, diff --git a/e2e/specs/nwc.spec.ts b/e2e/specs/nwc.spec.ts index e7a77ce..789c2ca 100644 --- a/e2e/specs/nwc.spec.ts +++ b/e2e/specs/nwc.spec.ts @@ -1,17 +1,34 @@ import { test, expect } from '../fixtures'; -import { createFreshWallet } from '../helpers/wallet'; +import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet'; +import { hexToBytes } from '@noble/hashes/utils.js'; +import { finalizeEvent } from 'nostr-tools/pure'; +import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from 'nostr-tools/nip04'; // M5 Nostr Wallet Connect settings surface. No real relay traffic: the one // test that runs the service injects a fake transport through the dev-only // window hook (stores/nwc.ts) before enabling - every other test stays // UI-only with the service off. +// a Nostr event as the fake transport carries it - the plain JSON shape +// NIP-47 requests and responses take on the wire +type WireEvent = { + id: string; + pubkey: string; + kind: number; + created_at: number; + tags: string[][]; + content: string; + sig: string; +}; + type FakeSub = { closed: boolean; close: () => void }; declare global { interface Window { __sattleNwcTest: { setTransport: (transport: unknown) => void }; __nwcSubs: FakeSub[]; + __nwcPublished: WireEvent[]; + __nwcOnEvent?: (event: WireEvent) => void; } } @@ -146,4 +163,112 @@ test.describe('NWC settings page', () => { .poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed))) .toBe(true); }); + + test('a client pays an invoice and the wallet settles it at the mint', async ({ page, mint }) => { + // a funded wallet: 50 sats at the mock mint, its signing key pinned + await fundMockMintWallet(page, mint, 50_000); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + + // create a connection and read its one-time URI: the wallet service + // pubkey is the host, the client secret the `secret` param + await openNwcPage(page); + await page.getByRole('button', { name: 'Create connection' }).click(); + const uri = (await page.locator('.nwc-connection-string').textContent())?.trim(); + expect(uri).toBeTruthy(); + if (!uri) throw new Error('Expected the one-time NWC connection string.'); + await page.getByRole('button', { name: "Done - I've saved it" }).click(); + const connectionUrl = new URL(uri); + const walletServicePubkey = connectionUrl.host; + const clientSecret = connectionUrl.searchParams.get('secret') ?? ''; + + // a fake transport that captures the request handler and every + // published event, installed before the service starts + await page.evaluate(() => { + window.__nwcSubs = []; + window.__nwcPublished = []; + window.__sattleNwcTest.setTransport({ + publish: (_relays: string[], event: WireEvent) => { + window.__nwcPublished.push(event); + return Promise.resolve(); + }, + subscribe: (_relays: string[], _filter: unknown, onEvent: (event: WireEvent) => void) => { + window.__nwcOnEvent = onEvent; + const sub: FakeSub = { + closed: false, + close() { + this.closed = true; + }, + }; + window.__nwcSubs.push(sub); + return sub; + }, + }); + }); + await page.locator('.q-toggle[aria-label="Enable Nostr Wallet Connect"]').click(); + await expect(page.getByText('Service running', { exact: false })).toBeVisible(); + await expect.poll(async () => page.evaluate(() => typeof window.__nwcOnEvent)).toBe('function'); + + // a 21-sat invoice the mint will settle; the request is exactly what a + // NIP-47 client sends: kind 23194, NIP-04 encrypted to the service, + // schnorr-signed by the connection's client secret + const invoice = 'lnbc210n1pjqrstuvwxyz'; + const content = nip04Encrypt( + clientSecret, + walletServicePubkey, + JSON.stringify({ method: 'pay_invoice', params: { invoice } }), + ); + const request = finalizeEvent( + { + kind: 23194, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', walletServicePubkey], + ['encryption', 'nip04'], + ], + content, + }, + hexToBytes(clientSecret), + ); + await page.evaluate((event) => window.__nwcOnEvent?.(event), request); + + // the service pays and answers on the same scheme + await expect + .poll(async () => + page.evaluate( + (id) => + window.__nwcPublished.find( + (event) => + event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id), + ) ?? null, + request.id, + ), + ) + .not.toBeNull(); + const responseEvent = await page.evaluate( + (id) => + window.__nwcPublished.find( + (event) => + event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id), + ) ?? null, + request.id, + ); + if (!responseEvent) throw new Error('the service published no response'); + const response: unknown = JSON.parse( + nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content), + ); + if (typeof response !== 'object' || response === null) { + throw new Error('the service response is not an object'); + } + const result = response as { result_type?: unknown; error?: unknown; result?: unknown }; + expect(result.result_type).toBe('pay_invoice'); + expect(result.error).toBeNull(); + // the receipt is the melt's payment preimage from the mint's own + // settle proof - 64 hex chars, never a note secret + const payload = result.result as { preimage?: unknown }; + expect(payload.preimage).toMatch(/^[0-9a-f]{64}$/); + + // the wallet paid 21 of its 50 sats; the change stays in the wallet + await page.goto('/#/'); + await expect(page.locator('.balance-card .text-h2')).toHaveText('29'); + }); }); diff --git a/e2e/specs/receive.spec.ts b/e2e/specs/receive.spec.ts index f5eeee7..11afbae 100644 --- a/e2e/specs/receive.spec.ts +++ b/e2e/specs/receive.spec.ts @@ -5,7 +5,6 @@ import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker'; import { createFreshWallet } from '../helpers/wallet'; const AMOUNT_MSAT = 21_000; // 21 sats -const MINT_PUBKEY = `02${'aa'.repeat(32)}`; // a syntactically valid bearer note against the mock mint - the k1 is a // fresh random secret, so every test redeems a distinct note @@ -26,7 +25,7 @@ const redeemNote = async (page: Page, noteUrl: string): Promise => { test.describe('Receive bearer note', () => { test('redeeming a valid note updates the balance', async ({ page, mint }) => { await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT }); - await mint.mockRotateOk(); + await mint.mockMutationEndpoint(); await createFreshWallet(page); const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); @@ -38,6 +37,18 @@ test.describe('Receive bearer note', () => { // were really consumed (the kit bug this guards against silently fell // back to the declared amount without any request) await expect(dialog.locator('.q-banner')).toHaveCount(0); + // the mint signed the rotation's output (LUD-25) - the wallet's + // requireSignatures policy accepted a signed answer, not a rescue + expect( + mint.signaturesIssued.some( + (entry) => entry.origin === MINT_ORIGIN && entry.amountMsat === AMOUNT_MSAT, + ), + ).toBe(true); + + // the note advertised the mint's key, so the first-contact trust prompt + // opened - dismiss it, then close the success screen + const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' }); + await trustDialog.getByRole('button', { name: 'Just this once' }).click(); await dialog.getByRole('button', { name: 'Done' }).click(); await expect(page.locator('.balance-card .text-h2')).toHaveText('21'); }); @@ -64,8 +75,8 @@ test.describe('Receive bearer note', () => { page, mint, }) => { - await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); - await mint.mockRotateOk(); + await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT }); + await mint.mockMutationEndpoint(); await createFreshWallet(page); const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); await redeemNote(page, freshNoteUrl()); @@ -87,8 +98,8 @@ test.describe('Receive bearer note', () => { page, mint, }) => { - await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); - await mint.mockRotateOk(); + await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT }); + await mint.mockMutationEndpoint(); await createFreshWallet(page); await page.evaluate(() => { localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}'); diff --git a/e2e/specs/send.spec.ts b/e2e/specs/send.spec.ts new file mode 100644 index 0000000..4f28698 --- /dev/null +++ b/e2e/specs/send.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '../fixtures'; +import type { Page } from '@playwright/test'; +import { + fromBech32Lnurl, + noteDeclaredAmount, + noteK1, + noteSignature, + verifyNoteSignature, +} from 'lnurlcash-kit'; +import { fundMockMintWallet } from '../helpers/wallet'; + +const FUNDING_MSAT = 50_000; // 50 sats +const SEND_MSAT = 21_000; // 21 sats + +// give the wallet a verified, spendable 50-sat note at the mock mint, with +// the mint's signing key pinned through the first-contact trust prompt +const fundMockMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => { + await fundMockMintWallet(page, mint, FUNDING_MSAT); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); +}; + +// prepare a note hand-over and read the displayed note off the clipboard +const prepareNote = async (page: Page, sats: string): Promise => { + await page.getByRole('button', { name: 'Send' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + const dialog = page.locator('.q-dialog', { hasText: 'Send a note' }); + await dialog.getByLabel('Amount (sats)').fill(sats); + await dialog.getByRole('button', { name: 'Prepare note' }).click(); + await expect(dialog.getByText('Tap to reveal')).toBeVisible(); + await dialog.getByRole('button', { name: 'Copy' }).click(); + return page.evaluate(() => navigator.clipboard.readText()); +}; + +test.describe('Send a note', () => { + test('a carved note carries a signature that verifies against the mint', async ({ + page, + mint, + }) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await fundMockMint(page, mint); + + // the mint signed the split's two outputs (the carved note and the + // change), and the note the dialog hands over carries that signature + // in its URL + const beforeSend = mint.signaturesIssued.length; + const bech32 = await prepareNote(page, '21'); + const issued = mint.signaturesIssued + .slice(beforeSend) + .map((entry) => entry.amountMsat) + .sort((a, b) => a - b); + expect(issued).toEqual([SEND_MSAT, FUNDING_MSAT - SEND_MSAT]); + const url = fromBech32Lnurl(bech32.trim()); + expect(url).not.toBeNull(); + if (!url) throw new Error('the dialog handed over an undecodable note'); + const k1 = noteK1(url); + const amountMsat = noteDeclaredAmount(url); + const signature = noteSignature(url); + expect(k1).not.toBeNull(); + expect(amountMsat).toBe(SEND_MSAT); + expect(signature).not.toBeNull(); + if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param'); + // the offline proof verifies against the key the mint advertised and + // the wallet pinned - this is what marked the carved note spendable + expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(true); + + // keep the note: nothing was handed over, the balance is untouched + const dialog = page.locator('.q-dialog', { hasText: 'Send a note' }); + await dialog.getByRole('button', { name: 'Keep in wallet' }).click(); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + }); + + test('outputs the mint signs with a foreign key never become spendable', async ({ + page, + mint, + }) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await fundMockMint(page, mint); + + // the mint's mutations land but are signed by a key it never advertised + mint.invalidSignatures = true; + const bech32 = await prepareNote(page, '21'); + + // the note exists (the split landed mint-side), but its offline proof + // verifies against no key this mint publishes + const url = fromBech32Lnurl(bech32.trim()); + if (!url) throw new Error('the dialog handed over an undecodable note'); + const k1 = noteK1(url); + const amountMsat = noteDeclaredAmount(url); + const signature = noteSignature(url); + if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param'); + expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(false); + + // keep the note: the balance is not corrupted by the bad proof + const sendDialog = page.locator('.q-dialog', { hasText: 'Send a note' }); + await sendDialog.getByRole('button', { name: 'Keep in wallet' }).click(); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + + // the carve's outputs stayed unverified, so the wallet refuses to spend + // from them - a second preparation fails instead of moving bad notes + await page.getByRole('button', { name: 'Send' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + const retry = page.locator('.q-dialog', { hasText: 'Send a note' }); + await retry.getByLabel('Amount (sats)').fill('21'); + await retry.getByRole('button', { name: 'Prepare note' }).click(); + await expect( + retry.locator('.q-banner', { hasText: 'Not enough spendable balance' }), + ).toBeVisible(); + await expect(retry.getByText('Tap to reveal')).toHaveCount(0); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + }); +}); diff --git a/e2e/specs/trusted-mint-tabs.spec.ts b/e2e/specs/trusted-mint-tabs.spec.ts index ff2566c..444cabd 100644 --- a/e2e/specs/trusted-mint-tabs.spec.ts +++ b/e2e/specs/trusted-mint-tabs.spec.ts @@ -3,6 +3,14 @@ import type { Page, TestInfo } from '@playwright/test'; import { test, expect } from '../fixtures'; import { createFreshWallet } from '../helpers/wallet'; +// the dev-only lifecycle hook (boot/wallet.ts) used to drive the forget +// transition, which has no UI surface +declare global { + interface Window { + __sattleWalletTest: { state: () => string; forget: () => Promise }; + } +} + const MINT_KEY_A = `02${'aa'.repeat(32)}`; const MINT_KEY_B = `03${'bb'.repeat(32)}`; const MINT_KEY_C = `02${'cc'.repeat(32)}`; @@ -23,7 +31,7 @@ const addMint = async (page: Page, server: string, mintPubkey: string): Promise< const ownerId = async (page: Page): Promise => page.evaluate(() => { - const raw = localStorage.getItem('sattle_linking_key'); + const raw = localStorage.getItem('sattle_wallet_material_v2'); if (raw === null) return null; const saved: unknown = JSON.parse(raw); if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null; diff --git a/e2e/specs/wallet-lifecycle.spec.ts b/e2e/specs/wallet-lifecycle.spec.ts index 217ee15..7fdf13f 100644 --- a/e2e/specs/wallet-lifecycle.spec.ts +++ b/e2e/specs/wallet-lifecycle.spec.ts @@ -1,215 +1,95 @@ -import { bytesToHex } from '@noble/hashes/utils.js'; -import { secp256k1 } from '@noble/curves/secp256k1.js'; - import { test, expect } from '../fixtures'; -// Wallet lifecycle in the real browser: a LEGACY (ownerless) encrypted wallet -// with ownerless passkey/NWC/trust residue must migrate to the proven owner -// during password unlock - before the NWC service may start - and forgetting -// the wallet must drain/stop that service and remove every wallet-owned key -// before a successor wallet can be created without any of the old residue. -// -// The legacy encrypted record is produced here with the exact same KDF/wrap -// the app uses (keys.ts: PBKDF2-SHA256 210k -> AES-GCM), just without the -// owner marker a current build would stamp. +// Wallet lifecycle in the real browser: an unsupported LEGACY (alpha) +// install - the linking-key-only record, the pre-v2 bearer store and their +// ownerless residue - is never migrated (the cash root is unrecoverable +// from a linking key), so boot resets the device to the uninstalled state +// before any route can expose it, and a successor wallet starts without +// any of the old residue. -declare global { - interface Window { - __sattleWalletTest: { state: () => string; forget: () => Promise }; - } -} - -const PASSWORD = 'correct horse battery staple'; -const LINKING_KEY_HEX = '07'.repeat(32); -const OWNER_ID = bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(LINKING_KEY_HEX), true)); -const MINT_PUBKEY = '02' + 'aa'.repeat(32); - -function hexToBytesLocal(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i += 1) { - bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -const PBKDF2_ITERATIONS = 210_000; - -const legacyEncryptedRecord = async ( - valueHex: string, - password: string, -): Promise<{ salt: string; iv: string; ciphertext: string }> => { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const baseKey = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(password), - 'PBKDF2', - false, - ['deriveKey'], - ); - const aesKey = await crypto.subtle.deriveKey( - { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, - baseKey, - { name: 'AES-GCM', length: 256 }, - false, - ['encrypt'], - ); - const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - aesKey, - new TextEncoder().encode(valueHex), - ), - ); - return { salt: bytesToHex(salt), iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) }; -}; - -const WALLET_KEYS = [ +// every namespace the alpha wallet could have touched +const LEGACY_KEYS = [ 'sattle_linking_key', 'sattle_bearers', - 'sattle_activity', - 'sattle_settings', + 'sattle_wallet_material_v2', 'sattle_passkey_slots', 'sattle_nwc_connections', 'sattle_nwc_enabled', 'sattle_trusted_mints', 'sattle_biometric_wrap', + 'sattle_activity', + 'sattle_settings', ] as const; test.describe('wallet lifecycle', () => { - test('legacy unlock migrates, forget wipes the owner, successor starts clean', async ({ + test('a legacy alpha install is reset at boot and the successor starts clean', async ({ page, }) => { - // Given a legacy encrypted wallet (no owner marker) plus ownerless residue - const record = await legacyEncryptedRecord(LINKING_KEY_HEX, PASSWORD); - await page.addInitScript( - ({ storedRecord, mintPubkey }) => { - localStorage.setItem('sattle_linking_key', JSON.stringify({ enc: true, ...storedRecord })); - localStorage.setItem( - 'sattle_passkey_slots', - JSON.stringify([ - { - credentialId: '11'.repeat(16), - hkdfSalt: '22'.repeat(16), - iv: '33'.repeat(12), - wrappedKey: '44'.repeat(48), - createdAt: 1, - }, - ]), - ); - localStorage.setItem( - 'sattle_nwc_connections', - JSON.stringify([ - { - clientPubkey: '55'.repeat(32), - relays: ['wss://relay.example'], - budget: { maxMsat: 1000, periodMs: 60_000 }, - spent: { periodStart: 0, msat: 0 }, - createdAt: 1, - }, - ]), - ); - localStorage.setItem('sattle_nwc_enabled', 'true'); - localStorage.setItem( - 'sattle_trusted_mints', - JSON.stringify([{ server: 'legacy.example', mintPubkey, addedAt: 1, locked: false }]), - ); - }, - { storedRecord: record, mintPubkey: MINT_PUBKEY }, - ); + // Given a legacy encrypted linking-key record plus ownerless residue + await page.addInitScript(() => { + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: true, + salt: '11'.repeat(16), + iv: '22'.repeat(12), + ciphertext: '33'.repeat(48), + }), + ); + localStorage.setItem( + 'sattle_bearers', + JSON.stringify([{ url: 'https://mint.example/note?k1=legacy', amount: 1000 }]), + ); + localStorage.setItem( + 'sattle_passkey_slots', + JSON.stringify([ + { + credentialId: '11'.repeat(16), + hkdfSalt: '22'.repeat(16), + iv: '33'.repeat(12), + wrappedKey: '44'.repeat(48), + createdAt: 1, + }, + ]), + ); + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + { + clientPubkey: '55'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 1000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }, + ]), + ); + localStorage.setItem('sattle_nwc_enabled', 'true'); + localStorage.setItem( + 'sattle_trusted_mints', + JSON.stringify([ + { + server: 'legacy.example', + mintPubkey: `02${'aa'.repeat(32)}`, + addedAt: 1, + locked: false, + }, + ]), + ); + }); + // When the app boots await page.goto('/#/'); - // the unlock screen proves the app boot finished and the dev hooks exist - await expect(page.getByText('Wallet locked')).toBeVisible(); - await expect - .poll(async () => page.evaluate(() => typeof window.__sattleNwcTest)) - .toBe('object'); - - // no real relay traffic once the migrated enabled state starts the service - await page.evaluate(() => { - window.__nwcSubs = []; - window.__sattleNwcTest.setTransport({ - publish: () => Promise.resolve(), - subscribe: () => { - const sub = { - closed: false, - close() { - this.closed = true; - }, - }; - window.__nwcSubs.push(sub); - return sub; - }, - }); - }); - - // When the holder proves the wallet by password - await page.locator('.unlock-card input').fill(PASSWORD); - await page.getByRole('button', { name: 'Unlock', exact: true }).click(); - - // Then the wallet unlocks... - await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible(); - - // ...and every legacy namespace was migrated to the proven owner BEFORE - // the NWC service could start (the subscriptions below only exist because - // the migrated owner-scoped enabled record read true) - const migrated = await page.evaluate(() => { - const field = (storageKey: string, name: string): unknown => { - const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); - return typeof value === 'object' && value !== null - ? Object.entries(value).find(([key]) => key === name)?.[1] - : undefined; - }; - const fields = (storageKey: string, name: string): unknown[] => { - const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]'); - if (!Array.isArray(value)) return []; - return value.map((entry: unknown) => - typeof entry === 'object' && entry !== null - ? Object.entries(entry).find(([key]) => key === name)?.[1] - : undefined, - ); - }; - const mints: unknown = field('sattle_trusted_mints', 'mints'); - return { - savedKeyOwner: field('sattle_linking_key', 'ownerId'), - passkeyOwners: fields('sattle_passkey_slots', 'ownerId'), - nwcEnabledOwner: field('sattle_nwc_enabled', 'ownerId'), - nwcEnabled: field('sattle_nwc_enabled', 'enabled'), - nwcConnectionOwners: fields('sattle_nwc_connections', 'ownerId'), - trustedMintsOwner: field('sattle_trusted_mints', 'ownerId'), - trustedMintServers: Array.isArray(mints) - ? mints.map((mint: unknown) => - typeof mint === 'object' && mint !== null && 'server' in mint - ? mint.server - : undefined, - ) - : [], - }; - }); - expect(migrated.savedKeyOwner).toBe(OWNER_ID); - expect(migrated.passkeyOwners).toEqual([OWNER_ID]); - expect({ ownerId: migrated.nwcEnabledOwner, enabled: migrated.nwcEnabled }).toEqual({ - ownerId: OWNER_ID, - enabled: true, - }); - expect(migrated.nwcConnectionOwners).toEqual([OWNER_ID]); - expect(migrated.trustedMintsOwner).toBe(OWNER_ID); - expect(migrated.trustedMintServers).toEqual(['legacy.example']); - await expect.poll(async () => page.evaluate(() => window.__nwcSubs.length)).toBeGreaterThan(0); - - // When the wallet is forgotten - await page.evaluate(() => window.__sattleWalletTest.forget()); - - // Then the app lands on the no-wallet screen, the service is drained - // (every subscription closed), and no wallet-owned key remains + // Then the alpha reset landed on the uninstalled state - no unlock + // surface for a wallet that can never be derived again await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible(); - await expect - .poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed))) - .toBe(true); + await expect(page.getByText('Wallet locked')).toHaveCount(0); + + // ...and every namespace the alpha wallet could have touched is gone const remaining = await page.evaluate( (keys) => keys.filter((key) => localStorage.getItem(key) !== null), - [...WALLET_KEYS], + [...LEGACY_KEYS], ); expect(remaining).toEqual([]); @@ -218,25 +98,24 @@ test.describe('wallet lifecycle', () => { await page.getByRole('button', { name: 'Create wallet' }).click(); await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click(); await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible(); - // Then it is a different owner with zero adopted residue - const successor = await page.evaluate(() => { - const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? 'null'); - return { - ownerId: - typeof saved === 'object' && saved !== null && 'ownerId' in saved - ? saved.ownerId - : undefined, - residueKeys: [ - 'sattle_passkey_slots', - 'sattle_nwc_connections', - 'sattle_nwc_enabled', - 'sattle_trusted_mints', - ].filter((key) => localStorage.getItem(key) !== null), - }; - }); - expect(successor.ownerId).not.toBe(OWNER_ID); - expect(successor.residueKeys).toEqual([]); + // Then it unlocks into a working wallet with zero adopted residue + // (settings/activity are the live app's own namespaces - the residue + // check covers only the stores a predecessor could have planted) + await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible(); + await expect(page.locator('.balance-card .text-h2')).toHaveText('0'); + const residue = await page.evaluate( + (keys) => keys.filter((key) => localStorage.getItem(key) !== null), + [ + 'sattle_linking_key', + 'sattle_bearers', + 'sattle_passkey_slots', + 'sattle_nwc_connections', + 'sattle_nwc_enabled', + 'sattle_trusted_mints', + 'sattle_biometric_wrap', + ], + ); + expect(residue).toEqual([]); }); }); diff --git a/package-lock.json b/package-lock.json index d9f2235..320bbd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@quasar/extras": "^2.0.4", "@scure/bip32": "^2.3.0", "@scure/bip39": "^2.3.0", - "lnurlcash-kit": "0.5.0", + "lnurlcash-kit": "0.8.0", "nostr-tools": "2.24.3", "pinia": "^4.0.2", "qrcode.vue": "^3.10.0", @@ -46,7 +46,7 @@ "eslint": "^10.8.0", "eslint-plugin-vue": "^10.8.0", "globals": "^17.4.0", - "lnurlcash-conformance": "0.4.0", + "lnurlcash-conformance": "0.7.0", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", @@ -5961,9 +5961,9 @@ } }, "node_modules/lnurlcash-conformance": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.4.0.tgz", - "integrity": "sha512-PsmjMrle5DE+OeTxLdBRD6ah63m9XPpNlPvB9f4FUdCtI6FbW6k5HcinMMt0mTkd9X9iOZHKG9SIqZ5HQJ0BDQ==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.7.0.tgz", + "integrity": "sha512-YJRFmPoJ9NyG5UcrjqMcU8PYkwcsgx6vorzBrF8sB2KCdn3KUmWGDe3RSQhV8VPxIyBd46BbxpBnNhf159fYZA==", "dev": true, "license": "MIT", "dependencies": { @@ -5987,9 +5987,9 @@ } }, "node_modules/lnurlcash-kit": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.5.0.tgz", - "integrity": "sha512-ASX+/yduZjV9aLSoUXlP06dWNJ6Bt3IYrG0QoAqtvnC1h8lCs5rWVzw9RgmE0j1sZs16V2RicDlaSvON4JV8Pg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.8.0.tgz", + "integrity": "sha512-AnlqAED2Qh/4T0gDJJEusPRzOavpC6LEAqSucwWbd22LgYi0VrOBrW0XjPlpUCKOvKGdLJUA1nd4Dxu9ME9k2A==", "license": "MIT", "dependencies": { "@noble/curves": "^2.3.0", diff --git a/package.json b/package.json index b6a9531..d571000 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@quasar/extras": "^2.0.4", "@scure/bip32": "^2.3.0", "@scure/bip39": "^2.3.0", - "lnurlcash-kit": "0.5.0", + "lnurlcash-kit": "0.8.0", "nostr-tools": "2.24.3", "pinia": "^4.0.2", "qrcode.vue": "^3.10.0", @@ -57,7 +57,7 @@ "eslint": "^10.8.0", "eslint-plugin-vue": "^10.8.0", "globals": "^17.4.0", - "lnurlcash-conformance": "0.4.0", + "lnurlcash-conformance": "0.7.0", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", diff --git a/playwright.config.ts b/playwright.config.ts index ebc968a..35ce509 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,6 +13,7 @@ const findSystemChromium = (): string | undefined => { export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json index 3d2c304..ec3737f 100644 --- a/public/.well-known/assetlinks.json +++ b/public/.well-known/assetlinks.json @@ -4,9 +4,7 @@ "target": { "namespace": "android_app", "package_name": "eu.protom.sattle", - "sha256_cert_fingerprints": [ - "REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT" - ] + "sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"] } } ] diff --git a/src-pwa/register-sw.ts b/src-pwa/register-sw.ts index eeffb6b..6c1882b 100644 --- a/src-pwa/register-sw.ts +++ b/src-pwa/register-sw.ts @@ -1,4 +1,4 @@ -import { register } from "register-service-worker"; +import { register } from 'register-service-worker'; // The ready(), registered(), cached(), updatefound() and updated() // events passes a ServiceWorkerRegistration instance in their arguments. @@ -37,5 +37,5 @@ register(import.meta.env.QUASAR_SERVICE_WORKER_FILE, { error(/* err */) { // console.error('Error during service worker registration:', err) - } + }, }); diff --git a/src-pwa/sw/custom-sw.ts b/src-pwa/sw/custom-sw.ts index 3832060..171d7d2 100644 --- a/src-pwa/sw/custom-sw.ts +++ b/src-pwa/sw/custom-sw.ts @@ -4,13 +4,13 @@ * quasar.config file > pwa > workboxMode is set to "InjectManifest" */ -import { clientsClaim } from "workbox-core"; -import { NavigationRoute, registerRoute } from "workbox-routing"; +import { clientsClaim } from 'workbox-core'; +import { NavigationRoute, registerRoute } from 'workbox-routing'; import { cleanupOutdatedCaches, createHandlerBoundToURL, - precacheAndRoute -} from "workbox-precaching"; + precacheAndRoute, +} from 'workbox-precaching'; declare const self: ServiceWorkerGlobalScope & typeof globalThis; @@ -26,14 +26,8 @@ if (import.meta.env.QUASAR_PROD) { // Non-SSR/SSG fallbacks to index.html // Production SSR/SSG fallbacks to offline.html (except for dev) registerRoute( - new NavigationRoute( - createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), - { - denylist: [ - new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), - /workbox-(.)*\.js$/ - ] - } - ) + new NavigationRoute(createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), { + denylist: [new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/], + }), ); } diff --git a/src/boot/wallet.ts b/src/boot/wallet.ts index 88f9f58..4ec96bb 100644 --- a/src/boot/wallet.ts +++ b/src/boot/wallet.ts @@ -13,9 +13,12 @@ declare global { } // Wallet lifecycle bootstrap: reflects whatever is on this device into the -// wallet store at app start - a plaintext-stored key unlocks straight away, -// a password-encrypted one lands on 'locked' for the unlock screen, and no -// key at all lands on 'none' for onboarding. +// wallet store at app start - a plaintext-stored material record unlocks +// straight away, a password-encrypted one lands on 'locked' for the unlock +// screen, and no record at all lands on 'none' for onboarding. init() first +// runs the alpha reset: unsupported legacy state (the linking-key-only +// record or the pre-v2 bearer namespace) is wiped, never migrated, before +// any route can expose it. export default defineBoot(async () => { const wallet = useWalletStore(); await wallet.init(); diff --git a/src/capabilities/biometricUnlock.test.ts b/src/capabilities/biometricUnlock.test.ts index 150777d..fd880d5 100644 --- a/src/capabilities/biometricUnlock.test.ts +++ b/src/capabilities/biometricUnlock.test.ts @@ -1,101 +1,470 @@ +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { bytesToHex } from '@noble/hashes/utils.js'; -import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys'; -import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; -import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots'; +import { + deriveWalletMaterial, + serializeWalletMaterial, + walletMaterialHash, + type WalletMaterialV2, +} from '@/lnurlcash/keys'; +import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; +import { saveWalletMaterial } from '@/lnurlcash/walletMaterialStorage'; import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils'; const pluginMocks = vi.hoisted(() => ({ authenticate: vi.fn<() => Promise>(), + checkBiometry: vi.fn<() => Promise<{ isAvailable: boolean; reason?: string }>>(), secureGet: vi.fn<(key: string) => Promise>(), + secureRemove: vi.fn<(key: string) => Promise>(), + secureSet: vi.fn<(key: string, value: string) => Promise>(), + secureValues: new Map(), })); vi.mock('@aparajita/capacitor-biometric-auth', () => ({ AndroidBiometryStrength: { weak: 'weak' }, - BiometricAuth: { authenticate: pluginMocks.authenticate }, + BiometricAuth: { + authenticate: pluginMocks.authenticate, + checkBiometry: pluginMocks.checkBiometry, + }, BiometryError: class BiometryError extends Error {}, BiometryErrorType: { userCancel: 'userCancel' }, })); vi.mock('@aparajita/capacitor-secure-storage', () => ({ - SecureStorage: { get: pluginMocks.secureGet }, + SecureStorage: { + get: pluginMocks.secureGet, + remove: pluginMocks.secureRemove, + set: pluginMocks.secureSet, + }, })); vi.mock('./platform', () => ({ isNative: () => true })); -import { unlockWithBiometrics } from './biometricUnlock'; +import { + disableBiometricUnlock, + enableBiometricUnlock, + unlockWalletMaterialWithBiometrics, +} from './biometricUnlock'; -const LINKING_KEY = new Uint8Array(32).fill(7); -const OTHER_LINKING_KEY = new Uint8Array(32).fill(9); +const SEED = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const OTHER_SEED = 'legal winner thank year wave sausage worth useful legal winner thank yellow'; +const MATERIAL = deriveWalletMaterial(SEED); +const OTHER_MATERIAL = deriveWalletMaterial(OTHER_SEED); +// same linking key (same owner) as MATERIAL, but a different, VALID cash root +const ALTERED_ROOT_MATERIAL: WalletMaterialV2 = { + ...MATERIAL, + cashRootHex: OTHER_MATERIAL.cashRootHex, +}; const WRAP_SECRET = new Uint8Array(32).fill(3); +const WRAP_RECORD_KEY = 'sattle_biometric_wrap'; +const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; +const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2'; +const WRAP_KEY_HKDF_INFO = utf8ToBytes('sattle-biometric-wrap-v2'); + +const persistedState = () => ({ + legacyKey: localStorage.getItem('sattle_linking_key'), + legacyNwc: localStorage.getItem('sattle_nwc_connections'), + legacyPasskeys: localStorage.getItem('sattle_passkey_slots'), + legacyTrust: localStorage.getItem('sattle_trusted_mints'), + material: localStorage.getItem(MATERIAL_STORAGE_KEY), + secureSecret: pluginMocks.secureValues.get(SECURE_SECRET_KEY) ?? null, + wrap: localStorage.getItem(WRAP_RECORD_KEY), +}); + +const deriveFixtureWrapKey = async ( + secret: Uint8Array, + hkdfSalt: Uint8Array, +): Promise => { + const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(hkdfSalt), + info: new Uint8Array(WRAP_KEY_HKDF_INFO), + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; + +const storePayload = async ( + plaintext: Uint8Array, + pubkey: string, + materialHash: string, + secret = WRAP_SECRET, +): Promise => { + const hkdfSalt = new Uint8Array(16).fill(4); + const iv = new Uint8Array(12).fill(5); + const wrapKey = await deriveFixtureWrapKey(secret, hkdfSalt); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new Uint8Array(plaintext)), + ); + localStorage.setItem( + WRAP_RECORD_KEY, + JSON.stringify({ + version: 2, + hkdfSalt: bytesToHex(hkdfSalt), + iv: bytesToHex(iv), + wrappedKey: bytesToHex(ciphertext), + pubkey, + materialHash, + createdAt: 1, + }), + ); + pluginMocks.secureValues.set(SECURE_SECRET_KEY, bytesToHex(secret)); +}; + +const storeMaterialPayload = async ( + material: WalletMaterialV2, + pubkey = walletMaterialOwnerId(material), + secret = WRAP_SECRET, + materialHash = walletMaterialHash(material), +): Promise => + storePayload(utf8ToBytes(serializeWalletMaterial(material)), pubkey, materialHash, secret); beforeEach(() => { vi.clearAllMocks(); stubLocalStorage(); + pluginMocks.secureValues.clear(); pluginMocks.authenticate.mockResolvedValue(); - pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET)); + pluginMocks.checkBiometry.mockResolvedValue({ isAvailable: true }); + pluginMocks.secureGet.mockImplementation((key) => + Promise.resolve(pluginMocks.secureValues.get(key) ?? null), + ); + pluginMocks.secureSet.mockImplementation((key, value) => { + pluginMocks.secureValues.set(key, value); + return Promise.resolve(); + }); + pluginMocks.secureRemove.mockImplementation((key) => { + pluginMocks.secureValues.delete(key); + return Promise.resolve(); + }); }); -describe('biometric unlock owner proof', () => { - it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => { - // Given an ownerless legacy wallet and credential residue plus a biometric wrap - // whose claimed pubkey does not match the key it unwraps - const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) }; - const legacySlot = { - credentialId: '11'.repeat(16), - hkdfSalt: '22'.repeat(16), - iv: '33'.repeat(12), - wrappedKey: '44'.repeat(48), - createdAt: 1, - }; - const legacyNwc = [ - { - clientPubkey: '55'.repeat(32), - relays: ['wss://relay.example'], - budget: { maxMsat: 1000, periodMs: 60_000 }, - spent: { periodStart: 0, msat: 0 }, - createdAt: 1, - }, - ]; - const legacyTrust = [ - { - server: 'legacy.example', - mintPubkey: '02' + 'aa'.repeat(32), - addedAt: 1, - locked: false, - }, - ]; - localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey)); - localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot])); - localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc)); - localStorage.setItem('sattle_nwc_enabled', 'true'); - localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust)); - const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY); +describe('biometric wallet material', () => { + it('rejects foreign enrollment before writing either biometric store', async () => { + // Given one proven saved owner and complete material for another owner + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + + // When enrollment is attempted with the foreign material + const attempt = enableBiometricUnlock(OTHER_MATERIAL); + + // Then ownership fails before native or local persistence changes + await expect(attempt).rejects.toThrow('proven saved wallet owner'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('does not create a local wrap when secure-secret persistence fails', async () => { + // Given valid material and a rejecting native secure store + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + pluginMocks.secureSet.mockRejectedValueOnce(new Error('secure write failed')); + + // When enrollment reaches native secret persistence + const attempt = enableBiometricUnlock(MATERIAL); + + // Then failure is reported without a misleading local enrollment record + await expect(attempt).rejects.toThrow('secure write failed'); + expect(persistedState()).toEqual(before); + }); + + it('round-trips both key parts when enrollment belongs to the saved owner', async () => { + // Given complete canonical material persisted for its proven owner + await saveWalletMaterial(MATERIAL); + + // When the capability enrolls and unlocks it through the native adapter + await enableBiometricUnlock(MATERIAL); + const beforeUnlock = persistedState(); + const unlocked = await unlockWalletMaterialWithBiometrics(); + + // Then both key parts return together and unlock does not rewrite persistence + expect(unlocked).toEqual(MATERIAL); + expect(persistedState()).toEqual(beforeUnlock); + }); + + it.each([ + ['malformed JSON', utf8ToBytes('{')], + ['legacy key-only payload', new Uint8Array(32).fill(7)], + [ + 'altered root', + utf8ToBytes( + JSON.stringify({ + ...MATERIAL, + cashRootHex: '00'.repeat(64), + }), + ), + ], + ])('returns no material for an authenticated %s and preserves state', async (_label, payload) => { + // Given an authenticated non-canonical payload bound to the saved owner + await saveWalletMaterial(MATERIAL); + await storePayload(payload, walletMaterialOwnerId(MATERIAL), walletMaterialHash(MATERIAL)); + const before = persistedState(); + + // When biometric unlock decrypts the payload + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither key part crosses the boundary and persistence is byte-identical + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('returns no material for a wrong secure secret and preserves state', async () => { + // Given a valid wrap whose secure secret has been replaced + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + pluginMocks.secureValues.set(SECURE_SECRET_KEY, '09'.repeat(32)); + const before = persistedState(); + + // When biometric unlock authenticates with the wrong wrapping secret + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then authenticated decryption fails without changing either store + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('returns no material for altered ciphertext and preserves state', async () => { + // Given a valid wrap with one ciphertext nibble altered + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + const record = parseJsonObject(localStorage.getItem(WRAP_RECORD_KEY) ?? '{}'); + const wrappedKey = record.wrappedKey; + if (typeof wrappedKey !== 'string') throw new Error('fixture wrap is missing ciphertext'); localStorage.setItem( - 'sattle_biometric_wrap', + WRAP_RECORD_KEY, JSON.stringify({ - ...wrap, - pubkey: linkingPubKeyHex(OTHER_LINKING_KEY), - createdAt: 1, + ...record, + wrappedKey: `${wrappedKey.startsWith('0') ? '1' : '0'}${wrappedKey.slice(1)}`, }), ); - const before = new Map([ - ['sattle_linking_key', localStorage.getItem('sattle_linking_key')], - ['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')], - ['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')], - ['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')], - ['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')], - ]); - - // When biometric unwrap reaches the pubkey proof check - const attempt = unlockWithBiometrics(); - - // Then no key crosses the capability boundary and no legacy namespace is adopted + const before = persistedState(); + + // When biometric unlock verifies the AES-GCM tag + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither key part returns and persistence remains untouched + await expect(attempt).rejects.toThrow(); + expect(persistedState()).toEqual(before); + }); + + it('rejects a stale wrap after saved-owner replacement without rewriting it', async () => { + // Given material wrapped for one owner and saved material replaced by another + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(MATERIAL); + await saveWalletMaterial(OTHER_MATERIAL); + const before = persistedState(); + + // When the stale biometric record is used + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then no old material returns and both stores remain byte-identical + await expect(attempt).rejects.toThrow('different wallet'); + expect(persistedState()).toEqual(before); + }); + + it('rejects authenticated material owned by another linking key', async () => { + // Given the saved owner and wrap claim owner A but authenticated material owner B + await saveWalletMaterial(MATERIAL); + localStorage.setItem('sattle_linking_key', 'legacy-key-residue'); + localStorage.setItem('sattle_nwc_connections', 'legacy-nwc-residue'); + localStorage.setItem('sattle_passkey_slots', 'legacy-passkey-residue'); + localStorage.setItem('sattle_trusted_mints', 'legacy-trust-residue'); + await storeMaterialPayload( + OTHER_MATERIAL, + walletMaterialOwnerId(MATERIAL), + WRAP_SECRET, + walletMaterialHash(MATERIAL), + ); + const before = persistedState(); + + // When biometric unlock checks the decrypted linking-key owner + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then neither foreign key part returns and persistence is unchanged await expect(attempt).rejects.toThrow('different wallet'); - expect(savedKeyOwnerId()).toBeNull(); - expect(readPasskeySlots()).toEqual([]); - expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey); - for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value); + expect(persistedState()).toEqual(before); + }); +}); + +describe('biometric exact-material commitment', () => { + it('rejects same-owner altered-root enrollment before writing either biometric store', async () => { + // Given one proven saved owner and well-formed material with only the cash root swapped + await saveWalletMaterial(MATERIAL); + const before = persistedState(); + + // When enrollment is attempted with the same-owner altered-root material + const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL); + + // Then the exact-material commitment fails before native or local persistence changes + await expect(attempt).rejects.toThrow('saved wallet material'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects same-owner altered-root enrollment against encrypted saved material', async () => { + // Given the saved material envelope is password-encrypted (no plaintext available) + await saveWalletMaterial(MATERIAL, 'password'); + const before = persistedState(); + + // When enrollment is attempted with the same-owner altered-root material + const attempt = enableBiometricUnlock(ALTERED_ROOT_MATERIAL); + + // Then the hash commitment alone rejects it before either store changes + await expect(attempt).rejects.toThrow('saved wallet material'); + expect(persistedState()).toEqual(before); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects enrollment when same-owner saved material changes during the prompt', async () => { + // Given valid enrollment input and a prompt-time swap of only the saved cash root + await saveWalletMaterial(MATERIAL); + pluginMocks.authenticate.mockImplementationOnce(async () => { + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + }); + + // When enrollment runs the biometric prompt + const attempt = enableBiometricUnlock(MATERIAL); + + // Then the post-prompt commitment recheck fails and no biometric store is written + await expect(attempt).rejects.toThrow('changed'); + expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull(); + expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false); + expect(pluginMocks.secureSet).not.toHaveBeenCalled(); + }); + + it('rejects an authenticated well-formed same-owner altered-root payload', async () => { + // Given an authenticated wrap that claims the saved commitment but whose + // decrypted material swaps only the cash root + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload( + ALTERED_ROOT_MATERIAL, + walletMaterialOwnerId(ALTERED_ROOT_MATERIAL), + WRAP_SECRET, + walletMaterialHash(MATERIAL), + ); + const before = persistedState(); + + // When biometric unlock decrypts the well-formed same-owner payload + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the material commitment rejects it and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(persistedState()).toEqual(before); + }); + + it('rejects a wrap whose own commitment differs from the saved material before prompting', async () => { + // Given an authenticated wrap committed to same-owner altered-root material + await saveWalletMaterial(MATERIAL); + await storeMaterialPayload(ALTERED_ROOT_MATERIAL); + const before = persistedState(); + + // When biometric unlock inspects the record commitment + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then it rejects before any prompt and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(pluginMocks.authenticate).not.toHaveBeenCalled(); + expect(persistedState()).toEqual(before); + }); + + it('rejects a stale wrap after same-owner saved material replacement', async () => { + // Given an enrolled wrap and a later saved-material swap of only the cash root + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + const before = persistedState(); + + // When the now-stale biometric wrap is used + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the stale commitment rejects and both stores stay byte-identical + await expect(attempt).rejects.toThrow('wallet material'); + expect(persistedState()).toEqual(before); + }); + + it('rejects unlock when same-owner saved material changes during the prompt', async () => { + // Given an enrolled wrap and a prompt-time swap of only the saved cash root + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + let swapped: ReturnType | null = null; + pluginMocks.authenticate.mockImplementationOnce(async () => { + await saveWalletMaterial(ALTERED_ROOT_MATERIAL); + swapped = persistedState(); + }); + + // When biometric unlock runs the prompt + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the post-prompt commitment recheck rejects and the ceremony writes nothing + await expect(attempt).rejects.toThrow('changed'); + expect(swapped).not.toBeNull(); + expect(persistedState()).toEqual(swapped); + }); + + it('rejects unlock when the wrap record is replaced during the prompt', async () => { + // Given an enrolled wrap replaced mid-prompt by another valid same-owner record + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + let swapped: ReturnType | null = null; + pluginMocks.authenticate.mockImplementationOnce(async () => { + const secret = pluginMocks.secureValues.get(SECURE_SECRET_KEY); + if (typeof secret !== 'string') throw new Error('expected an enrolled secure secret'); + await storeMaterialPayload(MATERIAL, walletMaterialOwnerId(MATERIAL), hexToBytes(secret)); + swapped = persistedState(); + }); + + // When biometric unlock runs the prompt + const attempt = unlockWalletMaterialWithBiometrics(); + + // Then the record recheck rejects and the ceremony writes nothing itself + await expect(attempt).rejects.toThrow('changed'); + expect(swapped).not.toBeNull(); + expect(persistedState()).toEqual(swapped); + }); +}); + +describe('biometric deletion ordering', () => { + it('keeps native and local state retryable when secure-secret deletion fails', async () => { + // Given a complete enrolled biometric wrap + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + const before = persistedState(); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('native deletion failed')); + + // When native secure-secret deletion fails + const attempt = disableBiometricUnlock(); + + // Then failure is reported before either persisted part is removed + await expect(attempt).rejects.toThrow('native deletion failed'); + expect(persistedState()).toEqual(before); + }); + + it('supports repeated delete interruption followed by a successful retry', async () => { + // Given two interrupted deletion attempts for an enrolled wrap + await saveWalletMaterial(MATERIAL); + await enableBiometricUnlock(MATERIAL); + const before = persistedState(); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('first interruption')); + pluginMocks.secureRemove.mockRejectedValueOnce(new Error('second interruption')); + + // When deletion is retried until native removal succeeds + await expect(disableBiometricUnlock()).rejects.toThrow('first interruption'); + expect(persistedState()).toEqual(before); + await expect(disableBiometricUnlock()).rejects.toThrow('second interruption'); + expect(persistedState()).toEqual(before); + await disableBiometricUnlock(); + + // Then native state is gone before the local wrap is removed + expect(pluginMocks.secureValues.has(SECURE_SECRET_KEY)).toBe(false); + expect(localStorage.getItem(WRAP_RECORD_KEY)).toBeNull(); + expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before.material); }); }); diff --git a/src/capabilities/biometricUnlock.ts b/src/capabilities/biometricUnlock.ts index f911eb1..ae9239f 100644 --- a/src/capabilities/biometricUnlock.ts +++ b/src/capabilities/biometricUnlock.ts @@ -1,23 +1,25 @@ -// Native biometric unlock: a THIRD wrap of the same linking key, alongside -// the password wrap (keys.ts) and the passkey slots (passkeys.ts). +// Native biometric unlock: a THIRD wrap of the same canonical wallet material, +// alongside the password wrap (keys.ts) and the passkey slots (passkeys.ts). // // Why native: Android WebView has no usable WebAuthn platform authenticator // for this app (passkeys need Play Services / Digital Asset Links wiring we // cannot rely on), so the biometric path on native is a device-credential // prompt instead of a passkey ceremony. // -// Design (mirrors the passkey-slot construction, reusing its wrap -// primitives verbatim): on enrollment a random 32-byte wrap secret is -// generated, the linking key is AES-GCM-wrapped under an HKDF of that -// secret (wrapLinkingKeyWithPrf - the "PRF output" parameter is just 32 -// bytes of IKM), and only the SECRET goes into biometric-gated secure +// Design (mirrors the passkey-slot construction with a biometric-specific +// HKDF domain): on enrollment a random 32-byte wrap secret is generated, +// canonical v2 material is AES-GCM-wrapped under an HKDF of that secret, +// and only the SECRET goes into biometric-gated secure // storage (Android Keystore-backed AES-GCM via // @aparajita/capacitor-secure-storage). The wrapped blob stays in -// localStorage as a record shaped like a passkey slot, plus the linking -// pubkey as an identity check: restoring a DIFFERENT seed leaves a stale -// wrap behind, and unlocking with it must fail loudly (never activate the -// old wallet silently), so unlock verifies the unwrapped key against the -// recorded pubkey and tells the holder to re-enroll. +// localStorage as a versioned AES-GCM record, plus the linking +// pubkey as an identity check AND the SHA-256 commitment of the canonical +// saved material: restoring a DIFFERENT seed leaves a stale wrap behind, +// and so does replacing only the saved cash root with same-owner material. +// Unlocking with either must fail loudly (never activate the old wallet +// silently), so unlock verifies the record commitment and the unwrapped +// material against the saved wallet material and tells the holder to +// re-enroll. // // The biometric gate is app-level (a BiometricPrompt before the secure // read), not a keystore key invalidated on biometric re-enrollment - 04 @@ -36,39 +38,90 @@ import { BiometryErrorType, } from '@aparajita/capacitor-biometric-auth'; import { SecureStorage } from '@aparajita/capacitor-secure-storage'; -import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; -import { linkingPubKeyHex } from '@/lnurlcash/keys'; -import type { PasskeyWrap } from '@/lnurlcash/passkeys'; -import { unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys'; +import { + getPlainWalletMaterial, + parseWalletMaterial, + savedWalletMaterialHash, + savedWalletMaterialOwnerId, + serializeWalletMaterial, + walletMaterialHash, + type WalletMaterialV2, +} from '@/lnurlcash/keys'; +import { isJsonObject } from '@/lnurlcash/jsonParsing'; +import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret'; import { isNative } from './platform'; -type BiometricWrapRecord = PasskeyWrap & { - pubkey: string; // linking pubkey the wrap belongs to - detects stale wraps - createdAt: number; +type BiometricWrapRecord = { + readonly version: 2; + readonly hkdfSalt: string; + readonly iv: string; + readonly wrappedKey: string; + readonly pubkey: string; + readonly materialHash: string; + readonly createdAt: number; }; // the wrapped blob is useless without the secure-storage secret, so (like // the passkey slots) this record sits in plain localStorage const WRAP_RECORD_STORAGE_KEY = 'sattle_biometric_wrap'; const SECURE_SECRET_KEY = 'sattle-biometric-wrap-secret'; +const BIOMETRIC_WRAP_VERSION = 2 as const; +const WRAP_KEY_HKDF_INFO = 'sattle-biometric-wrap-v2'; +const WRAP_RECORD_KEYS = [ + 'version', + 'hkdfSalt', + 'iv', + 'wrappedKey', + 'pubkey', + 'materialHash', + 'createdAt', +] as const; + +const deriveBiometricWrapKey = async ( + secret: Uint8Array, + hkdfSalt: Uint8Array, +): Promise => { + const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(secret), 'HKDF', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(hkdfSalt), + info: new Uint8Array(utf8ToBytes(WRAP_KEY_HKDF_INFO)), + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; const isValidWrapRecord = (record: unknown): record is BiometricWrapRecord => { - if (typeof record !== 'object' || record === null) return false; - const r = record as Record; + if (!isJsonObject(record)) return false; return ( - typeof r.hkdfSalt === 'string' && - /^[0-9a-f]{32}$/i.test(r.hkdfSalt) && - typeof r.iv === 'string' && - /^[0-9a-f]{24}$/i.test(r.iv) && - typeof r.wrappedKey === 'string' && - r.wrappedKey.length > 0 && - r.wrappedKey.length % 2 === 0 && - /^[0-9a-f]+$/i.test(r.wrappedKey) && - typeof r.pubkey === 'string' && - /^[0-9a-f]{66}$/i.test(r.pubkey) && - typeof r.createdAt === 'number' + Object.keys(record).length === WRAP_RECORD_KEYS.length && + Object.keys(record).every((key) => WRAP_RECORD_KEYS.some((expected) => expected === key)) && + record.version === BIOMETRIC_WRAP_VERSION && + typeof record.hkdfSalt === 'string' && + /^[0-9a-f]{32}$/.test(record.hkdfSalt) && + typeof record.iv === 'string' && + /^[0-9a-f]{24}$/.test(record.iv) && + typeof record.wrappedKey === 'string' && + record.wrappedKey.length > 0 && + record.wrappedKey.length % 2 === 0 && + /^[0-9a-f]+$/.test(record.wrappedKey) && + typeof record.pubkey === 'string' && + /^[0-9a-f]{66}$/.test(record.pubkey) && + typeof record.materialHash === 'string' && + /^[0-9a-f]{64}$/.test(record.materialHash) && + typeof record.createdAt === 'number' && + Number.isSafeInteger(record.createdAt) && + record.createdAt >= 0 ); }; @@ -83,6 +136,18 @@ const readWrapRecord = (): BiometricWrapRecord | null => { } }; +const biometricWrapRecordsEqual = ( + left: BiometricWrapRecord, + right: BiometricWrapRecord, +): boolean => + left.version === right.version && + left.hkdfSalt === right.hkdfSalt && + left.iv === right.iv && + left.wrappedKey === right.wrappedKey && + left.pubkey === right.pubkey && + left.materialHash === right.materialHash && + left.createdAt === right.createdAt; + // sync on purpose (same convention as hasPasskeySlots): the unlock form and // the security page ask this during render/setup export const isBiometricUnlockEnrolled = (): boolean => readWrapRecord() !== null; @@ -121,52 +186,125 @@ const authenticateOrThrow = async (reason: string): Promise => { } }; -export const enableBiometricUnlock = async (linkingKey: Uint8Array): Promise => { +export const enableBiometricUnlock = async (material: WalletMaterialV2): Promise => { if (!isNative()) throw new Error('Biometric unlock is only available in the native app.'); + const serialized = serializeWalletMaterial(material); + const canonicalMaterial = parseWalletMaterial(serialized); + if (canonicalMaterial === null) throw new Error('Biometric wallet material is invalid.'); + const ownerId = walletMaterialOwnerId(canonicalMaterial); + if (savedWalletMaterialOwnerId() !== ownerId) { + throw new Error('Biometric enrollment requires the proven saved wallet owner.'); + } + // exact-material commitment (same check as passkey slots): the enrolled + // bytes must equal the canonical saved material, not just share its owner + const materialHash = savedWalletMaterialHash(); + const plaintext = getPlainWalletMaterial(); + if ( + materialHash === null || + walletMaterialHash(canonicalMaterial) !== materialHash || + (plaintext !== null && serializeWalletMaterial(plaintext) !== serialized) + ) { + throw new Error('Biometric enrollment requires the exact saved wallet material.'); + } const biometry = await BiometricAuth.checkBiometry(); if (!biometry.isAvailable) { throw new Error(biometry.reason || 'No biometric unlock is set up on this device.'); } // prove presence before storing anything under the biometric gate await authenticateOrThrow('Set up biometric unlock for your wallet'); + // the prompt yields time: re-verify the commitment before writing anything + if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) { + throw new Error('Saved wallet material changed during biometric enrollment.'); + } const secret = crypto.getRandomValues(new Uint8Array(32)); - const wrap = await wrapLinkingKeyWithPrf(secret, linkingKey); + const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const wrapKey = await deriveBiometricWrapKey(secret, hkdfSalt); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + wrapKey, + new Uint8Array(utf8ToBytes(serialized)), + ), + ); // secure storage first: if it fails, no record is written and the wallet // simply stays unenrolled instead of carrying an unwrap-able-nothing await SecureStorage.set(SECURE_SECRET_KEY, bytesToHex(secret)); const record: BiometricWrapRecord = { - ...wrap, - pubkey: linkingPubKeyHex(linkingKey), + version: BIOMETRIC_WRAP_VERSION, + hkdfSalt: bytesToHex(hkdfSalt), + iv: bytesToHex(iv), + wrappedKey: bytesToHex(ciphertext), + pubkey: ownerId, + materialHash, createdAt: Date.now(), }; localStorage.setItem(WRAP_RECORD_STORAGE_KEY, JSON.stringify(record)); }; -export const unlockWithBiometrics = async (): Promise => { +export const unlockWalletMaterialWithBiometrics = async (): Promise => { const record = readWrapRecord(); if (!isNative() || !record) { throw new Error('Biometric unlock is not set up on this device.'); } - await authenticateOrThrow('Unlock your sattle wallet'); - const secretHex = await SecureStorage.get(SECURE_SECRET_KEY); - if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) { - throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.'); - } - const linkingKey = await unwrapLinkingKeyWithPrf(hexToBytes(secretHex), record); - if (linkingPubKeyHex(linkingKey) !== record.pubkey) { + const ownerId = savedWalletMaterialOwnerId(); + if (ownerId === null || ownerId !== record.pubkey) { throw new Error( 'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.', ); } - return linkingKey; + // the stored commitment must match the canonical saved material, or the + // wrap is stale (same-owner cash-root replacement) and must not unlock + const materialHash = savedWalletMaterialHash(); + if (materialHash === null || record.materialHash !== materialHash) { + throw new Error( + 'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.', + ); + } + await authenticateOrThrow('Unlock your sattle wallet'); + // the prompt yields time: re-read the record and the saved commitment + // before any decrypted plaintext can reach the caller + const currentRecord = readWrapRecord(); + if (currentRecord === null || !biometricWrapRecordsEqual(record, currentRecord)) { + throw new Error('The biometric wrap changed during the unlock ceremony.'); + } + if (savedWalletMaterialOwnerId() !== ownerId || savedWalletMaterialHash() !== materialHash) { + throw new Error('Saved wallet material changed during the unlock ceremony.'); + } + const secretHex = await SecureStorage.get(SECURE_SECRET_KEY); + if (typeof secretHex !== 'string' || !/^[0-9a-f]{64}$/i.test(secretHex)) { + throw new Error('Biometric unlock data is missing - set it up again in Settings > Security.'); + } + const wrapKey = await deriveBiometricWrapKey(hexToBytes(secretHex), hexToBytes(record.hkdfSalt)); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: new Uint8Array(hexToBytes(record.iv)) }, + wrapKey, + new Uint8Array(hexToBytes(record.wrappedKey)), + ); + const material = parseWalletMaterial(new TextDecoder().decode(plaintext)); + if ( + material === null || + savedWalletMaterialOwnerId() !== ownerId || + walletMaterialOwnerId(material) !== ownerId + ) { + throw new Error( + 'Biometric unlock belongs to a different wallet - set it up again in Settings > Security.', + ); + } + // the decrypted material itself must carry the committed cash root + if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) { + throw new Error( + 'Biometric unlock belongs to different wallet material - set it up again in Settings > Security.', + ); + } + return material; }; -// the localStorage record goes first and synchronously: even if the caller -// fire-and-forgets this (forgetWallet), a later unlock attempt can never -// reach the secure secret with a stale record +// Native deletion goes first so a failure leaves the complete enrollment +// available for the locked lifecycle to retry safely. export const disableBiometricUnlock = async (): Promise => { - localStorage.removeItem(WRAP_RECORD_STORAGE_KEY); if (isNative()) { await SecureStorage.remove(SECURE_SECRET_KEY); } + localStorage.removeItem(WRAP_RECORD_STORAGE_KEY); }; diff --git a/src/components/QrScanner.vue b/src/components/QrScanner.vue index 4f35253..db31c2d 100644 --- a/src/components/QrScanner.vue +++ b/src/components/QrScanner.vue @@ -19,19 +19,14 @@ onMounted(async () => { return; } if (!videoEl.value) return; - scanner = new QrScanner( - videoEl.value, - (result) => emit('decode', result.data), - { highlightScanRegion: true }, - ); + scanner = new QrScanner(videoEl.value, (result) => emit('decode', result.data), { + highlightScanRegion: true, + }); await scanner.start(); starting.value = false; } catch (err) { starting.value = false; - emit( - 'error', - err instanceof Error ? err.message : 'Camera access failed.', - ); + emit('error', err instanceof Error ? err.message : 'Camera access failed.'); } }); diff --git a/src/components/receive/ReceiveDialog.vue b/src/components/receive/ReceiveDialog.vue index 1d718ad..5010425 100644 --- a/src/components/receive/ReceiveDialog.vue +++ b/src/components/receive/ReceiveDialog.vue @@ -50,9 +50,7 @@
Bearer note
-
- Paste or scan a note someone sent you -
+
Paste or scan a note someone sent you
@@ -108,7 +106,9 @@ const onReceived = () => { border-radius: 12px; padding: 14px 16px; cursor: pointer; - transition: background-color 0.15s ease, border-color 0.15s ease; + transition: + background-color 0.15s ease, + border-color 0.15s ease; } .action-row:hover, diff --git a/src/components/receive/ReceiveLightningDialog.vue b/src/components/receive/ReceiveLightningDialog.vue index 4851300..5f7208c 100644 --- a/src/components/receive/ReceiveLightningDialog.vue +++ b/src/components/receive/ReceiveLightningDialog.vue @@ -113,6 +113,7 @@ {{ claimError }} emit('sent');