Formatting

This commit is contained in:
2026-09-05 18:30:35 +02:00
parent 454a9db1d5
commit 4caa075052
133 changed files with 8260 additions and 8181 deletions
+75 -81
View File
@@ -2,108 +2,102 @@
## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31) ## [0.0.4](https://github.com/tompro/sattle/compare/sattle-v0.0.3...sattle-v0.0.4) (2026-08-31)
### Features ### Features
* **funds:** keep the claimable note of a pending named transfer ([c996393](https://github.com/tompro/sattle/commit/c9963934aa6bc384cd9742e099b2c72b1f69e400)) - **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:** 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:** 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)) - **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)) - **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)) - **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)) - support LUD-25 named mint outputs (lnurl-mint v0.4.0) ([f3d5cc4](https://github.com/tompro/sattle/commit/f3d5cc4b31cf0dba845e3abc4fa1b108c34122e5))
### Bug Fixes ### 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 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)) - 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:** 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:** 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)) - **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)) - 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) ## [0.0.3](https://github.com/tompro/sattle/compare/sattle-v0.0.2...sattle-v0.0.3) (2026-08-24)
### Features ### Features
* activate only proven wallet owners ([c319e11](https://github.com/tompro/sattle/commit/c319e11d3eff2a4ae0bb42d23628750ed12de8fc)) - 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)) - 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 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)) - 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 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - harden passkey ceremonies ([d9be457](https://github.com/tompro/sattle/commit/d9be457221a774d330c9d7c7cfa67487ece2a039))
* invalidate stale wallet tabs ([2e0f003](https://github.com/tompro/sattle/commit/2e0f0038f756d935c3674fb85c0d042a946ff9d8)) - 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)) - 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)) - 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 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 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)) - 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 trusted mint transactions ([d735aca](https://github.com/tompro/sattle/commit/d735aca885a63ff2bf9d96dd6732f02e63c382d4))
* serialize wallet lifecycle transitions ([62b93ba](https://github.com/tompro/sattle/commit/62b93baa05789961f2e8ed336c1d079e666f311e)) - 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)) - validate trusted mint registry envelopes ([82f008b](https://github.com/tompro/sattle/commit/82f008b33f0843b8c999d26f1f7d4dd62e6c45b2))
### Bug Fixes ### Bug Fixes
* await NWC payment bearer commits ([09b7b63](https://github.com/tompro/sattle/commit/09b7b63c9ddf5f19bc14bd35f253387b7862cece)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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 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 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 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 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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 auxiliary wallet storage ([0862a10](https://github.com/tompro/sattle/commit/0862a10b01d33fd9c0f7f7a3f69a738e89af8c19))
* validate NWC requests before dispatch ([b52172c](https://github.com/tompro/sattle/commit/b52172c0cb28884b0d00636f601cac0a52e6d504)) - 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)) - **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) ## [0.0.2](https://github.com/tompro/sattle/compare/sattle-v0.0.1...sattle-v0.0.2) (2026-08-20)
### Features ### Features
* backup and security settings pages, nostr restore onboarding, passkey unlock entry ([1920a00](https://github.com/tompro/sattle/commit/1920a00cd65651f057cc44f4e38d14f8353cfa09)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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)) - 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 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)) - 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)) - webauthn prf passkey unlock engine as alternative wrap of the linking key ([7db3408](https://github.com/tompro/sattle/commit/7db34083e6345fb5acbf31722736d24fa0441815))
### Bug Fixes ### Bug Fixes
* bind window.fetch at boot so lnurlcash-kit transport works in browsers ([1839700](https://github.com/tompro/sattle/commit/18397002150eca483ba1f00951715e30fbacd792)) - 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)) - discover a mint's signing key from the mint-address endpoint, not the payRequest ([3926121](https://github.com/tompro/sattle/commit/39261212cf1b27626b2f33de045081a9adfc2592))
## Changelog ## Changelog
+1 -3
View File
@@ -4,9 +4,7 @@
"target": { "target": {
"namespace": "android_app", "namespace": "android_app",
"package_name": "eu.protom.sattle", "package_name": "eu.protom.sattle",
"sha256_cert_fingerprints": [ "sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"]
"REPLACE_WITH_RELEASE_KEYSTORE_SHA256_CERT_FINGERPRINT"
]
} }
} }
] ]
+2 -2
View File
@@ -1,4 +1,4 @@
import { register } from "register-service-worker"; import { register } from 'register-service-worker';
// The ready(), registered(), cached(), updatefound() and updated() // The ready(), registered(), cached(), updatefound() and updated()
// events passes a ServiceWorkerRegistration instance in their arguments. // events passes a ServiceWorkerRegistration instance in their arguments.
@@ -37,5 +37,5 @@ register(import.meta.env.QUASAR_SERVICE_WORKER_FILE, {
error(/* err */) { error(/* err */) {
// console.error('Error during service worker registration:', err) // console.error('Error during service worker registration:', err)
} },
}); });
+7 -13
View File
@@ -4,13 +4,13 @@
* quasar.config file > pwa > workboxMode is set to "InjectManifest" * quasar.config file > pwa > workboxMode is set to "InjectManifest"
*/ */
import { clientsClaim } from "workbox-core"; import { clientsClaim } from 'workbox-core';
import { NavigationRoute, registerRoute } from "workbox-routing"; import { NavigationRoute, registerRoute } from 'workbox-routing';
import { import {
cleanupOutdatedCaches, cleanupOutdatedCaches,
createHandlerBoundToURL, createHandlerBoundToURL,
precacheAndRoute precacheAndRoute,
} from "workbox-precaching"; } from 'workbox-precaching';
declare const self: ServiceWorkerGlobalScope & typeof globalThis; declare const self: ServiceWorkerGlobalScope & typeof globalThis;
@@ -26,14 +26,8 @@ if (import.meta.env.QUASAR_PROD) {
// Non-SSR/SSG fallbacks to index.html // Non-SSR/SSG fallbacks to index.html
// Production SSR/SSG fallbacks to offline.html (except for dev) // Production SSR/SSG fallbacks to offline.html (except for dev)
registerRoute( registerRoute(
new NavigationRoute( new NavigationRoute(createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), {
createHandlerBoundToURL(import.meta.env.QUASAR_PWA_FALLBACK_HTML), denylist: [new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/],
{ }),
denylist: [
new RegExp(import.meta.env.QUASAR_PWA_SERVICE_WORKER_REGEX),
/workbox-(.)*\.js$/
]
}
)
); );
} }
+1 -5
View File
@@ -205,11 +205,7 @@ describe('biometric wallet material', () => {
])('returns no material for an authenticated %s and preserves state', async (_label, payload) => { ])('returns no material for an authenticated %s and preserves state', async (_label, payload) => {
// Given an authenticated non-canonical payload bound to the saved owner // Given an authenticated non-canonical payload bound to the saved owner
await saveWalletMaterial(MATERIAL); await saveWalletMaterial(MATERIAL);
await storePayload( await storePayload(payload, walletMaterialOwnerId(MATERIAL), walletMaterialHash(MATERIAL));
payload,
walletMaterialOwnerId(MATERIAL),
walletMaterialHash(MATERIAL),
);
const before = persistedState(); const before = persistedState();
// When biometric unlock decrypts the payload // When biometric unlock decrypts the payload
+1 -3
View File
@@ -186,9 +186,7 @@ const authenticateOrThrow = async (reason: string): Promise<void> => {
} }
}; };
export const enableBiometricUnlock = async ( export const enableBiometricUnlock = async (material: WalletMaterialV2): Promise<void> => {
material: WalletMaterialV2,
): Promise<void> => {
if (!isNative()) throw new Error('Biometric unlock is only available in the native app.'); if (!isNative()) throw new Error('Biometric unlock is only available in the native app.');
const serialized = serializeWalletMaterial(material); const serialized = serializeWalletMaterial(material);
const canonicalMaterial = parseWalletMaterial(serialized); const canonicalMaterial = parseWalletMaterial(serialized);
+4 -9
View File
@@ -19,19 +19,14 @@ onMounted(async () => {
return; return;
} }
if (!videoEl.value) return; if (!videoEl.value) return;
scanner = new QrScanner( scanner = new QrScanner(videoEl.value, (result) => emit('decode', result.data), {
videoEl.value, highlightScanRegion: true,
(result) => emit('decode', result.data), });
{ highlightScanRegion: true },
);
await scanner.start(); await scanner.start();
starting.value = false; starting.value = false;
} catch (err) { } catch (err) {
starting.value = false; starting.value = false;
emit( emit('error', err instanceof Error ? err.message : 'Camera access failed.');
'error',
err instanceof Error ? err.message : 'Camera access failed.',
);
} }
}); });
+4 -4
View File
@@ -50,9 +50,7 @@
</div> </div>
<div class="col q-ml-md"> <div class="col q-ml-md">
<div class="text-body1 text-weight-medium">Bearer note</div> <div class="text-body1 text-weight-medium">Bearer note</div>
<div class="text-caption text-grey-5"> <div class="text-caption text-grey-5">Paste or scan a note someone sent you</div>
Paste or scan a note someone sent you
</div>
</div> </div>
<q-icon name="chevron_right" color="grey-5" size="24px" /> <q-icon name="chevron_right" color="grey-5" size="24px" />
</div> </div>
@@ -108,7 +106,9 @@ const onReceived = () => {
border-radius: 12px; border-radius: 12px;
padding: 14px 16px; padding: 14px 16px;
cursor: pointer; 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, .action-row:hover,
+6 -1
View File
@@ -34,7 +34,12 @@ const onSent = () => emit('sent');
</script> </script>
<template> <template>
<q-dialog v-model="show" position="bottom" transition-show="slide-up" transition-hide="slide-down"> <q-dialog
v-model="show"
position="bottom"
transition-show="slide-up"
transition-hide="slide-down"
>
<q-card class="sattle-card drawer-card full-width"> <q-card class="sattle-card drawer-card full-width">
<q-card-section class="row items-center q-pb-sm"> <q-card-section class="row items-center q-pb-sm">
<q-btn v-close-popup flat round dense icon="close" color="primary" aria-label="Close" /> <q-btn v-close-popup flat round dense icon="close" color="primary" aria-label="Close" />
+1 -5
View File
@@ -207,11 +207,7 @@ export const useMoveFundsPage = () => {
toast('positive', `Moved ${sats.toLocaleString()} sats.`); toast('positive', `Moved ${sats.toLocaleString()} sats.`);
} else if (transfer.outcome === 'failed-funds-returned') { } else if (transfer.outcome === 'failed-funds-returned') {
if (transfer.rotatedNote) { if (transfer.rotatedNote) {
await wallet.restoreStagedMintSource( await wallet.restoreStagedMintSource(stagedOutput.id, transfer.rotatedNote, ownerFence);
stagedOutput.id,
transfer.rotatedNote,
ownerFence,
);
} }
await activity.log( await activity.log(
'transfer', 'transfer',
+1 -2
View File
@@ -166,8 +166,7 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
}; };
const allocateOutputSecrets = (server: string, count: number) => const allocateOutputSecrets = (server: string, count: number) =>
wallet.allocateOutputSecrets(server, count, fence); wallet.allocateOutputSecrets(server, count, fence);
const mintSignatureKeys = (server: string) => const mintSignatureKeys = (server: string) => getTrustedMintVerificationKeys(server, ownerId);
getTrustedMintVerificationKeys(server, ownerId);
// the pay-return recovery output: staged durably (allocated secret, // the pay-return recovery output: staged durably (allocated secret,
// zero value, pendingMint) and linked to the carved source BEFORE the // zero value, pendingMint) and linked to the carved source BEFORE the
// classification rotate can burn it // classification rotate can burn it
+1 -3
View File
@@ -165,9 +165,7 @@ describe('commitCarve', () => {
expect(wallet.bearers).toHaveLength(3); expect(wallet.bearers).toHaveLength(3);
expect(committed.verified).toBe(true); expect(committed.verified).toBe(true);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true); expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
expect( expect(wallet.bearers.filter((bearer) => bearer.url.includes('bb'.repeat(32)))).toHaveLength(1);
wallet.bearers.filter((bearer) => bearer.url.includes('bb'.repeat(32))),
).toHaveLength(1);
const key = await deriveBearerAesKey(wallet.requireLinkingKey()); const key = await deriveBearerAesKey(wallet.requireLinkingKey());
expect(await loadBearers(key)).toHaveLength(3); expect(await loadBearers(key)).toHaveLength(3);
}); });
+26 -26
View File
@@ -23,29 +23,29 @@ import {
mintAddressUrl, mintAddressUrl,
resolveMintInput, resolveMintInput,
serverOf, serverOf,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {LnurlcashOptions, MintFee} from 'lnurlcash-kit' import type { LnurlcashOptions, MintFee } from 'lnurlcash-kit';
import {ceilMsatToSat, floorMsatToSat} from './units' import { ceilMsatToSat, floorMsatToSat } from './units';
export const netAfterMintFee = (grossMsat: number, fee: MintFee): number => export const netAfterMintFee = (grossMsat: number, fee: MintFee): number =>
applyMintFee(grossMsat, fee) applyMintFee(grossMsat, fee);
export const grossForMintFee = (netMsat: number, fee: MintFee): number => export const grossForMintFee = (netMsat: number, fee: MintFee): number =>
ceilMsatToSat(grossUpForMintFee(netMsat, fee)) ceilMsatToSat(grossUpForMintFee(netMsat, fee));
export const maxNetForBalance = (balanceMsat: number, fee: MintFee | null): number => export const maxNetForBalance = (balanceMsat: number, fee: MintFee | null): number =>
fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat fee ? floorMsatToSat(applyMintFee(balanceMsat, fee)) : balanceMsat;
// Fee quotes are per-mint and change rarely; cache briefly by server. // Fee quotes are per-mint and change rarely; cache briefly by server.
// nulls are cached too - an unreachable mint shouldn't be retried on // nulls are cached too - an unreachable mint shouldn't be retried on
// every render either. // every render either.
const QUOTE_TTL_MS = 60_000 const QUOTE_TTL_MS = 60_000;
const QUOTE_TIMEOUT_MS = 5_000 const QUOTE_TIMEOUT_MS = 5_000;
const quoteCache = new Map<string, {at: number; fee: MintFee | null}>() const quoteCache = new Map<string, { at: number; fee: MintFee | null }>();
// tests can reset the cache between cases // tests can reset the cache between cases
export const clearMintFeeQuoteCache = (): void => quoteCache.clear() export const clearMintFeeQuoteCache = (): void => quoteCache.clear();
// A pre-flight read of a mint's advertised receive fee WITHOUT requesting // A pre-flight read of a mint's advertised receive fee WITHOUT requesting
// an invoice - the same resolution chain as prepareMint (mint-address // an invoice - the same resolution chain as prepareMint (mint-address
@@ -57,29 +57,29 @@ export const quoteMintFee = async (
mintInput: string, mintInput: string,
options: LnurlcashOptions = {}, options: LnurlcashOptions = {},
): Promise<MintFee | null> => { ): Promise<MintFee | null> => {
const url = resolveMintInput(mintInput) const url = resolveMintInput(mintInput);
if (!url) return null if (!url) return null;
const server = serverOf(url) const server = serverOf(url);
const cached = quoteCache.get(server) const cached = quoteCache.get(server);
if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee if (cached && Date.now() - cached.at < QUOTE_TTL_MS) return cached.fee;
const opts: LnurlcashOptions = {timeoutMs: QUOTE_TIMEOUT_MS, ...options} const opts: LnurlcashOptions = { timeoutMs: QUOTE_TIMEOUT_MS, ...options };
let payUrl = url let payUrl = url;
const addressUrl = mintAddressUrl(url) const addressUrl = mintAddressUrl(url);
if (addressUrl) { if (addressUrl) {
try { try {
payUrl = (await fetchMintAddress(addressUrl, opts)).payLink payUrl = (await fetchMintAddress(addressUrl, opts)).payLink;
} catch (error) { } catch (error) {
// no mint-address support - the plain payRequest guess still works // no mint-address support - the plain payRequest guess still works
if (!(error instanceof Error)) throw error if (!(error instanceof Error)) throw error;
} }
} }
let fee: MintFee | null let fee: MintFee | null;
try { try {
fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null fee = (await fetchPayRequest(payUrl, opts)).mintFee ?? null;
} catch { } catch {
fee = null fee = null;
}
quoteCache.set(server, {at: Date.now(), fee})
return fee
} }
quoteCache.set(server, { at: Date.now(), fee });
return fee;
};
+12 -12
View File
@@ -1,20 +1,20 @@
export const isJsonObject = (value: unknown): value is Record<string, unknown> => export const isJsonObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) typeof value === 'object' && value !== null && !Array.isArray(value);
export const parseJsonObject = (source: string): Record<string, unknown> => { export const parseJsonObject = (source: string): Record<string, unknown> => {
const parsed: unknown = JSON.parse(source) const parsed: unknown = JSON.parse(source);
if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.') if (!isJsonObject(parsed)) throw new TypeError('Expected a JSON object.');
return parsed return parsed;
} };
export const parseJsonArray = (source: string): unknown[] => { export const parseJsonArray = (source: string): unknown[] => {
const parsed: unknown = JSON.parse(source) const parsed: unknown = JSON.parse(source);
if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.') if (!Array.isArray(parsed)) throw new TypeError('Expected a JSON array.');
return Array.from(parsed, (value: unknown) => value) return Array.from(parsed, (value: unknown) => value);
} };
export const parseJsonObjectArray = (source: string): Array<Record<string, unknown>> => export const parseJsonObjectArray = (source: string): Array<Record<string, unknown>> =>
parseJsonArray(source).map((value) => { parseJsonArray(source).map((value) => {
if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.') if (!isJsonObject(value)) throw new TypeError('Expected a JSON object array.');
return value return value;
}) });
+88 -91
View File
@@ -1,6 +1,6 @@
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import { import {
decryptSavedWalletMaterial, decryptSavedWalletMaterial,
@@ -16,103 +16,102 @@ import {
savedWalletMaterialOwnerId, savedWalletMaterialOwnerId,
walletMaterialLinkingKey, walletMaterialLinkingKey,
WalletMaterialProofMismatchError, WalletMaterialProofMismatchError,
} from './keys' } from './keys';
import {walletMaterialOwnerId} from './storage/storedSecret' import { walletMaterialOwnerId } from './storage/storedSecret';
import {parseJsonObject, stubLocalStorage} from './test-utils' import { parseJsonObject, stubLocalStorage } from './test-utils';
const MNEMONIC = const MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const OTHER_MNEMONIC = const OTHER_MNEMONIC =
'legal winner thank year wave sausage worth useful legal winner thank yellow' 'legal winner thank year wave sausage worth useful legal winner thank yellow';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const PASSWORD = 'correct horse battery staple' const PASSWORD = 'correct horse battery staple';
const materialHash = (serialized: string): string => const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized)));
bytesToHex(sha256(utf8ToBytes(serialized)))
const readRawMaterialRecord = (): Record<string, unknown> => { const readRawMaterialRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY) const raw = localStorage.getItem(MATERIAL_STORAGE_KEY);
if (raw === null) throw new Error('expected a saved wallet-material record') if (raw === null) throw new Error('expected a saved wallet-material record');
return parseJsonObject(raw) return parseJsonObject(raw);
} };
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
describe('baseline: wallet material identity', () => { describe('baseline: wallet material identity', () => {
it('derives identity only from the linking key when the cash root changes', () => { it('derives identity only from the linking key when the cash root changes', () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
const other = deriveWalletMaterial(OTHER_MNEMONIC) const other = deriveWalletMaterial(OTHER_MNEMONIC);
const alteredRoot = {...material, cashRootHex: other.cashRootHex} const alteredRoot = { ...material, cashRootHex: other.cashRootHex };
expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material)) expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material));
expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material)) expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material));
}) });
}) });
describe('saved wallet material commitment', () => { describe('saved wallet material commitment', () => {
it('stores the canonical material commitment on plaintext and encrypted saves', async () => { it('stores the canonical material commitment on plaintext and encrypted saves', async () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
const expectedHash = materialHash(serializeWalletMaterial(material)) const expectedHash = materialHash(serializeWalletMaterial(material));
await saveWalletMaterial(material) await saveWalletMaterial(material);
const plaintext = readRawMaterialRecord() const plaintext = readRawMaterialRecord();
await saveWalletMaterial(material, PASSWORD) await saveWalletMaterial(material, PASSWORD);
const encrypted = readRawMaterialRecord() const encrypted = readRawMaterialRecord();
expect(plaintext.materialHash).toBe(expectedHash) expect(plaintext.materialHash).toBe(expectedHash);
expect(encrypted.materialHash).toBe(expectedHash) expect(encrypted.materialHash).toBe(expectedHash);
}) });
it('rejects authenticated encrypted material when its commitment differs', async () => { it('rejects authenticated encrypted material when its commitment differs', async () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
await saveWalletMaterial(material, PASSWORD) await saveWalletMaterial(material, PASSWORD);
localStorage.setItem( localStorage.setItem(
MATERIAL_STORAGE_KEY, MATERIAL_STORAGE_KEY,
JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) }), JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) }),
) );
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf( await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
WalletMaterialProofMismatchError, WalletMaterialProofMismatchError,
) );
}) });
it('rejects plaintext material when its commitment differs', async () => { it('rejects plaintext material when its commitment differs', async () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
await saveWalletMaterial(material) await saveWalletMaterial(material);
const before = JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)}) const before = JSON.stringify({ ...readRawMaterialRecord(), materialHash: '00'.repeat(32) });
localStorage.setItem(MATERIAL_STORAGE_KEY, before) localStorage.setItem(MATERIAL_STORAGE_KEY, before);
expect(getPlainWalletMaterial()).toBeNull() expect(getPlainWalletMaterial()).toBeNull();
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
}) });
it('refuses to stamp same-owner material with an altered cash root', async () => { it('refuses to stamp same-owner material with an altered cash root', async () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
const other = deriveWalletMaterial(OTHER_MNEMONIC) const other = deriveWalletMaterial(OTHER_MNEMONIC);
await saveWalletMaterial(material, PASSWORD) await saveWalletMaterial(material, PASSWORD);
expect(() => expect(() =>
ensureSavedWalletMaterialOwner({ ...material, cashRootHex: other.cashRootHex }), ensureSavedWalletMaterialOwner({ ...material, cashRootHex: other.cashRootHex }),
).toThrow(WalletMaterialProofMismatchError) ).toThrow(WalletMaterialProofMismatchError);
}) });
it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => { it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material) const serialized = serializeWalletMaterial(material);
const record = { const record = {
enc: false, enc: false,
value: serialized, value: serialized,
materialHash: materialHash(serialized), materialHash: materialHash(serialized),
ownerId: walletMaterialOwnerId(material), ownerId: walletMaterialOwnerId(material),
version: 2, version: 2,
} };
expect(isValidStoredWalletMaterial(record)).toBe(true) expect(isValidStoredWalletMaterial(record)).toBe(true);
expect(isValidStoredWalletMaterial({...record, materialHash: record.materialHash.toUpperCase()})).toBe( expect(
false, isValidStoredWalletMaterial({ ...record, materialHash: record.materialHash.toUpperCase() }),
) ).toBe(false);
expect( expect(
isValidStoredWalletMaterial({ isValidStoredWalletMaterial({
enc: record.enc, enc: record.enc,
@@ -120,13 +119,13 @@ describe('saved wallet material commitment', () => {
ownerId: record.ownerId, ownerId: record.ownerId,
version: record.version, version: record.version,
}), }),
).toBe(false) ).toBe(false);
}) });
it('rejects malformed authenticated material after its correct commitment matches', async () => { it('rejects malformed authenticated material after its correct commitment matches', async () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
const malformed = JSON.stringify({...material, cashRootHex: '00'.repeat(64)}) const malformed = JSON.stringify({ ...material, cashRootHex: '00'.repeat(64) });
const parts = await encryptSecretParts(malformed, PASSWORD) const parts = await encryptSecretParts(malformed, PASSWORD);
localStorage.setItem( localStorage.setItem(
MATERIAL_STORAGE_KEY, MATERIAL_STORAGE_KEY,
JSON.stringify({ JSON.stringify({
@@ -136,16 +135,14 @@ describe('saved wallet material commitment', () => {
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2, version: 2,
}), }),
) );
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow( await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow('valid v2 wallet material');
'valid v2 wallet material', });
)
})
it('preserves a verified restored commitment while discarding its owner claim', () => { it('preserves a verified restored commitment while discarding its owner claim', () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material) const serialized = serializeWalletMaterial(material);
restoreWalletMaterialStored({ restoreWalletMaterialStored({
enc: false, enc: false,
@@ -153,50 +150,50 @@ describe('saved wallet material commitment', () => {
materialHash: materialHash(serialized), materialHash: materialHash(serialized),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2, version: 2,
}) });
expect(readRawMaterialRecord()).toEqual({ expect(readRawMaterialRecord()).toEqual({
enc: false, enc: false,
value: serialized, value: serialized,
materialHash: materialHash(serialized), materialHash: materialHash(serialized),
}) });
expect(getPlainWalletMaterial()).toEqual(material) expect(getPlainWalletMaterial()).toEqual(material);
}) });
it('proves an encrypted restored commitment before owner stamping', async () => { it('proves an encrypted restored commitment before owner stamping', async () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material) const serialized = serializeWalletMaterial(material);
const parts = await encryptSecretParts(serialized, PASSWORD) const parts = await encryptSecretParts(serialized, PASSWORD);
restoreWalletMaterialStored({ restoreWalletMaterialStored({
enc: true, enc: true,
...parts, ...parts,
materialHash: materialHash(serialized), materialHash: materialHash(serialized),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2, version: 2,
}) });
expect(savedWalletMaterialOwnerId()).toBeNull() expect(savedWalletMaterialOwnerId()).toBeNull();
const proven = await decryptSavedWalletMaterial(PASSWORD) const proven = await decryptSavedWalletMaterial(PASSWORD);
ensureSavedWalletMaterialOwner(proven) ensureSavedWalletMaterialOwner(proven);
expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material)) expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material));
}) });
it('rejects an unproven restored hash that contradicts encrypted material', async () => { it('rejects an unproven restored hash that contradicts encrypted material', async () => {
const material = deriveWalletMaterial(MNEMONIC) const material = deriveWalletMaterial(MNEMONIC);
const serialized = serializeWalletMaterial(material) const serialized = serializeWalletMaterial(material);
const parts = await encryptSecretParts(serialized, PASSWORD) const parts = await encryptSecretParts(serialized, PASSWORD);
restoreWalletMaterialStored({ restoreWalletMaterialStored({
enc: true, enc: true,
...parts, ...parts,
materialHash: '00'.repeat(32), materialHash: '00'.repeat(32),
ownerId: walletMaterialOwnerId(material), ownerId: walletMaterialOwnerId(material),
version: 2, version: 2,
}) });
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf( await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
WalletMaterialProofMismatchError, WalletMaterialProofMismatchError,
) );
expect(savedWalletMaterialOwnerId()).toBeNull() expect(savedWalletMaterialOwnerId()).toBeNull();
}) });
}) });
+90 -88
View File
@@ -1,6 +1,6 @@
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import { import {
decryptSavedWalletMaterial, decryptSavedWalletMaterial,
@@ -17,77 +17,79 @@ import {
saveWalletMaterial, saveWalletMaterial,
serializeWalletMaterial, serializeWalletMaterial,
walletMaterialLinkingKey, walletMaterialLinkingKey,
} from './keys' } from './keys';
import {parseJsonObject, stubLocalStorage} from './test-utils' import { parseJsonObject, stubLocalStorage } from './test-utils';
const FIXED_MNEMONIC = const FIXED_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const PASSWORD = 'correct horse battery staple' const PASSWORD = 'correct horse battery staple';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const LEGACY_STORAGE_KEY = 'sattle_linking_key' const LEGACY_STORAGE_KEY = 'sattle_linking_key';
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa' const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa';
const CASH_ROOT_HEX = const CASH_ROOT_HEX =
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60';
const materialHash = (serialized: string): string => const materialHash = (serialized: string): string => bytesToHex(sha256(utf8ToBytes(serialized)));
bytesToHex(sha256(utf8ToBytes(serialized)))
const readRawMaterialRecord = (): Record<string, unknown> => { const readRawMaterialRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY) const raw = localStorage.getItem(MATERIAL_STORAGE_KEY);
if (raw === null) throw new Error('expected a saved wallet-material record') if (raw === null) throw new Error('expected a saved wallet-material record');
return parseJsonObject(raw) return parseJsonObject(raw);
} };
describe('WalletMaterialV2 persistence', () => { describe('WalletMaterialV2 persistence', () => {
it('round-trips canonical plaintext material in the new namespace', async () => { it('round-trips canonical plaintext material in the new namespace', async () => {
// Given complete wallet material and an unrelated legacy record // Given complete wallet material and an unrelated legacy record
stubLocalStorage() stubLocalStorage();
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({enc: false, value: '07'.repeat(32)})) localStorage.setItem(
const material = deriveWalletMaterial(FIXED_MNEMONIC) LEGACY_STORAGE_KEY,
JSON.stringify({ enc: false, value: '07'.repeat(32) }),
);
const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When the material is saved without a password // When the material is saved without a password
await saveWalletMaterial(material) await saveWalletMaterial(material);
// Then only the new namespace carries the exact complete material // Then only the new namespace carries the exact complete material
expect(savedWalletMaterialExists()).toBe(true) expect(savedWalletMaterialExists()).toBe(true);
expect(savedWalletMaterialIsEncrypted()).toBe(false) expect(savedWalletMaterialIsEncrypted()).toBe(false);
expect(getPlainWalletMaterial()).toEqual(material) expect(getPlainWalletMaterial()).toEqual(material);
expect(readRawMaterialRecord()).toEqual({ expect(readRawMaterialRecord()).toEqual({
enc: false, enc: false,
value: serializeWalletMaterial(material), value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)), materialHash: materialHash(serializeWalletMaterial(material)),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2, version: 2,
}) });
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull() expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull();
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC) expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC);
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"') expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"');
}) });
it('round-trips canonical password-encrypted material without plaintext fields', async () => { it('round-trips canonical password-encrypted material without plaintext fields', async () => {
// Given complete wallet material // Given complete wallet material
stubLocalStorage() stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC) const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When the material is password protected // When the material is password protected
await saveWalletMaterial(material, PASSWORD) await saveWalletMaterial(material, PASSWORD);
// Then only authenticated decryption returns both parts // Then only authenticated decryption returns both parts
expect(savedWalletMaterialIsEncrypted()).toBe(true) expect(savedWalletMaterialIsEncrypted()).toBe(true);
expect(getPlainWalletMaterial()).toBeNull() expect(getPlainWalletMaterial()).toBeNull();
expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material) expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material);
await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow() await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow();
const raw = JSON.stringify(readRawMaterialRecord()) const raw = JSON.stringify(readRawMaterialRecord());
expect(raw).not.toContain(LINKING_KEY_HEX) expect(raw).not.toContain(LINKING_KEY_HEX);
expect(raw).not.toContain(CASH_ROOT_HEX) expect(raw).not.toContain(CASH_ROOT_HEX);
expect(raw).not.toContain(FIXED_MNEMONIC) expect(raw).not.toContain(FIXED_MNEMONIC);
}) });
it('strips restored owner claims until the material proves its real owner', () => { it('strips restored owner claims until the material proves its real owner', () => {
// Given plaintext material carrying a foreign owner claim // Given plaintext material carrying a foreign owner claim
stubLocalStorage() stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC) const material = deriveWalletMaterial(FIXED_MNEMONIC);
const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9)) const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9));
// When the record is restored and subsequently proven by plaintext unlock // When the record is restored and subsequently proven by plaintext unlock
restoreWalletMaterialStored({ restoreWalletMaterialStored({
@@ -96,25 +98,25 @@ describe('WalletMaterialV2 persistence', () => {
materialHash: materialHash(serializeWalletMaterial(material)), materialHash: materialHash(serializeWalletMaterial(material)),
ownerId: foreignOwner, ownerId: foreignOwner,
version: 2, version: 2,
}) });
const beforeProof = readRawMaterialRecord() const beforeProof = readRawMaterialRecord();
const unlocked = getPlainWalletMaterial() const unlocked = getPlainWalletMaterial();
if (unlocked === null) throw new Error('expected restored plaintext material') if (unlocked === null) throw new Error('expected restored plaintext material');
ensureSavedWalletMaterialOwner(unlocked) ensureSavedWalletMaterialOwner(unlocked);
// Then the claim was absent before proof and only the derived owner was stamped // Then the claim was absent before proof and only the derived owner was stamped
expect(beforeProof).toEqual({ expect(beforeProof).toEqual({
enc: false, enc: false,
value: serializeWalletMaterial(material), value: serializeWalletMaterial(material),
materialHash: materialHash(serializeWalletMaterial(material)), materialHash: materialHash(serializeWalletMaterial(material)),
}) });
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material))) expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material)));
}) });
it('rejects a plaintext owner mismatch without returning or stamping material', () => { it('rejects a plaintext owner mismatch without returning or stamping material', () => {
// Given canonical material stamped with a foreign current owner // Given canonical material stamped with a foreign current owner
stubLocalStorage() stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC) const material = deriveWalletMaterial(FIXED_MNEMONIC);
localStorage.setItem( localStorage.setItem(
MATERIAL_STORAGE_KEY, MATERIAL_STORAGE_KEY,
JSON.stringify({ JSON.stringify({
@@ -124,38 +126,38 @@ describe('WalletMaterialV2 persistence', () => {
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
version: 2, version: 2,
}), }),
) );
const before = localStorage.getItem(MATERIAL_STORAGE_KEY) const before = localStorage.getItem(MATERIAL_STORAGE_KEY);
// When it is read or offered for proof // When it is read or offered for proof
const unlocked = getPlainWalletMaterial() const unlocked = getPlainWalletMaterial();
const stamp = () => ensureSavedWalletMaterialOwner(material) const stamp = () => ensureSavedWalletMaterialOwner(material);
// Then neither operation trusts or rewrites the foreign record // Then neither operation trusts or rewrites the foreign record
expect(unlocked).toBeNull() expect(unlocked).toBeNull();
expect(stamp).toThrow('different wallet') expect(stamp).toThrow('different wallet');
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
}) });
it('rejects an encrypted owner mismatch after authentication', async () => { it('rejects an encrypted owner mismatch after authentication', async () => {
// Given valid encrypted material whose owner marker was replaced // Given valid encrypted material whose owner marker was replaced
stubLocalStorage() stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC) const material = deriveWalletMaterial(FIXED_MNEMONIC);
await saveWalletMaterial(material, PASSWORD) await saveWalletMaterial(material, PASSWORD);
const foreign = { const foreign = {
...readRawMaterialRecord(), ...readRawMaterialRecord(),
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)), ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
} };
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign)) localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign));
const before = localStorage.getItem(MATERIAL_STORAGE_KEY) const before = localStorage.getItem(MATERIAL_STORAGE_KEY);
// When the authenticated material contradicts the current owner marker // When the authenticated material contradicts the current owner marker
const unlock = decryptSavedWalletMaterial(PASSWORD) const unlock = decryptSavedWalletMaterial(PASSWORD);
// Then no material is returned or marker changed // Then no material is returned or marker changed
await expect(unlock).rejects.toThrow('different wallet') await expect(unlock).rejects.toThrow('different wallet');
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before) expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before);
}) });
it.each([ it.each([
['legacy key-only plaintext', LINKING_KEY_HEX, 2], ['legacy key-only plaintext', LINKING_KEY_HEX, 2],
@@ -167,29 +169,29 @@ describe('WalletMaterialV2 persistence', () => {
], ],
])('rejects %s records at the storage boundary', (_name, value, version) => { ])('rejects %s records at the storage boundary', (_name, value, version) => {
// Given an invalid record under the v2 material namespace // Given an invalid record under the v2 material namespace
stubLocalStorage() stubLocalStorage();
const material = deriveWalletMaterial(FIXED_MNEMONIC) const material = deriveWalletMaterial(FIXED_MNEMONIC);
const record = { const record = {
enc: false, enc: false,
value, value,
materialHash: materialHash(value), materialHash: materialHash(value),
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version, version,
} };
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record)) localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record));
// When storage helpers parse it // When storage helpers parse it
// Then the record is unusable as wallet material // Then the record is unusable as wallet material
expect(isValidStoredWalletMaterial(record)).toBe(false) expect(isValidStoredWalletMaterial(record)).toBe(false);
expect(savedWalletMaterialExists()).toBe(false) expect(savedWalletMaterialExists()).toBe(false);
expect(getPlainWalletMaterial()).toBeNull() expect(getPlainWalletMaterial()).toBeNull();
}) });
it('rejects authenticated ciphertext containing malformed material', async () => { it('rejects authenticated ciphertext containing malformed material', async () => {
// Given valid AES-GCM parts whose plaintext is not a v2 material envelope // Given valid AES-GCM parts whose plaintext is not a v2 material envelope
stubLocalStorage() stubLocalStorage();
const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD) const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD);
const material = deriveWalletMaterial(FIXED_MNEMONIC) const material = deriveWalletMaterial(FIXED_MNEMONIC);
localStorage.setItem( localStorage.setItem(
MATERIAL_STORAGE_KEY, MATERIAL_STORAGE_KEY,
JSON.stringify({ JSON.stringify({
@@ -199,12 +201,12 @@ describe('WalletMaterialV2 persistence', () => {
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)), ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
version: 2, version: 2,
}), }),
) );
// When the password successfully authenticates the malformed plaintext // When the password successfully authenticates the malformed plaintext
const unlock = decryptSavedWalletMaterial(PASSWORD) const unlock = decryptSavedWalletMaterial(PASSWORD);
// Then it still fails closed as invalid material // Then it still fails closed as invalid material
await expect(unlock).rejects.toThrow('valid v2 wallet material') await expect(unlock).rejects.toThrow('valid v2 wallet material');
}) });
}) });
+36 -39
View File
@@ -1,7 +1,7 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import {getPublicKey} from 'nostr-tools/pure' import { getPublicKey } from 'nostr-tools/pure';
import {cashNodeToHex} from 'lnurlcash-kit' import { cashNodeToHex } from 'lnurlcash-kit';
import { import {
deriveBearerAesKey, deriveBearerAesKey,
@@ -12,22 +12,22 @@ import {
serializeWalletMaterial, serializeWalletMaterial,
walletMaterialCashRoot, walletMaterialCashRoot,
walletMaterialLinkingKey, walletMaterialLinkingKey,
} from './keys' } from './keys';
import {backupPubkey, deriveBackupKey} from './nostrBackup' import { backupPubkey, deriveBackupKey } from './nostrBackup';
import {deriveNwcWalletKey, nwcWalletPubkey} from './nwc' import { deriveNwcWalletKey, nwcWalletPubkey } from './nwc';
const FIXED_MNEMONIC = const FIXED_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const CLIENT_PUBKEY = getPublicKey(new Uint8Array(32).fill(17)) const CLIENT_PUBKEY = getPublicKey(new Uint8Array(32).fill(17));
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa' const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa';
const CASH_ROOT_HEX = const CASH_ROOT_HEX =
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60' 'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60';
describe('fixed mnemonic identity characterization', () => { describe('fixed mnemonic identity characterization', () => {
it('pins every public linking-key-derived identity and bearer encryption behavior', async () => { it('pins every public linking-key-derived identity and bearer encryption behavior', async () => {
// Given a mnemonic accepted by the existing wallet derivation // Given a mnemonic accepted by the existing wallet derivation
const linkingKey = deriveWalletLinkingKey(FIXED_MNEMONIC) const linkingKey = deriveWalletLinkingKey(FIXED_MNEMONIC);
const bearerKey = await deriveBearerAesKey(linkingKey) const bearerKey = await deriveBearerAesKey(linkingKey);
// When its owner, bearer, Nostr, and NWC observables are derived // When its owner, bearer, Nostr, and NWC observables are derived
const bearerCiphertext = new Uint8Array( const bearerCiphertext = new Uint8Array(
@@ -36,54 +36,54 @@ describe('fixed mnemonic identity characterization', () => {
bearerKey, bearerKey,
new TextEncoder().encode('sattle-vector-v1'), new TextEncoder().encode('sattle-vector-v1'),
), ),
) );
// Then they match the pre-v2 wallet's independently captured vectors // Then they match the pre-v2 wallet's independently captured vectors
expect(linkingPubKeyHex(linkingKey)).toBe( expect(linkingPubKeyHex(linkingKey)).toBe(
'0249f93654b6db2c0c4194c2fbc5eb54ff36f055e82bb867321a4c7ec7f7c31481', '0249f93654b6db2c0c4194c2fbc5eb54ff36f055e82bb867321a4c7ec7f7c31481',
) );
expect(bytesToHex(bearerCiphertext)).toBe( expect(bytesToHex(bearerCiphertext)).toBe(
'4caf30a7c3fdb53ad3edefb34f202a67a2e969f267db3a5842ee6cfd23032e78', '4caf30a7c3fdb53ad3edefb34f202a67a2e969f267db3a5842ee6cfd23032e78',
) );
expect(backupPubkey(deriveBackupKey(linkingKey))).toBe( expect(backupPubkey(deriveBackupKey(linkingKey))).toBe(
'6b6dc0633162e1985316d06edb85a99cc31f89b9c42d350ffdcf57853b875577', '6b6dc0633162e1985316d06edb85a99cc31f89b9c42d350ffdcf57853b875577',
) );
expect(nwcWalletPubkey(deriveNwcWalletKey(linkingKey, CLIENT_PUBKEY))).toBe( expect(nwcWalletPubkey(deriveNwcWalletKey(linkingKey, CLIENT_PUBKEY))).toBe(
'3aa81f13d0dbccba0df5772abccda43c9a51c1dc99ea9a76090cada33b9fc587', '3aa81f13d0dbccba0df5772abccda43c9a51c1dc99ea9a76090cada33b9fc587',
) );
}) });
}) });
describe('WalletMaterialV2 derivation and serialization', () => { describe('WalletMaterialV2 derivation and serialization', () => {
it('derives the unchanged linking key and kit cash root from one mnemonic', () => { it('derives the unchanged linking key and kit cash root from one mnemonic', () => {
// Given the fixed recovery mnemonic // Given the fixed recovery mnemonic
// When its complete wallet material is derived // When its complete wallet material is derived
const material = deriveWalletMaterial(FIXED_MNEMONIC) const material = deriveWalletMaterial(FIXED_MNEMONIC);
// Then both independently captured vectors and the exact schema are stable // Then both independently captured vectors and the exact schema are stable
expect(material).toEqual({ expect(material).toEqual({
version: 2, version: 2,
linkingKeyHex: LINKING_KEY_HEX, linkingKeyHex: LINKING_KEY_HEX,
cashRootHex: CASH_ROOT_HEX, cashRootHex: CASH_ROOT_HEX,
}) });
expect(Object.keys(material)).toEqual(['version', 'linkingKeyHex', 'cashRootHex']) expect(Object.keys(material)).toEqual(['version', 'linkingKeyHex', 'cashRootHex']);
expect(walletMaterialLinkingKey(material)).toEqual(deriveWalletLinkingKey(FIXED_MNEMONIC)) expect(walletMaterialLinkingKey(material)).toEqual(deriveWalletLinkingKey(FIXED_MNEMONIC));
expect(cashNodeToHex(walletMaterialCashRoot(material))).toBe(CASH_ROOT_HEX) expect(cashNodeToHex(walletMaterialCashRoot(material))).toBe(CASH_ROOT_HEX);
}) });
it('round-trips only the canonical exact serialization', () => { it('round-trips only the canonical exact serialization', () => {
// Given valid v2 material // Given valid v2 material
const material = deriveWalletMaterial(FIXED_MNEMONIC) const material = deriveWalletMaterial(FIXED_MNEMONIC);
// When it crosses the serialization boundary // When it crosses the serialization boundary
const serialized = serializeWalletMaterial(material) const serialized = serializeWalletMaterial(material);
// Then its bytes and parsed value are canonical // Then its bytes and parsed value are canonical
expect(serialized).toBe( expect(serialized).toBe(
`{"version":2,"linkingKeyHex":"${LINKING_KEY_HEX}","cashRootHex":"${CASH_ROOT_HEX}"}`, `{"version":2,"linkingKeyHex":"${LINKING_KEY_HEX}","cashRootHex":"${CASH_ROOT_HEX}"}`,
) );
expect(parseWalletMaterial(serialized)).toEqual(material) expect(parseWalletMaterial(serialized)).toEqual(material);
}) });
it.each([ it.each([
['legacy key only', LINKING_KEY_HEX], ['legacy key only', LINKING_KEY_HEX],
@@ -91,10 +91,7 @@ describe('WalletMaterialV2 derivation and serialization', () => {
'wrong version', 'wrong version',
JSON.stringify({ version: 1, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: CASH_ROOT_HEX }), JSON.stringify({ version: 1, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: CASH_ROOT_HEX }),
], ],
[ ['missing field', JSON.stringify({ version: 2, linkingKeyHex: LINKING_KEY_HEX })],
'missing field',
JSON.stringify({version: 2, linkingKeyHex: LINKING_KEY_HEX}),
],
[ [
'extra field', 'extra field',
JSON.stringify({ JSON.stringify({
@@ -151,9 +148,9 @@ describe('WalletMaterialV2 derivation and serialization', () => {
])('rejects %s', (_name, serialized) => { ])('rejects %s', (_name, serialized) => {
// Given malformed or non-canonical serialized material // Given malformed or non-canonical serialized material
// When it crosses the parser // When it crosses the parser
const parsed = parseWalletMaterial(serialized) const parsed = parseWalletMaterial(serialized);
// Then no trusted material is returned // Then no trusted material is returned
expect(parsed).toBeNull() expect(parsed).toBeNull();
}) });
}) });
+163 -163
View File
@@ -5,8 +5,8 @@
// field that binds the saved key to its one proven wallet identity. // field that binds the saved key to its one proven wallet identity.
// Node env: in-memory localStorage stub, native WebCrypto. // Node env: in-memory localStorage stub, native WebCrypto.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import { import {
decryptSavedLinkingKey, decryptSavedLinkingKey,
@@ -20,153 +20,153 @@ import {
savedKeyOwnerMatches, savedKeyOwnerMatches,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
} from './keys' } from './keys';
import {isWalletOwnerId} from './storage/walletOwner' import { isWalletOwnerId } from './storage/walletOwner';
import {parseJsonObject, stubLocalStorage} from './test-utils' import { parseJsonObject, stubLocalStorage } from './test-utils';
import './keys.version.cases' import './keys.version.cases';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const PASSWORD = 'hunter2' const PASSWORD = 'hunter2';
const STORAGE_KEY = 'sattle_linking_key' const STORAGE_KEY = 'sattle_linking_key';
const readRawRecord = (): Record<string, unknown> => { const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record') if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw) return parseJsonObject(raw);
} };
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
// hand-written records in the pre-owner-marker shape - what every wallet // hand-written records in the pre-owner-marker shape - what every wallet
// created before this change has on disk // created before this change has on disk
const saveLegacyPlaintext = (key: Uint8Array): void => { const saveLegacyPlaintext = (key: Uint8Array): void => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({enc: false, value: bytesToHex(key)})) localStorage.setItem(STORAGE_KEY, JSON.stringify({ enc: false, value: bytesToHex(key) }));
} };
const saveLegacyEncrypted = async ( const saveLegacyEncrypted = async (
key: Uint8Array, key: Uint8Array,
password: string, password: string,
): Promise<Record<string, unknown>> => { ): Promise<Record<string, unknown>> => {
const parts = await encryptSecretParts(bytesToHex(key), password) const parts = await encryptSecretParts(bytesToHex(key), password);
const record: Record<string, unknown> = {enc: true, ...parts} const record: Record<string, unknown> = { enc: true, ...parts };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
return record return record;
} };
describe('baseline: saved-key record behavior', () => { describe('baseline: saved-key record behavior', () => {
it('saves a plaintext key and reads it back', async () => { it('saves a plaintext key and reads it back', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
expect(savedKeyExists()).toBe(true) expect(savedKeyExists()).toBe(true);
expect(savedKeyIsEncrypted()).toBe(false) expect(savedKeyIsEncrypted()).toBe(false);
expect(readRawRecord()).toMatchObject({enc: false, value: bytesToHex(LINKING_KEY)}) expect(readRawRecord()).toMatchObject({ enc: false, value: bytesToHex(LINKING_KEY) });
expect(getPlainLinkingKey()).toEqual(LINKING_KEY) expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
}) });
it('saves a password-encrypted key and decrypts it with the password', async () => { it('saves a password-encrypted key and decrypts it with the password', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD) await saveLinkingKey(LINKING_KEY, PASSWORD);
expect(savedKeyIsEncrypted()).toBe(true) expect(savedKeyIsEncrypted()).toBe(true);
const record = readRawRecord() const record = readRawRecord();
expect(record.enc).toBe(true) expect(record.enc).toBe(true);
expect(record.value).toBeUndefined() expect(record.value).toBeUndefined();
// an encrypted record never reads through the plaintext path // an encrypted record never reads through the plaintext path
expect(getPlainLinkingKey()).toBeNull() expect(getPlainLinkingKey()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
}) });
it('rejects the wrong password via the GCM auth tag', async () => { it('rejects the wrong password via the GCM auth tag', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD) await saveLinkingKey(LINKING_KEY, PASSWORD);
await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow() await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow();
}) });
it('throws when asked to decrypt a plaintext record', async () => { it('throws when asked to decrypt a plaintext record', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow( await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow(
'No encrypted linking key saved.', 'No encrypted linking key saved.',
) );
}) });
it('restores an ownerless record verbatim and reads it back', async () => { it('restores an ownerless record verbatim and reads it back', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD) const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
const record = {enc: true as const, ...parts} const record = { enc: true as const, ...parts };
restoreLinkingKeyStored(record) restoreLinkingKeyStored(record);
expect(readRawRecord()).toEqual(record) expect(readRawRecord()).toEqual(record);
expect(savedKeyIsEncrypted()).toBe(true) expect(savedKeyIsEncrypted()).toBe(true);
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
}) });
it('drops a malformed stored record instead of trusting it', () => { it('drops a malformed stored record instead of trusting it', () => {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ enc: true, salt: 'zz', iv: '00', ciphertext: '' }), JSON.stringify({ enc: true, salt: 'zz', iv: '00', ciphertext: '' }),
) );
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
localStorage.setItem(STORAGE_KEY, 'not json') localStorage.setItem(STORAGE_KEY, 'not json');
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
expect(getPlainLinkingKey()).toBeNull() expect(getPlainLinkingKey()).toBeNull();
}) });
}) });
describe('owner marker on new writes', () => { describe('owner marker on new writes', () => {
it('matches the canonical owner derived from the saved linking key', async () => { it('matches the canonical owner derived from the saved linking key', async () => {
// Given a newly saved owner-bearing key // Given a newly saved owner-bearing key
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
// When its freshly derived linking key is compared // When its freshly derived linking key is compared
const matches = savedKeyOwnerMatches(LINKING_KEY) const matches = savedKeyOwnerMatches(LINKING_KEY);
// Then the saved owner matches // Then the saved owner matches
expect(matches).toBe(true) expect(matches).toBe(true);
}) });
it('does not match a different linking key', async () => { it('does not match a different linking key', async () => {
// Given a key owned by this wallet // Given a key owned by this wallet
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
// When a foreign freshly derived linking key is compared // When a foreign freshly derived linking key is compared
const matches = savedKeyOwnerMatches(OTHER_KEY) const matches = savedKeyOwnerMatches(OTHER_KEY);
// Then the foreign key is rejected // Then the foreign key is rejected
expect(matches).toBe(false) expect(matches).toBe(false);
}) });
it('stamps the same canonical owner on plaintext and encrypted saves', async () => { it('stamps the same canonical owner on plaintext and encrypted saves', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
const plainOwner = savedKeyOwnerId() const plainOwner = savedKeyOwnerId();
expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY)) expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY));
await saveLinkingKey(LINKING_KEY, PASSWORD) await saveLinkingKey(LINKING_KEY, PASSWORD);
expect(savedKeyOwnerId()).toBe(plainOwner) expect(savedKeyOwnerId()).toBe(plainOwner);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
}) });
it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => { it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/) expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/);
}) });
}) });
describe('owner marker on legacy records', () => { describe('owner marker on legacy records', () => {
it('reads a legacy record without ownerId as ownerless', async () => { it('reads a legacy record without ownerId as ownerless', async () => {
saveLegacyPlaintext(LINKING_KEY) saveLegacyPlaintext(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
// the record itself stays a fully valid saved key // the record itself stays a fully valid saved key
expect(getPlainLinkingKey()).toEqual(LINKING_KEY) expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
await saveLegacyEncrypted(LINKING_KEY, PASSWORD) await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
}) });
it('rejects malformed owner-bearing records instead of treating them as legacy', () => { it('rejects malformed owner-bearing records instead of treating them as legacy', () => {
const real = linkingPubKeyHex(LINKING_KEY) const real = linkingPubKeyHex(LINKING_KEY);
const junk: unknown[] = [ const junk: unknown[] = [
real.slice(1), // wrong length (65) real.slice(1), // wrong length (65)
real + '00', // wrong length (68) real + '00', // wrong length (68)
@@ -178,100 +178,100 @@ describe('owner marker on legacy records', () => {
null, null,
{ pubkey: real }, { pubkey: real },
'', '',
] ];
for (const ownerId of junk) { for (const ownerId of junk) {
saveLegacyPlaintext(LINKING_KEY) saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem(STORAGE_KEY, JSON.stringify({...readRawRecord(), ownerId})) localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...readRawRecord(), ownerId }));
// the junk marker never reads as an owner... // the junk marker never reads as an owner...
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
// ...or downgrades to an adoptable ownerless legacy record // ...or downgrades to an adoptable ownerless legacy record
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
expect(getPlainLinkingKey()).toBeNull() expect(getPlainLinkingKey()).toBeNull();
} }
}) });
it('stamps the owner after a password unlock without touching the ciphertext', async () => { it('stamps the owner after a password unlock without touching the ciphertext', async () => {
// Given a legacy encrypted record with no owner marker // Given a legacy encrypted record with no owner marker
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD) const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
// When ownership is proven by a successful unlock and then stamped // When ownership is proven by a successful unlock and then stamped
const linkingKey = await decryptSavedLinkingKey(PASSWORD) const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey) ensureSavedKeyOwner(linkingKey);
// Then the marker names the proven key and every ciphertext byte is // Then the marker names the proven key and every ciphertext byte is
// preserved // preserved
const after = readRawRecord() const after = readRawRecord();
expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY));
expect(after.ciphertext).toBe(before.ciphertext) expect(after.ciphertext).toBe(before.ciphertext);
expect(after.salt).toBe(before.salt) expect(after.salt).toBe(before.salt);
expect(after.iv).toBe(before.iv) expect(after.iv).toBe(before.iv);
}) });
it('stamps a plaintext legacy record after a plaintext unlock', () => { it('stamps a plaintext legacy record after a plaintext unlock', () => {
saveLegacyPlaintext(LINKING_KEY) saveLegacyPlaintext(LINKING_KEY);
const linkingKey = getPlainLinkingKey() const linkingKey = getPlainLinkingKey();
if (linkingKey === null) throw new Error('expected a plaintext key') if (linkingKey === null) throw new Error('expected a plaintext key');
ensureSavedKeyOwner(linkingKey) ensureSavedKeyOwner(linkingKey);
expect(readRawRecord()).toEqual({ expect(readRawRecord()).toEqual({
enc: false, enc: false,
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
version: 1, version: 1,
ownerId: linkingPubKeyHex(LINKING_KEY), ownerId: linkingPubKeyHex(LINKING_KEY),
}) });
}) });
it('is idempotent - stamping twice leaves storage untouched after the first write', async () => { it('is idempotent - stamping twice leaves storage untouched after the first write', async () => {
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD) const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD);
const linkingKey = await decryptSavedLinkingKey(PASSWORD) const linkingKey = await decryptSavedLinkingKey(PASSWORD);
ensureSavedKeyOwner(linkingKey) ensureSavedKeyOwner(linkingKey);
const afterFirst = localStorage.getItem(STORAGE_KEY) const afterFirst = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(linkingKey) ensureSavedKeyOwner(linkingKey);
expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst) expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst);
expect(readRawRecord().ciphertext).toBe(before.ciphertext) expect(readRawRecord().ciphertext).toBe(before.ciphertext);
}) });
it('writes nothing when a new-format record is already correctly stamped', async () => { it('writes nothing when a new-format record is already correctly stamped', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD) await saveLinkingKey(LINKING_KEY, PASSWORD);
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY);
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(localStorage.getItem(STORAGE_KEY)).toBe(raw) expect(localStorage.getItem(STORAGE_KEY)).toBe(raw);
}) });
it('refuses to restamp a record owned by a different wallet', async () => { it('refuses to restamp a record owned by a different wallet', async () => {
await saveLinkingKey(OTHER_KEY, PASSWORD) await saveLinkingKey(OTHER_KEY, PASSWORD);
const before = localStorage.getItem(STORAGE_KEY) const before = localStorage.getItem(STORAGE_KEY);
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow() expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
// the failed stamp leaves the record - marker included - untouched // the failed stamp leaves the record - marker included - untouched
expect(localStorage.getItem(STORAGE_KEY)).toBe(before) expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY)) expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY));
}) });
it('refuses to stamp a key that contradicts a plaintext record', () => { it('refuses to stamp a key that contradicts a plaintext record', () => {
saveLegacyPlaintext(OTHER_KEY) saveLegacyPlaintext(OTHER_KEY);
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow() expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow();
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
}) });
it('does not adopt a record carrying a junk owner marker', () => { it('does not adopt a record carrying a junk owner marker', () => {
saveLegacyPlaintext(LINKING_KEY) saveLegacyPlaintext(LINKING_KEY);
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ ...readRawRecord(), ownerId: 'obviously junk' }), JSON.stringify({ ...readRawRecord(), ownerId: 'obviously junk' }),
) );
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
}) });
it('is a no-op when no record is saved at all', () => { it('is a no-op when no record is saved at all', () => {
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
}) });
}) });
describe('owner marker on restore', () => { describe('owner marker on restore', () => {
it('strips the unproven ownerId a restored record arrives with', async () => { it('strips the unproven ownerId a restored record arrives with', async () => {
@@ -281,36 +281,36 @@ describe('owner marker on restore', () => {
enc: false, enc: false,
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_KEY), ownerId: linkingPubKeyHex(OTHER_KEY),
}) });
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(getPlainLinkingKey()).toEqual(LINKING_KEY) expect(getPlainLinkingKey()).toEqual(LINKING_KEY);
// the first proven unlock then establishes the true owner // the first proven unlock then establishes the true owner
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
}) });
it('strips a junk ownerId from a restored encrypted record', async () => { it('strips a junk ownerId from a restored encrypted record', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD) const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
restoreLinkingKeyStored({enc: true, ...parts, ownerId: 42}) restoreLinkingKeyStored({ enc: true, ...parts, ownerId: 42 });
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY) expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY);
}) });
}) });
describe('isWalletOwnerId', () => { describe('isWalletOwnerId', () => {
it('accepts exactly what linkingPubKeyHex produces', () => { it('accepts exactly what linkingPubKeyHex produces', () => {
expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true) expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true);
expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true) expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true);
}) });
it('rejects everything else', () => { it('rejects everything else', () => {
const real = linkingPubKeyHex(LINKING_KEY) const real = linkingPubKeyHex(LINKING_KEY);
expect(isWalletOwnerId(real.toUpperCase())).toBe(false) expect(isWalletOwnerId(real.toUpperCase())).toBe(false);
expect(isWalletOwnerId(real.slice(0, 64))).toBe(false) expect(isWalletOwnerId(real.slice(0, 64))).toBe(false);
expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false) expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false);
expect(isWalletOwnerId(66)).toBe(false) expect(isWalletOwnerId(66)).toBe(false);
expect(isWalletOwnerId(undefined)).toBe(false) expect(isWalletOwnerId(undefined)).toBe(false);
expect(isWalletOwnerId(null)).toBe(false) expect(isWalletOwnerId(null)).toBe(false);
}) });
}) });
+99 -95
View File
@@ -1,11 +1,11 @@
import {mnemonicToSeedSync, generateMnemonic, validateMnemonic} from '@scure/bip39' import { mnemonicToSeedSync, generateMnemonic, validateMnemonic } from '@scure/bip39';
import {wordlist} from '@scure/bip39/wordlists/english.js' import { wordlist } from '@scure/bip39/wordlists/english.js';
import {HDKey, HARDENED_OFFSET} from '@scure/bip32' import { HDKey, HARDENED_OFFSET } from '@scure/bip32';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {secp256k1} from '@noble/curves/secp256k1.js' import { secp256k1 } from '@noble/curves/secp256k1.js';
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import {cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode} from 'lnurlcash-kit' import { cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode } from 'lnurlcash-kit';
import { import {
parseStoredSecret, parseStoredSecret,
@@ -16,9 +16,9 @@ import {
STORED_SECRET_VERSION, STORED_SECRET_VERSION,
type StoredSecret, type StoredSecret,
type WalletMaterialV2, type WalletMaterialV2,
} from './storage/storedSecret' } from './storage/storedSecret';
import {decryptSecretParts, encryptSecretParts} from './passwordWrap' import { decryptSecretParts, encryptSecretParts } from './passwordWrap';
import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents' import { LINKING_KEY_STORAGE_KEY } from './storage/walletOwnerEvents';
export { export {
isValidStoredSecret, isValidStoredSecret,
@@ -26,95 +26,95 @@ export {
parseWalletMaterial, parseWalletMaterial,
serializeWalletMaterial, serializeWalletMaterial,
walletMaterialHash, walletMaterialHash,
} from './storage/storedSecret' } from './storage/storedSecret';
export type {StoredSecret, StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret' export type { StoredSecret, StoredWalletMaterial, WalletMaterialV2 } from './storage/storedSecret';
export {decryptSecretParts, encryptSecretParts} from './passwordWrap' export { decryptSecretParts, encryptSecretParts } from './passwordWrap';
export type {EncryptedSecretParts} from './passwordWrap' export type { EncryptedSecretParts } from './passwordWrap';
export * from './walletMaterialStorage' export * from './walletMaterialStorage';
// The wallet's identity is derived against this fixed domain rather than // The wallet's identity is derived against this fixed domain rather than
// window.location.hostname, so the same seed phrase always yields the same // window.location.hostname, so the same seed phrase always yields the same
// linking key (and thus decrypts the same bearer tokens) no matter where // linking key (and thus decrypts the same bearer tokens) no matter where
// this static build happens to be hosted - github.io, a mirror, file://. // this static build happens to be hosted - github.io, a mirror, file://.
export const WALLET_DOMAIN = 'sattle' export const WALLET_DOMAIN = 'sattle';
export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128) export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128);
export const isValidSeedPhrase = (phrase: string): boolean => export const isValidSeedPhrase = (phrase: string): boolean =>
validateMnemonic(phrase.trim().toLowerCase(), wordlist) validateMnemonic(phrase.trim().toLowerCase(), wordlist);
const readUint32BE = (bytes: Uint8Array, offset: number): number => const readUint32BE = (bytes: Uint8Array, offset: number): number =>
((bytes[offset] << 24) | ((bytes[offset] << 24) |
(bytes[offset + 1] << 16) | (bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) | (bytes[offset + 2] << 8) |
bytes[offset + 3]) >>> bytes[offset + 3]) >>>
0 0;
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server - // LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server -
// a seed restored there or here produces the same identity for a given domain // a seed restored there or here produces the same identity for a given domain
const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => { const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => {
const master = HDKey.fromMasterSeed(seed) const master = HDKey.fromMasterSeed(seed);
const hashingKeyNode = master.derive("m/138'/0") const hashingKeyNode = master.derive("m/138'/0");
if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key') if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key');
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain) const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain);
// path suffix longs are raw BIP32 child indices: whether each level ends up // path suffix longs are raw BIP32 child indices: whether each level ends up
// hardened depends solely on its own magnitude (>= 2^31), never forced // hardened depends solely on its own magnitude (>= 2^31), never forced
let node = master.deriveChild(138 + HARDENED_OFFSET) let node = master.deriveChild(138 + HARDENED_OFFSET);
for (const index of suffix) { for (const index of suffix) {
node = node.deriveChild(index) node = node.deriveChild(index);
}
if (!node.privateKey) throw new Error('Could not derive linking key')
return node.privateKey
} }
if (!node.privateKey) throw new Error('Could not derive linking key');
return node.privateKey;
};
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array =>
deriveLud05LinkingKeyFromSeed(mnemonicToSeedSync(seedPhrase.trim().toLowerCase()), domain) deriveLud05LinkingKeyFromSeed(mnemonicToSeedSync(seedPhrase.trim().toLowerCase()), domain);
// the HMAC half of the derivation, split out so the LUD-05 test vector // the HMAC half of the derivation, split out so the LUD-05 test vector
// (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it // (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
// directly - see keys.test.ts // directly - see keys.test.ts
export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => { export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => {
const material = hmac(sha256, hashingKey, utf8ToBytes(domain)) const material = hmac(sha256, hashingKey, utf8ToBytes(domain));
return [0, 4, 8, 12].map((i) => readUint32BE(material, i)) return [0, 4, 8, 12].map((i) => readUint32BE(material, i));
} };
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array => export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN) deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN);
export const deriveWalletMaterial = (seedPhrase: string): WalletMaterialV2 => { export const deriveWalletMaterial = (seedPhrase: string): WalletMaterialV2 => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase());
try { try {
return { return {
version: 2, version: 2,
linkingKeyHex: bytesToHex(deriveLud05LinkingKeyFromSeed(seed, WALLET_DOMAIN)), linkingKeyHex: bytesToHex(deriveLud05LinkingKeyFromSeed(seed, WALLET_DOMAIN)),
cashRootHex: cashNodeToHex(deriveCashRoot(seed)), cashRootHex: cashNodeToHex(deriveCashRoot(seed)),
} };
} finally { } finally {
seed.fill(0) seed.fill(0);
}
} }
};
export const walletMaterialLinkingKey = (material: WalletMaterialV2): Uint8Array => export const walletMaterialLinkingKey = (material: WalletMaterialV2): Uint8Array =>
hexToBytes(material.linkingKeyHex) hexToBytes(material.linkingKeyHex);
export const walletMaterialCashRoot = (material: WalletMaterialV2): CashNode => export const walletMaterialCashRoot = (material: WalletMaterialV2): CashNode =>
cashNodeFromHex(material.cashRootHex) cashNodeFromHex(material.cashRootHex);
export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string => export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true)) bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true));
const readSecret = (storageKey: string): StoredSecret | null => { const readSecret = (storageKey: string): StoredSecret | null => {
const raw = localStorage.getItem(storageKey) const raw = localStorage.getItem(storageKey);
if (!raw) return null if (!raw) return null;
try { try {
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(raw);
return parseStoredSecret(parsed)?.secret ?? null return parseStoredSecret(parsed)?.secret ?? null;
} catch { } catch {
return null return null;
}
} }
};
// The legacy alpha lifecycle below persists only the linking key under its old // The legacy alpha lifecycle below persists only the linking key under its old
// namespace. Task 5 replaces that lifecycle with the v2 material repository; // namespace. Task 5 replaces that lifecycle with the v2 material repository;
@@ -135,24 +135,24 @@ const readSecret = (storageKey: string): StoredSecret | null => {
// preserving ciphertext byte-for-byte; it refuses to restamp a record // preserving ciphertext byte-for-byte; it refuses to restamp a record
// already owned by a different valid owner, and refuses a key that // already owned by a different valid owner, and refuses a key that
// contradicts a plaintext record. // contradicts a plaintext record.
export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null;
export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true;
export const getSavedLinkingKeyStored = (): StoredSecret | null => export const getSavedLinkingKeyStored = (): StoredSecret | null =>
readSecret(LINKING_KEY_STORAGE_KEY) readSecret(LINKING_KEY_STORAGE_KEY);
// the proven owner of the saved key, or null when there is no record or it // the proven owner of the saved key, or null when there is no record or it
// carries no current version-1 marker (ownerless or compatible unversioned) // carries no current version-1 marker (ownerless or compatible unversioned)
export const savedKeyOwnerId = (): string | null => { export const savedKeyOwnerId = (): string | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY);
return stored === null ? null : storedSecretOwnerId(stored) return stored === null ? null : storedSecretOwnerId(stored);
} };
// Compares only against an owner freshly derived from a linking key. An // Compares only against an owner freshly derived from a linking key. An
// ownerless or unversioned saved marker never matches. // ownerless or unversioned saved marker never matches.
export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean => export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
savedKeyOwnerId() === linkingPubKeyHex(linkingKey) savedKeyOwnerId() === linkingPubKeyHex(linkingKey);
// Stamps the owner marker onto the existing record. Call ONLY with the key // Stamps the owner marker onto the existing record. Call ONLY with the key
// just proven against this record (decryptSavedLinkingKey / a plaintext // just proven against this record (decryptSavedLinkingKey / a plaintext
@@ -161,90 +161,94 @@ export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
// already-correct marker: no write. A DIFFERENT valid owner, or a key that // already-correct marker: no write. A DIFFERENT valid owner, or a key that
// contradicts a plaintext record, throws and leaves storage untouched. // contradicts a plaintext record, throws and leaves storage untouched.
export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => { export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (stored === null) return if (stored === null) return;
const ownerId = linkingPubKeyHex(linkingKey) const ownerId = linkingPubKeyHex(linkingKey);
if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(linkingKey)) { if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(linkingKey)) {
throw new Error('Proven key does not match the saved wallet key.') throw new Error('Proven key does not match the saved wallet key.');
} }
const claimedOwnerId = storedSecretClaimedOwnerId(stored) const claimedOwnerId = storedSecretClaimedOwnerId(stored);
if (storedSecretOwnerId(stored) === ownerId) return if (storedSecretOwnerId(stored) === ownerId) return;
if (claimedOwnerId !== null && claimedOwnerId !== ownerId) { if (claimedOwnerId !== null && claimedOwnerId !== ownerId) {
throw new Error('Saved wallet key is owned by a different wallet.') throw new Error('Saved wallet key is owned by a different wallet.');
} }
localStorage.setItem( localStorage.setItem(
LINKING_KEY_STORAGE_KEY, LINKING_KEY_STORAGE_KEY,
JSON.stringify(stampStoredSecretOwner(stored, ownerId)), JSON.stringify(stampStoredSecretOwner(stored, ownerId)),
) );
} };
export const getPlainLinkingKey = (): Uint8Array | null => { export const getPlainLinkingKey = (): Uint8Array | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (stored === null || stored.enc === true) return null if (stored === null || stored.enc === true) return null;
return hexToBytes(stored.value) return hexToBytes(stored.value);
} };
export const saveLinkingKey = async ( export const saveLinkingKey = async (
linkingPrivKey: Uint8Array, linkingPrivKey: Uint8Array,
password?: string, password?: string,
): Promise<void> => { ): Promise<void> => {
const hex = bytesToHex(linkingPrivKey) const hex = bytesToHex(linkingPrivKey);
const ownerId = linkingPubKeyHex(linkingPrivKey) const ownerId = linkingPubKeyHex(linkingPrivKey);
if (!password) { if (!password) {
localStorage.setItem( localStorage.setItem(
LINKING_KEY_STORAGE_KEY, LINKING_KEY_STORAGE_KEY,
JSON.stringify({ enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION }), JSON.stringify({ enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION }),
) );
return return;
} }
const parts = await encryptSecretParts(hex, password) const parts = await encryptSecretParts(hex, password);
localStorage.setItem( localStorage.setItem(
LINKING_KEY_STORAGE_KEY, LINKING_KEY_STORAGE_KEY,
JSON.stringify({ enc: true, ...parts, ownerId, version: STORED_SECRET_VERSION }), JSON.stringify({ enc: true, ...parts, ownerId, version: STORED_SECRET_VERSION }),
) );
} };
// installs a record from a backup/relay. Any ownerId it carries is an // installs a record from a backup/relay. Any ownerId it carries is an
// unproven claim by whoever produced that file, so the marker is stripped // unproven claim by whoever produced that file, so the marker is stripped
// here - the first proven unlock re-establishes it (see the failure-model // here - the first proven unlock re-establishes it (see the failure-model
// comment above) // comment above)
export const restoreLinkingKeyStored = (stored: StoredSecret): void => { export const restoreLinkingKeyStored = (stored: StoredSecret): void => {
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored))) localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored)));
} };
export const decryptSavedLinkingKey = async (password: string): Promise<Uint8Array> => { export const decryptSavedLinkingKey = async (password: string): Promise<Uint8Array> => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY);
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.') if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.');
return hexToBytes(await decryptSecretParts(stored, password)) return hexToBytes(await decryptSecretParts(stored, password));
} };
export const clearSavedLinkingKey = (): void => { export const clearSavedLinkingKey = (): void => {
localStorage.removeItem(LINKING_KEY_STORAGE_KEY) localStorage.removeItem(LINKING_KEY_STORAGE_KEY);
} };
// The bearer-encryption key is derived (not random): sha256 over the linking // The bearer-encryption key is derived (not random): sha256 over the linking
// key plus a fixed context string. Deterministic derivation is what makes // key plus a fixed context string. Deterministic derivation is what makes
// backup/restore work with nothing but the seed phrase - restore the seed on // backup/restore work with nothing but the seed phrase - restore the seed on
// a fresh device and every previously exported ciphertext decrypts again. // a fresh device and every previously exported ciphertext decrypts again.
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1' const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1';
export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise<CryptoKey> => { export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise<CryptoKey> => {
const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)])) const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]));
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt']) return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt']);
} };
export type EncryptedRecordParts = {iv: string; ciphertext: string} export type EncryptedRecordParts = { iv: string; ciphertext: string };
export const encryptRecord = async ( export const encryptRecord = async (
aesKey: CryptoKey, aesKey: CryptoKey,
value: object, value: object,
): Promise<EncryptedRecordParts> => { ): Promise<EncryptedRecordParts> => {
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array( const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(JSON.stringify(value))), await crypto.subtle.encrypt(
) { name: 'AES-GCM', iv },
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)} aesKey,
} utf8ToBytes(JSON.stringify(value)),
),
);
return { iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
export const decryptRecord = async ( export const decryptRecord = async (
aesKey: CryptoKey, aesKey: CryptoKey,
@@ -254,6 +258,6 @@ export const decryptRecord = async (
{ name: 'AES-GCM', iv: hexToBytes(parts.iv) }, { name: 'AES-GCM', iv: hexToBytes(parts.iv) },
aesKey, aesKey,
hexToBytes(parts.ciphertext), hexToBytes(parts.ciphertext),
) );
return JSON.parse(new TextDecoder().decode(plaintext)) return JSON.parse(new TextDecoder().decode(plaintext));
} };
+46 -46
View File
@@ -1,5 +1,5 @@
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import { import {
ensureSavedKeyOwner, ensureSavedKeyOwner,
@@ -9,54 +9,54 @@ import {
savedKeyExists, savedKeyExists,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
} from './keys' } from './keys';
import {parseJsonObject, stubLocalStorage} from './test-utils' import { parseJsonObject, stubLocalStorage } from './test-utils';
import './keys.material.cases' import './keys.material.cases';
import './keys.material-commitment.cases' import './keys.material-commitment.cases';
import './keys.material-storage.cases' import './keys.material-storage.cases';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const STORAGE_KEY = 'sattle_linking_key' const STORAGE_KEY = 'sattle_linking_key';
const readRawRecord = (): Record<string, unknown> => { const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) throw new Error('expected a saved linking-key record') if (raw === null) throw new Error('expected a saved linking-key record');
return parseJsonObject(raw) return parseJsonObject(raw);
} };
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
describe('saved-key schema version', () => { describe('saved-key schema version', () => {
it('writes version 1 on current plaintext and encrypted records', async () => { it('writes version 1 on current plaintext and encrypted records', async () => {
// Given a linking key saved through each current persistence path // Given a linking key saved through each current persistence path
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
const plaintext = readRawRecord() const plaintext = readRawRecord();
await saveLinkingKey(LINKING_KEY, 'correct horse') await saveLinkingKey(LINKING_KEY, 'correct horse');
const encrypted = readRawRecord() const encrypted = readRawRecord();
// When the persisted schema metadata is inspected // When the persisted schema metadata is inspected
// Then both owner-bearing records carry the recognized discriminator // Then both owner-bearing records carry the recognized discriminator
expect(plaintext.version).toBe(1) expect(plaintext.version).toBe(1);
expect(encrypted.version).toBe(1) expect(encrypted.version).toBe(1);
}) });
it('upgrades an unversioned same-owner record only after key proof', () => { it('upgrades an unversioned same-owner record only after key proof', () => {
// Given the valid owner-bearing shape written before schema versioning // Given the valid owner-bearing shape written before schema versioning
const ownerId = linkingPubKeyHex(LINKING_KEY) const ownerId = linkingPubKeyHex(LINKING_KEY);
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId }), JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId }),
) );
// When it is read before and then stamped after the plaintext key proves ownership // When it is read before and then stamped after the plaintext key proves ownership
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
const provenKey = getPlainLinkingKey() const provenKey = getPlainLinkingKey();
if (provenKey === null) throw new Error('expected the compatible plaintext key') if (provenKey === null) throw new Error('expected the compatible plaintext key');
ensureSavedKeyOwner(provenKey) ensureSavedKeyOwner(provenKey);
// Then it becomes an explicitly versioned current record // Then it becomes an explicitly versioned current record
expect(readRawRecord()).toEqual({ expect(readRawRecord()).toEqual({
@@ -64,8 +64,8 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
ownerId, ownerId,
version: 1, version: 1,
}) });
}) });
it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => { it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => {
// Given an otherwise valid owner-bearing record with unrecognized metadata // Given an otherwise valid owner-bearing record with unrecognized metadata
@@ -74,16 +74,16 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(LINKING_KEY), ownerId: linkingPubKeyHex(LINKING_KEY),
version, version,
} };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
// When the saved-key boundary parses it // When the saved-key boundary parses it
// Then the record is neither usable nor eligible for legacy adoption // Then the record is neither usable nor eligible for legacy adoption
expect(isValidStoredSecret(record)).toBe(false) expect(isValidStoredSecret(record)).toBe(false);
expect(savedKeyExists()).toBe(false) expect(savedKeyExists()).toBe(false);
expect(savedKeyOwnerId()).toBeNull() expect(savedKeyOwnerId()).toBeNull();
expect(getPlainLinkingKey()).toBeNull() expect(getPlainLinkingKey()).toBeNull();
}) });
it('rejects a foreign current owner at the proven-key stamping boundary', () => { it('rejects a foreign current owner at the proven-key stamping boundary', () => {
// Given a versioned record whose owner conflicts with its plaintext key // Given a versioned record whose owner conflicts with its plaintext key
@@ -92,15 +92,15 @@ describe('saved-key schema version', () => {
value: bytesToHex(LINKING_KEY), value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
version: 1, version: 1,
} };
localStorage.setItem(STORAGE_KEY, JSON.stringify(record)) localStorage.setItem(STORAGE_KEY, JSON.stringify(record));
const before = localStorage.getItem(STORAGE_KEY) const before = localStorage.getItem(STORAGE_KEY);
// When the actual key is proven // When the actual key is proven
const stamp = () => ensureSavedKeyOwner(LINKING_KEY) const stamp = () => ensureSavedKeyOwner(LINKING_KEY);
// Then the foreign claim fails closed without rewriting storage // Then the foreign claim fails closed without rewriting storage
expect(stamp).toThrow('different wallet') expect(stamp).toThrow('different wallet');
expect(localStorage.getItem(STORAGE_KEY)).toBe(before) expect(localStorage.getItem(STORAGE_KEY)).toBe(before);
}) });
}) });
+135 -125
View File
@@ -13,51 +13,51 @@
// mintToHash alone is only an additive receipt extension, not permission // mintToHash alone is only an additive receipt extension, not permission
// to mint. // to mint.
import {afterEach, describe, expect, it} from 'vitest' import { afterEach, describe, expect, it } from 'vitest';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {buildNoteUrl, fetchMintAddress, hashK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchMintAddress, hashK1 } from 'lnurlcash-kit';
import {claimMintedNote, prepareMint} from './ops' import { claimMintedNote, prepareMint } from './ops';
import {mintAddressCacheInfo} from './trustedMints' import { mintAddressCacheInfo } from './trustedMints';
import './mintContract.v08.cases' import './mintContract.v08.cases';
type Mint = Awaited<ReturnType<typeof createMockMint>> type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [] const mints: Mint[] = [];
const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => { const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options) const m = await createMockMint(options);
mints.push(m) mints.push(m);
return m return m;
} };
afterEach(async () => { afterEach(async () => {
await Promise.all(mints.splice(0).map((m) => m.close())) await Promise.all(mints.splice(0).map((m) => m.close()));
}) });
// paying a mint invoice is what brings its note into existence - the mock // paying a mint invoice is what brings its note into existence - the mock
// exposes that through its test hook (settle + credit in one step) // exposes that through its test hook (settle + credit in one step)
const settleLastInvoice = async (m: Mint): Promise<string> => { const settleLastInvoice = async (m: Mint): Promise<string> => {
const paymentHash = [...m.state.invoices.keys()].at(-1) const paymentHash = [...m.state.invoices.keys()].at(-1);
if (!paymentHash) throw new Error('no invoice requested yet') if (!paymentHash) throw new Error('no invoice requested yet');
const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
if (!res.ok) throw new Error(`settle hook failed: ${res.status}`) if (!res.ok) throw new Error(`settle hook failed: ${res.status}`);
const invoice = m.state.invoices.get(paymentHash) const invoice = m.state.invoices.get(paymentHash);
if (!invoice) throw new Error('settled invoice vanished from the mock') if (!invoice) throw new Error('settled invoice vanished from the mock');
return invoice.preimage return invoice.preimage;
} };
const MINT_PUBKEY = `02${'ab'.repeat(32)}` const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
const persistOutput = async (): Promise<void> => undefined const persistOutput = async (): Promise<void> => undefined;
// deterministic stand-in for the wallet's BIP-32 allocation path // deterministic stand-in for the wallet's BIP-32 allocation path
let allocatedCounter = 0 let allocatedCounter = 0;
const allocateOutputSecrets = (_server: string, count: number): Promise<readonly string[]> => { const allocateOutputSecrets = (_server: string, count: number): Promise<readonly string[]> => {
const secrets = Array.from({ length: count }, () => { const secrets = Array.from({ length: count }, () => {
allocatedCounter += 1 allocatedCounter += 1;
return `03${allocatedCounter.toString(16).padStart(62, '0')}` return `03${allocatedCounter.toString(16).padStart(62, '0')}`;
}) });
return Promise.resolve(secrets) return Promise.resolve(secrets);
} };
// a mint-address (LUD-25) wire response exactly as lnurl-mint serves it: // a mint-address (LUD-25) wire response exactly as lnurl-mint serves it:
// node stats under their WIRE names - `nodeCapacity` is msat like every // node stats under their WIRE names - `nodeCapacity` is msat like every
@@ -74,26 +74,26 @@ const mintAddressFixture = {
nodeCapacity: 500_000_000, nodeCapacity: 500_000_000,
nodeNumChannels: 4, nodeNumChannels: 4,
nodeNumPeers: 6, nodeNumPeers: 6,
} };
const jsonResponse = (body: unknown): Response => const jsonResponse = (body: unknown): Response =>
new Response(JSON.stringify(body), { new Response(JSON.stringify(body), {
status: 200, status: 200,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
}) });
// a fetch that serves fixture bodies by URL prefix and 404s everything else, // a fetch that serves fixture bodies by URL prefix and 404s everything else,
// so a test drives the real kit HTTP boundary without any network // so a test drives the real kit HTTP boundary without any network
const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof fetch => { const fixtureFetch = (routes: ReadonlyArray<readonly [string, unknown]>): typeof fetch => {
const impl: typeof fetch = (input, _init) => { const impl: typeof fetch = (input, _init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
for (const [prefix, body] of routes) { for (const [prefix, body] of routes) {
if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body)) if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body));
}
return Promise.resolve(new Response('not found', {status: 404}))
}
return impl
} }
return Promise.resolve(new Response('not found', { status: 404 }));
};
return impl;
};
// fixtureFetch plus a record of every URL it saw, for canaries about the // fixtureFetch plus a record of every URL it saw, for canaries about the
// exact query the kit puts on the wire // exact query the kit puts on the wire
@@ -102,69 +102,75 @@ const capturingFetch = (
seen: string[], seen: string[],
): typeof fetch => { ): typeof fetch => {
const impl: typeof fetch = (input, init) => { const impl: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
seen.push(url) seen.push(url);
return fixtureFetch(routes)(input, init) return fixtureFetch(routes)(input, init);
} };
return impl return impl;
} };
describe('mint-address wire contract', () => { describe('mint-address wire contract', () => {
it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => { it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]), fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
}) });
// renamed fields have to be mapped, not spread: the spread carries the // renamed fields have to be mapped, not spread: the spread carries the
// wire name through and the typed one reads undefined forever // wire name through and the typed one reads undefined forever
expect(info.nodePubkey).toBe(MINT_PUBKEY) expect(info.nodePubkey).toBe(MINT_PUBKEY);
expect(info.nodeCapacityMsat).toBe(500_000_000) expect(info.nodeCapacityMsat).toBe(500_000_000);
expect(info.nodeNumChannels).toBe(4) expect(info.nodeNumChannels).toBe(4);
expect(info.nodeNumPeers).toBe(6) expect(info.nodeNumPeers).toBe(6);
}) });
it('carries node stats into the cached trusted-mint display metadata', async () => { it('carries node stats into the cached trusted-mint display metadata', async () => {
const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', {
fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]), fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]),
}) });
const cached = mintAddressCacheInfo(info, 'mint') const cached = mintAddressCacheInfo(info, 'mint');
expect(cached?.nodeCapacityMsat).toBe(500_000_000) expect(cached?.nodeCapacityMsat).toBe(500_000_000);
expect(cached?.nodeNumChannels).toBe(4) expect(cached?.nodeNumChannels).toBe(4);
expect(cached?.nodeNumPeers).toBe(6) expect(cached?.nodeNumPeers).toBe(6);
}) });
it("surfaces the mock mint's mint-address node stats through prepareMint", async () => { it("surfaces the mock mint's mint-address node stats through prepareMint", async () => {
const m = await mint() const m = await mint();
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput, allocateOutputSecrets}) const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
persistOutput,
allocateOutputSecrets,
});
// the metadata is advertised at the mint-address endpoint itself - // the metadata is advertised at the mint-address endpoint itself -
// the payRequest never carried it // the payRequest never carried it
expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey);
expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000) expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000);
expect(prepared.nodeInfo?.nodeNumChannels).toBe(4) expect(prepared.nodeInfo?.nodeNumChannels).toBe(4);
expect(prepared.nodeInfo?.nodeNumPeers).toBe(6) expect(prepared.nodeInfo?.nodeNumPeers).toBe(6);
const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username) const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username);
expect(cached?.nodeCapacityMsat).toBe(500_000_000) expect(cached?.nodeCapacityMsat).toBe(500_000_000);
}) });
}) });
describe('withdraw-link forms', () => { describe('withdraw-link forms', () => {
it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => { it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => {
// conformance 0.7.0 defaults to plain https links; lnurlw:// is an // conformance 0.7.0 defaults to plain https links; lnurlw:// is an
// explicit LUD-17 transport form // explicit LUD-17 transport form
const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'}) const m = await mint({ testHooks: true, withdrawLinkForm: 'lnurlw' });
const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {persistOutput, allocateOutputSecrets}) const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000, {
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) persistOutput,
allocateOutputSecrets,
});
expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//);
// and the link is fully usable: settle the invoice, claim the note // and the link is fully usable: settle the invoice, claim the note
const preimage = await settleLastInvoice(m) const preimage = await settleLastInvoice(m);
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) });
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.amount).toBe(21_000);
expect(m.state.noteState(preimage)).toBeNull() expect(m.state.noteState(preimage)).toBeNull();
expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding') expect(m.state.noteState(prepared.noteSecret)).toBe('outstanding');
}) });
it('accepts an HTTPS withdraw link', async () => { it('accepts an HTTPS withdraw link', async () => {
const fetch = fixtureFetch([ const fetch = fixtureFetch([
@@ -183,20 +189,24 @@ describe('withdraw-link forms', () => {
], ],
// amount-less invoice: the kit skips its amount cross-check // amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }], ['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
]) ]);
const prepared = await prepareMint('mint@mint.example', 21_000, {fetch, persistOutput, allocateOutputSecrets}) const prepared = await prepareMint('mint@mint.example', 21_000, {
expect(prepared.withdrawLink).toBe('https://mint.example/note') fetch,
persistOutput,
allocateOutputSecrets,
});
expect(prepared.withdrawLink).toBe('https://mint.example/note');
// the mint-address payLink is authoritative - the payRequest came from it // the mint-address payLink is authoritative - the payRequest came from it
expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint') expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint');
}) });
it('builds the same note URL from both withdraw-link forms', () => { it('builds the same note URL from both withdraw-link forms', () => {
const k1 = 'ab'.repeat(32) const k1 = 'ab'.repeat(32);
expect(buildNoteUrl('lnurlw://mint.example/note', k1, 21_000)).toBe( expect(buildNoteUrl('lnurlw://mint.example/note', k1, 21_000)).toBe(
buildNoteUrl('https://mint.example/note', k1, 21_000), buildNoteUrl('https://mint.example/note', k1, 21_000),
) );
}) });
}) });
describe('quote output-naming wire contract', () => { describe('quote output-naming wire contract', () => {
const payRequestFixture = (extra: Record<string, unknown>) => ({ const payRequestFixture = (extra: Record<string, unknown>) => ({
@@ -207,90 +217,90 @@ describe('quote output-naming wire contract', () => {
withdrawLink: 'https://mint.example/note', withdrawLink: 'https://mint.example/note',
metadata: '[]', metadata: '[]',
...extra, ...extra,
}) });
const routesFor = (payRequest: Record<string, unknown>) => const routesFor = (payRequest: Record<string, unknown>) =>
[ [
['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture], ['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture],
['https://mint.example/.well-known/lnurlp/mint', payRequest], ['https://mint.example/.well-known/lnurlp/mint', payRequest],
// amount-less invoice: the kit skips its amount cross-check // amount-less invoice: the kit skips its amount cross-check
['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }], ['https://mint.example/pay', { pr: 'lnmock1fixture', verify: null }],
] as const ] as const;
const callbackUrl = (seen: string[]): URL => { const callbackUrl = (seen: string[]): URL => {
const hit = seen.find((url) => url.startsWith('https://mint.example/pay?')) const hit = seen.find((url) => url.startsWith('https://mint.example/pay?'));
if (!hit) throw new Error('the quote request never hit the wire') if (!hit) throw new Error('the quote request never hit the wire');
return new URL(hit) return new URL(hit);
} };
it('rejects a mint that cannot carry the mandatory output commitment', async () => { it('rejects a mint that cannot carry the mandatory output commitment', async () => {
const seen: string[] = [] const seen: string[] = [];
await expect( await expect(
prepareMint('mint@mint.example', 21_000, { prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({})), seen), fetch: capturingFetch(routesFor(payRequestFixture({})), seen),
persistOutput, persistOutput,
allocateOutputSecrets, allocateOutputSecrets,
}), }),
).rejects.toThrow(/64-character output commitment/i) ).rejects.toThrow(/64-character output commitment/i);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
}) });
it('persists the wallet output before sending its hash as comment and h', async () => { it('persists the wallet output before sending its hash as comment and h', async () => {
const seen: string[] = [] const seen: string[] = [];
const events: string[] = [] const events: string[] = [];
const prepared = await prepareMint('mint@mint.example', 21_000, { const prepared = await prepareMint('mint@mint.example', 21_000, {
allocateOutputSecrets, allocateOutputSecrets,
fetch: (input, init) => { fetch: (input, init) => {
const url = const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery') events.push(url.startsWith('https://mint.example/pay?') ? 'invoice' : 'discovery');
return capturingFetch( return capturingFetch(
routesFor(payRequestFixture({ commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}` })), routesFor(payRequestFixture({ commentAllowed: 64, mintPubkey: `03${'cd'.repeat(32)}` })),
seen, seen,
)(input, init) )(input, init);
}, },
persistOutput: async (note) => { persistOutput: async (note) => {
events.push('persist') events.push('persist');
expect(note.verified).toBe(false) expect(note.verified).toBe(false);
expect(note.callback).toBe('') expect(note.callback).toBe('');
expect(note.amount).toBe(0) expect(note.amount).toBe(0);
expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY) expect(note.pendingMint?.mintPubkey).toBe(MINT_PUBKEY);
}, },
}) });
const noteSecret = prepared.noteSecret const noteSecret = prepared.noteSecret;
const url = callbackUrl(seen) const url = callbackUrl(seen);
// both spellings go out together: `comment` is the LUD-25 form // both spellings go out together: `comment` is the LUD-25 form
// lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read // lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read
const comment = url.searchParams.get('comment') const comment = url.searchParams.get('comment');
const h = url.searchParams.get('h') const h = url.searchParams.get('h');
expect(comment).toMatch(/^[0-9a-f]{64}$/) expect(comment).toMatch(/^[0-9a-f]{64}$/);
expect(h).toBe(comment) expect(h).toBe(comment);
expect(comment).toBe(hashK1(noteSecret)) expect(comment).toBe(hashK1(noteSecret));
expect(prepared.mintPubkey).toBe(MINT_PUBKEY) expect(prepared.mintPubkey).toBe(MINT_PUBKEY);
expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0) expect(events.indexOf('persist')).toBeGreaterThanOrEqual(0);
expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice')) expect(events.indexOf('persist')).toBeLessThan(events.indexOf('invoice'));
}) });
it('does not request an invoice when output persistence fails', async () => { it('does not request an invoice when output persistence fails', async () => {
const seen: string[] = [] const seen: string[] = [];
await expect( await expect(
prepareMint('mint@mint.example', 21_000, { prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({ commentAllowed: 64 })), seen), fetch: capturingFetch(routesFor(payRequestFixture({ commentAllowed: 64 })), seen),
persistOutput: () => Promise.reject(new Error('storage full')), persistOutput: () => Promise.reject(new Error('storage full')),
allocateOutputSecrets, allocateOutputSecrets,
}), }),
).rejects.toThrow(/storage full/) ).rejects.toThrow(/storage full/);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
}) });
it('rejects mintToHash without the mandatory comment capacity', async () => { it('rejects mintToHash without the mandatory comment capacity', async () => {
const seen: string[] = [] const seen: string[] = [];
await expect( await expect(
prepareMint('mint@mint.example', 21_000, { prepareMint('mint@mint.example', 21_000, {
fetch: capturingFetch(routesFor(payRequestFixture({ mintToHash: true })), seen), fetch: capturingFetch(routesFor(payRequestFixture({ mintToHash: true })), seen),
persistOutput, persistOutput,
allocateOutputSecrets, allocateOutputSecrets,
}), }),
).rejects.toThrow(/64-character output commitment/i) ).rejects.toThrow(/64-character output commitment/i);
expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false) expect(seen.some((url) => url.startsWith('https://mint.example/pay?'))).toBe(false);
}) });
}) });
+14 -14
View File
@@ -1,7 +1,7 @@
// The wallet's deterministic-secret and signed-output work depends on this // The wallet's deterministic-secret and signed-output work depends on this
// exact installed API surface, so package drift must fail before funds code runs. // exact installed API surface, so package drift must fail before funds code runs.
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import { import {
cashNodeFromHex, cashNodeFromHex,
cashNodeToHex, cashNodeToHex,
@@ -12,12 +12,12 @@ import {
verifyNoteSignatureAgainst, verifyNoteSignatureAgainst,
verifyNoteSignatureHash, verifyNoteSignatureHash,
verifyNoteSignatureHashAgainst, verifyNoteSignatureHashAgainst,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {LnurlcashOptions} from 'lnurlcash-kit' import type { LnurlcashOptions } from 'lnurlcash-kit';
import conformancePackage from '../../node_modules/lnurlcash-conformance/package.json' import conformancePackage from '../../node_modules/lnurlcash-conformance/package.json';
import kitPackage from '../../node_modules/lnurlcash-kit/package.json' import kitPackage from '../../node_modules/lnurlcash-kit/package.json';
const retryOptions = {mutationRetries: 1} satisfies LnurlcashOptions const retryOptions = { mutationRetries: 1 } satisfies LnurlcashOptions;
const requiredExports = [ const requiredExports = [
deriveCashRoot, deriveCashRoot,
cashNodeToHex, cashNodeToHex,
@@ -28,16 +28,16 @@ const requiredExports = [
verifyNoteSignatureAgainst, verifyNoteSignatureAgainst,
verifyNoteSignatureHash, verifyNoteSignatureHash,
verifyNoteSignatureHashAgainst, verifyNoteSignatureHashAgainst,
] as const ] as const;
describe('lnurlcash 0.8 dependency contract', () => { describe('lnurlcash 0.8 dependency contract', () => {
it('exposes the BIP-32, recovery, signature, and retry contracts', () => { it('exposes the BIP-32, recovery, signature, and retry contracts', () => {
expect(requiredExports.every((value) => typeof value === 'function')).toBe(true) expect(requiredExports.every((value) => typeof value === 'function')).toBe(true);
expect(retryOptions.mutationRetries).toBe(1) expect(retryOptions.mutationRetries).toBe(1);
}) });
it('runs against the exact installed package versions', () => { it('runs against the exact installed package versions', () => {
expect(kitPackage.version).toBe('0.8.0') expect(kitPackage.version).toBe('0.8.0');
expect(conformancePackage.version).toBe('0.7.0') expect(conformancePackage.version).toBe('0.7.0');
}) });
}) });
+75 -74
View File
@@ -20,25 +20,25 @@
// mints - the trusted-mint registry // mints - the trusted-mint registry
// settings - plaintext wallet settings // settings - plaintext wallet settings
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {utf8ToBytes} from '@noble/hashes/utils.js' import { utf8ToBytes } from '@noble/hashes/utils.js';
import type {Event as NostrEvent} from 'nostr-tools/core' import type { Event as NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey, verifyEvent} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey, verifyEvent } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import type {EncryptedBearerRecord} from '../storage/bearers' import type { EncryptedBearerRecord } from '../storage/bearers';
import type {TrustedMint} from '../trustedMints' import type { TrustedMint } from '../trustedMints';
import type {WalletSettings} from '../storage/settings' import type { WalletSettings } from '../storage/settings';
export type {NostrEvent} export type { NostrEvent };
// addressable (parametrized-replaceable) app-data event - relays keep only // addressable (parametrized-replaceable) app-data event - relays keep only
// the newest event per (pubkey, kind, d-tag) // the newest event per (pubkey, kind, d-tag)
export const BACKUP_EVENT_KIND = 30078 export const BACKUP_EVENT_KIND = 30078;
export type BackupPart = 'notes' | 'mints' | 'settings' export type BackupPart = 'notes' | 'mints' | 'settings';
export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'] export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'];
// the backup key is dedicated to this wallet (derived with a // the backup key is dedicated to this wallet (derived with a
// sattle-specific context, see below), so its pubkey is ours alone and the // sattle-specific context, see below), so its pubkey is ours alone and the
@@ -47,45 +47,46 @@ export const BACKUP_D_TAGS: Record<BackupPart, string> = {
notes: 'notes', notes: 'notes',
mints: 'mints', mints: 'mints',
settings: 'settings', settings: 'settings',
} };
// the notes payload: bearers plus the counter map, from one consistent // the notes payload: bearers plus the counter map, from one consistent
// funds-document read // funds-document read
export type NotesBackupPayload = { export type NotesBackupPayload = {
bearers: EncryptedBearerRecord[] bearers: EncryptedBearerRecord[];
nextByHost: Record<string, number> nextByHost: Record<string, number>;
} };
// the decrypted payload of each part, without its envelope // the decrypted payload of each part, without its envelope
export type BackupPartPayload = { export type BackupPartPayload = {
notes: NotesBackupPayload notes: NotesBackupPayload;
mints: TrustedMint[] mints: TrustedMint[];
settings: WalletSettings settings: WalletSettings;
} };
const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1' const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1';
// Deterministic: sha256(linking key || context), mirroring keys.ts's // Deterministic: sha256(linking key || context), mirroring keys.ts's
// deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for // deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for
// backup - it signs and decrypts backup events, nothing else. // backup - it signs and decrypts backup events, nothing else.
export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array => export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array =>
sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)])) sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]));
// the x-only nostr pubkey identifying this wallet's backup events // the x-only nostr pubkey identifying this wallet's backup events
export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey) export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey);
// NIP-44 "self-DM": the conversation key between the backup key and its own // NIP-44 "self-DM": the conversation key between the backup key and its own
// pubkey - decryptable by the seed holder and nobody else // pubkey - decryptable by the seed holder and nobody else
const selfConversationKey = (secretKey: Uint8Array): Uint8Array => const selfConversationKey = (secretKey: Uint8Array): Uint8Array =>
nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey)) nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
// a relay could serve a multi-megabyte content string; JSON.parse and // a relay could serve a multi-megabyte content string; JSON.parse and
// NIP-44 decrypt of that would hang the tab. A real backup is a handful of // NIP-44 decrypt of that would hang the tab. A real backup is a handful of
// kilobytes per part - this is far beyond generous (applyBackup's own // kilobytes per part - this is far beyond generous (applyBackup's own
// per-record bounds still apply on top after decrypt) // per-record bounds still apply on top after decrypt)
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024 const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024;
export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? '' export const dTagOf = (event: NostrEvent): string =>
event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
// per-part envelope versions: the notes part went v2 with the funds // per-part envelope versions: the notes part went v2 with the funds
// document (counters joined the payload - a breaking change); mints and // document (counters joined the payload - a breaking change); mints and
@@ -94,20 +95,20 @@ const PART_ENVELOPE_VERSION: Record<BackupPart, number> = {
notes: 2, notes: 2,
mints: 1, mints: 1,
settings: 1, settings: 1,
} };
const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => { const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
switch (part) { switch (part) {
case 'notes': { case 'notes': {
const notes = payload as NotesBackupPayload const notes = payload as NotesBackupPayload;
return JSON.stringify({version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost}) return JSON.stringify({ version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost });
} }
case 'mints': case 'mints':
return JSON.stringify({version: 1, trustedMints: payload}) return JSON.stringify({ version: 1, trustedMints: payload });
case 'settings': case 'settings':
return JSON.stringify({version: 1, settings: payload}) return JSON.stringify({ version: 1, settings: payload });
}
} }
};
// signs one addressable backup event for a single part // signs one addressable backup event for a single part
export const buildBackupEvent = <P extends BackupPart>( export const buildBackupEvent = <P extends BackupPart>(
@@ -124,7 +125,7 @@ export const buildBackupEvent = <P extends BackupPart>(
content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)), content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
}, },
secretKey, secretKey,
) );
// one event per part present in `parts`, all sharing one timestamp // one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = ( export const buildBackupEvents = (
@@ -132,33 +133,33 @@ export const buildBackupEvents = (
parts: Partial<BackupPartPayload>, parts: Partial<BackupPartPayload>,
createdAt?: number, createdAt?: number,
): NostrEvent[] => { ): NostrEvent[] => {
const at = createdAt ?? Math.floor(Date.now() / 1000) const at = createdAt ?? Math.floor(Date.now() / 1000);
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
for (const part of BACKUP_PARTS) { for (const part of BACKUP_PARTS) {
const payload = parts[part] const payload = parts[part];
if (payload === undefined) continue if (payload === undefined) continue;
events.push(buildBackupEvent(secretKey, part, payload, at)) events.push(buildBackupEvent(secretKey, part, payload, at));
}
return events
} }
return events;
};
export type ParsedBackupEvent = export type ParsedBackupEvent =
| { part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number> } | { part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number> }
| { part: 'mints'; trustedMints: TrustedMint[] } | { part: 'mints'; trustedMints: TrustedMint[] }
| {part: 'settings'; settings: WalletSettings} | { part: 'settings'; settings: WalletSettings };
// Light shape checks so parse returns typed values; the strict bounds // Light shape checks so parse returns typed values; the strict bounds
// (record counts, field lengths, counter ranges and caps) are enforced by // (record counts, field lengths, counter ranges and caps) are enforced by
// applyBackup / commitFundsRestore on the restore path, same as file // applyBackup / commitFundsRestore on the restore path, same as file
// backups. // backups.
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => { const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null if (typeof data !== 'object' || data === null) return null;
const envelope = data as Record<string, unknown> const envelope = data as Record<string, unknown>;
if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null;
switch (dTag) { switch (dTag) {
case 'notes': { case 'notes': {
if (!Array.isArray(envelope.bearers)) return null if (!Array.isArray(envelope.bearers)) return null;
const bearers = envelope.bearers as unknown[] const bearers = envelope.bearers as unknown[];
if ( if (
!bearers.every( !bearers.every(
(r) => (r) =>
@@ -167,20 +168,20 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string', typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
) )
) { ) {
return null return null;
} }
if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null;
const counters = envelope.nextByHost as Record<string, unknown> const counters = envelope.nextByHost as Record<string, unknown>;
if (!Object.values(counters).every((v) => typeof v === 'number')) return null if (!Object.values(counters).every((v) => typeof v === 'number')) return null;
return { return {
part: 'notes', part: 'notes',
bearers: bearers as EncryptedBearerRecord[], bearers: bearers as EncryptedBearerRecord[],
nextByHost: counters as Record<string, number>, nextByHost: counters as Record<string, number>,
} };
} }
case 'mints': { case 'mints': {
if (!Array.isArray(envelope.trustedMints)) return null if (!Array.isArray(envelope.trustedMints)) return null;
const mints = envelope.trustedMints as unknown[] const mints = envelope.trustedMints as unknown[];
if ( if (
!mints.every( !mints.every(
(m) => (m) =>
@@ -188,22 +189,22 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
typeof (m as TrustedMint)?.mintPubkey === 'string', typeof (m as TrustedMint)?.mintPubkey === 'string',
) )
) { ) {
return null return null;
} }
return {part: 'mints', trustedMints: mints as TrustedMint[]} return { part: 'mints', trustedMints: mints as TrustedMint[] };
} }
case 'settings': { case 'settings': {
if (typeof envelope.settings !== 'object' || envelope.settings === null) { if (typeof envelope.settings !== 'object' || envelope.settings === null) {
return null return null;
} }
const settings = envelope.settings as Record<string, unknown> const settings = envelope.settings as Record<string, unknown>;
if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') { if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
return null return null;
}
return {part: 'settings', settings: settings as WalletSettings}
} }
return { part: 'settings', settings: settings as WalletSettings };
} }
} }
};
// Validates and decrypts one backup event. Returns null for anything that // Validates and decrypts one backup event. Returns null for anything that
// isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag, // isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag,
@@ -213,23 +214,23 @@ export const parseBackupEvent = (
secretKey: Uint8Array, secretKey: Uint8Array,
event: NostrEvent, event: NostrEvent,
): ParsedBackupEvent | null => { ): ParsedBackupEvent | null => {
if (event.kind !== BACKUP_EVENT_KIND) return null if (event.kind !== BACKUP_EVENT_KIND) return null;
if (event.pubkey !== getPublicKey(secretKey)) return null if (event.pubkey !== getPublicKey(secretKey)) return null;
const dTag = dTagOf(event) const dTag = dTagOf(event);
if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null;
if (!verifyEvent(event)) return null if (!verifyEvent(event)) return null;
if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null;
let plaintext: string let plaintext: string;
try { try {
plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey)) plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey));
} catch { } catch {
return null return null;
} }
let data: unknown let data: unknown;
try { try {
data = JSON.parse(plaintext) data = JSON.parse(plaintext);
} catch { } catch {
return null return null;
}
return parsePayload(dTag, data)
} }
return parsePayload(dTag, data);
};
+35 -33
View File
@@ -2,74 +2,76 @@
// edits (a mint plus a receive plus a reorder) collapse into one publish // edits (a mint plus a receive plus a reorder) collapse into one publish
// of the final state. Trailing-edge debounce with in-flight coalescing. // of the final state. Trailing-edge debounce with in-flight coalescing.
import type {BackupPartPayload} from './events' import type { BackupPartPayload } from './events';
export type BackupPublisher = { export type BackupPublisher = {
// record that local state changed - the latest snapshot wins, and only // record that local state changed - the latest snapshot wins, and only
// one publish fires per quiet window no matter how many changes landed // one publish fires per quiet window no matter how many changes landed
schedule: (parts: Partial<BackupPartPayload>) => void schedule: (parts: Partial<BackupPartPayload>) => void;
// publish any pending snapshot immediately (app backgrounding, logout) // publish any pending snapshot immediately (app backgrounding, logout)
flush: () => Promise<void> flush: () => Promise<void>;
// drop any pending snapshot without publishing // drop any pending snapshot without publishing
cancel: () => void cancel: () => void;
} };
export type BackupPublisherOptions = { export type BackupPublisherOptions = {
publish: (parts: Partial<BackupPartPayload>) => Promise<void> publish: (parts: Partial<BackupPartPayload>) => Promise<void>;
delayMs: number delayMs: number;
// a debounced publish has no caller to throw to - errors surface here; // a debounced publish has no caller to throw to - errors surface here;
// the next scheduled change retries // the next scheduled change retries
onError?: (error: unknown) => void onError?: (error: unknown) => void;
} };
export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => { export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => {
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Partial<BackupPartPayload> | null = null let pending: Partial<BackupPartPayload> | null = null;
let running: Promise<void> | null = null let running: Promise<void> | null = null;
const clearTimer = (): void => { const clearTimer = (): void => {
if (timer !== null) { if (timer !== null) {
clearTimeout(timer) clearTimeout(timer);
timer = null timer = null;
}
} }
};
const drain = async (): Promise<void> => { const drain = async (): Promise<void> => {
// a change can land mid-publish - keep looping until the newest // a change can land mid-publish - keep looping until the newest
// snapshot is the one that's out // snapshot is the one that's out
while (pending !== null) { while (pending !== null) {
const snapshot = pending const snapshot = pending;
pending = null pending = null;
try { try {
await options.publish(snapshot) await options.publish(snapshot);
} catch (error) { } catch (error) {
options.onError?.( options.onError?.(
error instanceof Error ? error : new Error('Backup publication failed.', {cause: error}), error instanceof Error
) ? error
} : new Error('Backup publication failed.', { cause: error }),
);
} }
} }
};
const fire = (): Promise<void> => { const fire = (): Promise<void> => {
clearTimer() clearTimer();
// an in-flight drain picks up anything pending itself - a second drain // an in-flight drain picks up anything pending itself - a second drain
// would double-publish the same snapshot // would double-publish the same snapshot
running ??= drain().finally(() => { running ??= drain().finally(() => {
running = null running = null;
}) });
return running return running;
} };
return { return {
schedule: (parts) => { schedule: (parts) => {
pending = parts pending = parts;
clearTimer() clearTimer();
timer = setTimeout(() => void fire(), options.delayMs) timer = setTimeout(() => void fire(), options.delayMs);
}, },
flush: fire, flush: fire,
cancel: () => { cancel: () => {
clearTimer() clearTimer();
pending = null pending = null;
}, },
} };
} };
+54 -54
View File
@@ -8,11 +8,11 @@
// under different record ids, spent-wins) happens after decrypt in // under different record ids, spent-wins) happens after decrypt in
// bearers.ts's mergeBearers, exactly as with a file backup. // bearers.ts's mergeBearers, exactly as with a file backup.
import type {RestoreResult} from '../storage/backup' import type { RestoreResult } from '../storage/backup';
import {applyBackup} from '../storage/backup' import { applyBackup } from '../storage/backup';
import {linkingPubKeyHex} from '../keys' import { linkingPubKeyHex } from '../keys';
import type {BackupPart, BackupPartPayload, NostrEvent} from './events' import type { BackupPart, BackupPartPayload, NostrEvent } from './events';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
BACKUP_PARTS, BACKUP_PARTS,
@@ -21,18 +21,18 @@ import {
deriveBackupKey, deriveBackupKey,
dTagOf, dTagOf,
parseBackupEvent, parseBackupEvent,
} from './events' } from './events';
import type {BackupTransport} from './transport' import type { BackupTransport } from './transport';
import {defaultTransport} from './transport' import { defaultTransport } from './transport';
export type PublishBackupOptions = { export type PublishBackupOptions = {
transport?: BackupTransport transport?: BackupTransport;
createdAt?: number createdAt?: number;
} };
export type PublishBackupResult = { export type PublishBackupResult = {
published: BackupPart[] published: BackupPart[];
} };
// builds and publishes one event per part present in `parts` // builds and publishes one event per part present in `parts`
export const publishBackup = async ( export const publishBackup = async (
@@ -41,26 +41,26 @@ export const publishBackup = async (
relays: string[], relays: string[],
options: PublishBackupOptions = {}, options: PublishBackupOptions = {},
): Promise<PublishBackupResult> => { ): Promise<PublishBackupResult> => {
const at = options.createdAt ?? Math.floor(Date.now() / 1000) const at = options.createdAt ?? Math.floor(Date.now() / 1000);
const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined) const present = BACKUP_PARTS.filter((part) => parts[part] !== undefined);
if (present.length === 0) return {published: []} if (present.length === 0) return { published: [] };
const transport = options.transport ?? (await defaultTransport()) const transport = options.transport ?? (await defaultTransport());
const published: BackupPart[] = [] const published: BackupPart[] = [];
for (const part of present) { for (const part of present) {
const payload = parts[part] const payload = parts[part];
if (payload === undefined) continue if (payload === undefined) continue;
await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at)) await transport.publish(relays, buildBackupEvent(secretKey, part, payload, at));
published.push(part) published.push(part);
}
return {published}
} }
return { published };
};
export type FetchBackupOptions = { export type FetchBackupOptions = {
// the pubkey alone can fetch the ciphertext, but only the key holder // the pubkey alone can fetch the ciphertext, but only the key holder
// reads it - decryption is part of fetching // reads it - decryption is part of fetching
secretKey: Uint8Array secretKey: Uint8Array;
transport?: BackupTransport transport?: BackupTransport;
} };
// fetches the newest valid event per d-tag and decrypts it. Honest relays // fetches the newest valid event per d-tag and decrypts it. Honest relays
// already replace addressable events, but a stale or misbehaving relay may // already replace addressable events, but a stale or misbehaving relay may
@@ -71,49 +71,49 @@ export const fetchBackup = async (
relays: string[], relays: string[],
options: FetchBackupOptions, options: FetchBackupOptions,
): Promise<Partial<BackupPartPayload>> => { ): Promise<Partial<BackupPartPayload>> => {
const transport = options.transport ?? (await defaultTransport()) const transport = options.transport ?? (await defaultTransport());
const events = await transport.fetch(relays, { const events = await transport.fetch(relays, {
kinds: [BACKUP_EVENT_KIND], kinds: [BACKUP_EVENT_KIND],
authors: [pubkey], authors: [pubkey],
}) });
const byTag = new Map<string, NostrEvent[]>() const byTag = new Map<string, NostrEvent[]>();
for (const event of events) { for (const event of events) {
const d = dTagOf(event) const d = dTagOf(event);
if (!d) continue if (!d) continue;
const candidates = byTag.get(d) const candidates = byTag.get(d);
if (candidates) candidates.push(event) if (candidates) candidates.push(event);
else byTag.set(d, [event]) else byTag.set(d, [event]);
} }
const parts: Partial<BackupPartPayload> = {} const parts: Partial<BackupPartPayload> = {};
for (const candidates of byTag.values()) { for (const candidates of byTag.values()) {
// a hostile relay may serve a tampered "newest" copy - walk // a hostile relay may serve a tampered "newest" copy - walk
// newest-first and take the first that validates and decrypts // newest-first and take the first that validates and decrypts
candidates.sort((a, b) => b.created_at - a.created_at) candidates.sort((a, b) => b.created_at - a.created_at);
for (const event of candidates) { for (const event of candidates) {
const parsed = parseBackupEvent(options.secretKey, event) const parsed = parseBackupEvent(options.secretKey, event);
if (!parsed) continue if (!parsed) continue;
switch (parsed.part) { switch (parsed.part) {
case 'notes': case 'notes':
parts.notes = {bearers: parsed.bearers, nextByHost: parsed.nextByHost} parts.notes = { bearers: parsed.bearers, nextByHost: parsed.nextByHost };
break break;
case 'mints': case 'mints':
parts.mints = parsed.trustedMints parts.mints = parsed.trustedMints;
break break;
case 'settings': case 'settings':
parts.settings = parsed.settings parts.settings = parsed.settings;
break break;
} }
break break;
} }
} }
return parts return parts;
} };
export type NostrRestoreResult = RestoreResult & { export type NostrRestoreResult = RestoreResult & {
// which d-tags carried a valid payload at all - lets the caller // which d-tags carried a valid payload at all - lets the caller
// distinguish "nothing backed up yet" from "restored an empty wallet" // distinguish "nothing backed up yet" from "restored an empty wallet"
found: BackupPart[] found: BackupPart[];
} };
// The restore path end to end: re-derive the backup key from the linking // The restore path end to end: re-derive the backup key from the linking
// key (the holder already re-entered the seed phrase to get that far), // key (the holder already re-entered the seed phrase to get that far),
@@ -126,11 +126,11 @@ export const restoreFromNostr = async (
relays: string[], relays: string[],
options: { transport?: BackupTransport } = {}, options: { transport?: BackupTransport } = {},
): Promise<NostrRestoreResult> => { ): Promise<NostrRestoreResult> => {
const secretKey = deriveBackupKey(linkingPrivKey) const secretKey = deriveBackupKey(linkingPrivKey);
const parts = await fetchBackup(backupPubkey(secretKey), relays, { const parts = await fetchBackup(backupPubkey(secretKey), relays, {
secretKey, secretKey,
transport: options.transport, transport: options.transport,
}) });
const result = await applyBackup( const result = await applyBackup(
{ {
type: 'sattle-backup', type: 'sattle-backup',
@@ -142,9 +142,9 @@ export const restoreFromNostr = async (
settings: parts.settings, settings: parts.settings,
}, },
linkingPubKeyHex(linkingPrivKey), linkingPubKeyHex(linkingPrivKey),
) );
return { return {
...result, ...result,
found: BACKUP_PARTS.filter((part) => parts[part] !== undefined), found: BACKUP_PARTS.filter((part) => parts[part] !== undefined),
} };
} };
+12 -12
View File
@@ -3,30 +3,30 @@
// lazily: merely importing the backup module must never open (or even // lazily: merely importing the backup module must never open (or even
// reference) a WebSocket. // reference) a WebSocket.
import type {Filter as NostrFilter} from 'nostr-tools/filter' import type { Filter as NostrFilter } from 'nostr-tools/filter';
import type {NostrEvent} from './events' import type { NostrEvent } from './events';
export type {NostrFilter} export type { NostrFilter };
// publish throws when the event was accepted NOWHERE // publish throws when the event was accepted NOWHERE
export type BackupTransport = { export type BackupTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void> publish: (relays: string[], event: NostrEvent) => Promise<void>;
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]> fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>;
} };
export const defaultTransport = async (): Promise<BackupTransport> => { export const defaultTransport = async (): Promise<BackupTransport> => {
const {SimplePool} = await import('nostr-tools/pool') const { SimplePool } = await import('nostr-tools/pool');
const pool = new SimplePool() const pool = new SimplePool();
return { return {
publish: async (relays, event) => { publish: async (relays, event) => {
const results = await Promise.allSettled(pool.publish(relays, event)) const results = await Promise.allSettled(pool.publish(relays, event));
// one honest relay keeping the event is enough - addressable events // one honest relay keeping the event is enough - addressable events
// are re-publishable, and the next debounced publish retries anyway // are re-publishable, and the next debounced publish retries anyway
if (!results.some((r) => r.status === 'fulfilled')) { if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the backup event.') throw new Error('No relay accepted the backup event.');
} }
}, },
fetch: (relays, filter) => pool.querySync(relays, filter), fetch: (relays, filter) => pool.querySync(relays, filter),
} };
} };
+83 -83
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,107 +78,107 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!filter.authors || filter.authors.includes(e.pubkey)),
), ),
), ),
} };
return {transport, events} return { transport, events };
} };
// flips the end of a base64 payload to different-but-valid characters // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('buildBackupEvent / parseBackupEvent', () => { describe('buildBackupEvent / parseBackupEvent', () => {
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const records = [ const records = [
// long unique sentinel id: a short id like 'r1' randomly appears in // long unique sentinel id: a short id like 'r1' randomly appears in
// base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext // base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext
// assertion below // assertion below
{ id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }, { id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) },
] ];
const notes = {bearers: records, nextByHost: {'mint.example': 3}} const notes = { bearers: records, nextByHost: { 'mint.example': 3 } };
const mints = [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}] const mints = [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true }];
const settings = {defaultMint: 'mint.example'} const settings = { defaultMint: 'mint.example' };
it('round-trips all three parts through build and parse', () => { it('round-trips all three parts through build and parse', () => {
const events = buildBackupEvents(secretKey, {notes, mints, settings}, 1000) const events = buildBackupEvents(secretKey, { notes, mints, settings }, 1000);
expect(events).toHaveLength(3) expect(events).toHaveLength(3);
expect(events.map((e) => e.kind)).toEqual([ expect(events.map((e) => e.kind)).toEqual([
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
]) ]);
expect(events.map((e) => e.tags)).toEqual([ expect(events.map((e) => e.tags)).toEqual([
[['d', 'notes']], [['d', 'notes']],
[['d', 'mints']], [['d', 'mints']],
[['d', 'settings']], [['d', 'settings']],
]) ]);
expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true) expect(events.every((e) => e.pubkey === backupPubkey(secretKey))).toBe(true);
expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({ expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({
part: 'notes', part: 'notes',
bearers: records, bearers: records,
nextByHost: { 'mint.example': 3 }, nextByHost: { 'mint.example': 3 },
}) });
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({ expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
part: 'mints', part: 'mints',
trustedMints: mints, trustedMints: mints,
}) });
expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({ expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({
part: 'settings', part: 'settings',
settings, settings,
}) });
}) });
it('leaves no plaintext in the payload', () => { it('leaves no plaintext in the payload', () => {
const event = buildBackupEvent(secretKey, 'notes', notes) const event = buildBackupEvent(secretKey, 'notes', notes);
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a') expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a');
expect(event.content).not.toContain('ciphertext') expect(event.content).not.toContain('ciphertext');
}) });
it('builds events only for the parts present', () => { it('builds events only for the parts present', () => {
const events = buildBackupEvents(secretKey, {settings}, 1000) const events = buildBackupEvents(secretKey, { settings }, 1000);
expect(events).toHaveLength(1) expect(events).toHaveLength(1);
expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]) expect(requiredValue(events[0]).tags).toEqual([['d', 'settings']]);
}) });
it('rejects a payload encrypted for a different key', () => { it('rejects a payload encrypted for a different key', () => {
const event = buildBackupEvent(secretKey, 'settings', settings) const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull() expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull();
}) });
it('rejects the wrong kind', () => { it('rejects the wrong kind', () => {
const event = buildBackupEvent(secretKey, 'settings', settings) const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull() expect(parseBackupEvent(secretKey, { ...event, kind: 30079 })).toBeNull();
}) });
it('rejects an unknown d-tag', () => { it('rejects an unknown d-tag', () => {
const event = buildBackupEvent(secretKey, 'settings', settings) const event = buildBackupEvent(secretKey, 'settings', settings);
expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull() expect(parseBackupEvent(secretKey, { ...event, tags: [['d', 'secrets']] })).toBeNull();
}) });
it('rejects a modified ciphertext - the signature no longer matches', () => { it('rejects a modified ciphertext - the signature no longer matches', () => {
const event = buildBackupEvent(secretKey, 'settings', settings) const event = buildBackupEvent(secretKey, 'settings', settings);
const tampered = {...event, content: tamperContent(event.content)} const tampered = { ...event, content: tamperContent(event.content) };
expect(parseBackupEvent(secretKey, tampered)).toBeNull() expect(parseBackupEvent(secretKey, tampered)).toBeNull();
}) });
it('rejects an event signed by a different key', () => { it('rejects an event signed by a different key', () => {
const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings) const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings);
expect(parseBackupEvent(secretKey, foreign)).toBeNull() expect(parseBackupEvent(secretKey, foreign)).toBeNull();
}) });
it('rejects a validly signed event whose payload is not a backup envelope', () => { it('rejects a validly signed event whose payload is not a backup envelope', () => {
// a same-key event of the right kind and d-tag, but its decrypted // a same-key event of the right kind and d-tag, but its decrypted
// content is not a version-1 envelope // content is not a version-1 envelope
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey)) const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
const event = finalizeEvent( const event = finalizeEvent(
{ {
kind: BACKUP_EVENT_KIND, kind: BACKUP_EVENT_KIND,
@@ -187,12 +187,12 @@ describe('buildBackupEvent / parseBackupEvent', () => {
content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey), content: nip44v2.encrypt(JSON.stringify({ version: 2, settings: {} }), conversationKey),
}, },
secretKey, secretKey,
) );
expect(parseBackupEvent(secretKey, event)).toBeNull() expect(parseBackupEvent(secretKey, event)).toBeNull();
}) });
it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => { it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => {
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey)) const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey));
const build = (payload: unknown): NostrEvent => const build = (payload: unknown): NostrEvent =>
finalizeEvent( finalizeEvent(
{ {
@@ -202,13 +202,13 @@ describe('buildBackupEvent / parseBackupEvent', () => {
content: nip44v2.encrypt(JSON.stringify(payload), conversationKey), content: nip44v2.encrypt(JSON.stringify(payload), conversationKey),
}, },
secretKey, secretKey,
) );
// v1 notes carried bare bearer arrays and no counters - pre-funds-document // v1 notes carried bare bearer arrays and no counters - pre-funds-document
expect(parseBackupEvent(secretKey, build({version: 1, bearers: records}))).toBeNull() expect(parseBackupEvent(secretKey, build({ version: 1, bearers: records }))).toBeNull();
expect(parseBackupEvent(secretKey, build({version: 2, bearers: records}))).toBeNull() expect(parseBackupEvent(secretKey, build({ version: 2, bearers: records }))).toBeNull();
// a v2 envelope whose counters are malformed is rejected, not coerced // a v2 envelope whose counters are malformed is rejected, not coerced
expect( expect(
parseBackupEvent(secretKey, build({ version: 2, bearers: records, nextByHost: 'junk' })), parseBackupEvent(secretKey, build({ version: 2, bearers: records, nextByHost: 'junk' })),
).toBeNull() ).toBeNull();
}) });
}) });
+39 -39
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,21 +78,21 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!filter.authors || filter.authors.includes(e.pubkey)),
), ),
), ),
} };
return {transport, events} return { transport, events };
} };
// flips the end of a base64 payload to different-but-valid characters // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('deriveBackupKey', () => { describe('deriveBackupKey', () => {
it('derives a stable key from the linking key', () => { it('derives a stable key from the linking key', () => {
@@ -101,12 +101,12 @@ describe('deriveBackupKey', () => {
// derive a different pubkey and find nothing to restore // derive a different pubkey and find nothing to restore
expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe( expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe(
'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9', 'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9',
) );
}) });
it('derives a different key from a different linking key', () => { it('derives a different key from a different linking key', () => {
expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe( expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe(
bytesToHex(deriveBackupKey(LINKING_KEY)), bytesToHex(deriveBackupKey(LINKING_KEY)),
) );
}) });
}) });
+105 -102
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,134 +78,137 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!filter.authors || filter.authors.includes(e.pubkey)),
), ),
), ),
} };
return {transport, events} return { transport, events };
} };
// flips the end of a base64 payload to different-but-valid characters // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('createBackupPublisher', () => { describe('createBackupPublisher', () => {
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => { it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
published.push(p) published.push(p);
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 1000, delayMs: 1000,
}) });
publisher.schedule({settings: {defaultMint: 'a'}}) publisher.schedule({ settings: { defaultMint: 'a' } });
publisher.schedule({settings: {defaultMint: 'b'}}) publisher.schedule({ settings: { defaultMint: 'b' } });
publisher.schedule({settings: {defaultMint: 'c'}}) publisher.schedule({ settings: { defaultMint: 'c' } });
await vi.advanceTimersByTimeAsync(999) await vi.advanceTimersByTimeAsync(999);
expect(published).toEqual([]) expect(published).toEqual([]);
await vi.advanceTimersByTimeAsync(1) await vi.advanceTimersByTimeAsync(1);
expect(published).toEqual([{settings: {defaultMint: 'c'}}]) expect(published).toEqual([{ settings: { defaultMint: 'c' } }]);
}) });
it('publishes again when a change lands after the quiet window', async () => { it('publishes again when a change lands after the quiet window', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
published.push(p) published.push(p);
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 1000, delayMs: 1000,
}) });
publisher.schedule({settings: {defaultMint: 'a'}}) publisher.schedule({ settings: { defaultMint: 'a' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
publisher.schedule({settings: {defaultMint: 'b'}}) publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([{settings: {defaultMint: 'a'}}, {settings: {defaultMint: 'b'}}]) expect(published).toEqual([
}) { settings: { defaultMint: 'a' } },
{ settings: { defaultMint: 'b' } },
]);
});
it('publishes a snapshot that lands mid-publish instead of losing it', async () => { it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
// the publish callback re-schedules on the publisher being created - // the publish callback re-schedules on the publisher being created -
// a holder indirection keeps both const // a holder indirection keeps both const
const holder: {publisher?: ReturnType<typeof createBackupPublisher>} = {} const holder: { publisher?: ReturnType<typeof createBackupPublisher> } = {};
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
published.push(p) published.push(p);
// a local change lands while the first publish is in flight // a local change lands while the first publish is in flight
if (published.length === 1) { if (published.length === 1) {
holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}}) holder.publisher?.schedule({ settings: { defaultMint: 'mid-flight' } });
} }
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 1000, delayMs: 1000,
}) });
holder.publisher = publisher holder.publisher = publisher;
publisher.schedule({settings: {defaultMint: 'first'}}) publisher.schedule({ settings: { defaultMint: 'first' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([ expect(published).toEqual([
{ settings: { defaultMint: 'first' } }, { settings: { defaultMint: 'first' } },
{ settings: { defaultMint: 'mid-flight' } }, { settings: { defaultMint: 'mid-flight' } },
]) ]);
}) });
it('flush publishes immediately; cancel drops the pending snapshot', async () => { it('flush publishes immediately; cancel drops the pending snapshot', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
published.push(p) published.push(p);
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 60_000, delayMs: 60_000,
}) });
publisher.schedule({settings: {defaultMint: 'a'}}) publisher.schedule({ settings: { defaultMint: 'a' } });
await publisher.flush() await publisher.flush();
expect(published).toEqual([{settings: {defaultMint: 'a'}}]) expect(published).toEqual([{ settings: { defaultMint: 'a' } }]);
publisher.schedule({settings: {defaultMint: 'b'}}) publisher.schedule({ settings: { defaultMint: 'b' } });
publisher.cancel() publisher.cancel();
await vi.advanceTimersByTimeAsync(60_000) await vi.advanceTimersByTimeAsync(60_000);
expect(published).toHaveLength(1) expect(published).toHaveLength(1);
}) });
it('reports a failed publish via onError and retries on the next change', async () => { it('reports a failed publish via onError and retries on the next change', async () => {
vi.useFakeTimers() vi.useFakeTimers();
const published: Partial<BackupPartPayload>[] = [] const published: Partial<BackupPartPayload>[] = [];
const errors: unknown[] = [] const errors: unknown[] = [];
let failing = true let failing = true;
const publisher = createBackupPublisher({ const publisher = createBackupPublisher({
publish: (p) => { publish: (p) => {
if (failing) return Promise.reject(new Error('relay down')) if (failing) return Promise.reject(new Error('relay down'));
published.push(p) published.push(p);
return Promise.resolve() return Promise.resolve();
}, },
delayMs: 1000, delayMs: 1000,
onError: (e) => { onError: (e) => {
errors.push(e) errors.push(e);
}, },
}) });
publisher.schedule({settings: {defaultMint: 'a'}}) publisher.schedule({ settings: { defaultMint: 'a' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([]) expect(published).toEqual([]);
expect(errors).toHaveLength(1) expect(errors).toHaveLength(1);
failing = false failing = false;
publisher.schedule({settings: {defaultMint: 'b'}}) publisher.schedule({ settings: { defaultMint: 'b' } });
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000);
expect(published).toEqual([{settings: {defaultMint: 'b'}}]) expect(published).toEqual([{ settings: { defaultMint: 'b' } }]);
}) });
}) });
+138 -114
View File
@@ -2,14 +2,19 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial} from './keys' import {
deriveBearerAesKey,
deriveWalletMaterial,
linkingPubKeyHex,
saveWalletMaterial,
} from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +26,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -31,29 +36,29 @@ import {
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
readNextByHost, readNextByHost,
} from './storage' } from './storage';
import {readFundsDocument, writeFundsDocument} from './storage/bearers' import { readFundsDocument, writeFundsDocument } from './storage/bearers';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
// full v2 material for the fixed harness linking key (real BIP-32 cash root) // full v2 material for the fixed harness linking key (real BIP-32 cash root)
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -64,20 +69,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -87,33 +92,33 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!filter.authors || filter.authors.includes(e.pubkey)),
), ),
), ),
} };
return {transport, events} return { transport, events };
} };
// flips the end of a base64 payload to different-but-valid characters // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('restoreFromNostr', () => { describe('restoreFromNostr', () => {
it('restores notes, mints and settings onto a fresh device through applyBackup', async () => { it('restores notes, mints and settings onto a fresh device through applyBackup', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY) const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
// device A: one note, one trusted mint, one setting - all published // device A: one note, one trusted mint, one setting - all published
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
await persistBearer(aesKey, bearerFixture({id: 'note-a'})) await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID}) await addTrustedMint('mint.example', MINT_PUBKEY, { ownerId: OWNER_ID });
persistSettings({defaultMint: 'mint.example'}) persistSettings({ defaultMint: 'mint.example' });
await publishBackup( await publishBackup(
secretKey, secretKey,
{ {
@@ -123,156 +128,175 @@ describe('restoreFromNostr', () => {
}, },
RELAYS, RELAYS,
{ transport }, { transport },
) );
// device B: the same seed on empty storage // device B: the same seed on empty storage
stubLocalStorage() stubLocalStorage();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual(['notes', 'mints', 'settings']) expect(result.found).toEqual(['notes', 'mints', 'settings']);
expect(result.added).toBe(1) expect(result.added).toBe(1);
expect(result.trustedMintsAdded).toBe(1) expect(result.trustedMintsAdded).toBe(1);
expect(result.settingsRestored).toBe(true) expect(result.settingsRestored).toBe(true);
// the linking key is never part of a nostr backup - the seed phrase // the linking key is never part of a nostr backup - the seed phrase
// the holder entered is its recovery path // the holder entered is its recovery path
expect(result.linkingKeyRestored).toBe(false) expect(result.linkingKeyRestored).toBe(false);
// the note decrypts under this device's bearer key - same seed // the note decrypts under this device's bearer key - same seed
expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})]) expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
expect(loadSettings()).toEqual({defaultMint: 'mint.example'}) expect(loadSettings()).toEqual({ defaultMint: 'mint.example' });
// a file/backup-sourced mint pin stays unconfirmed until a live // a file/backup-sourced mint pin stays unconfirmed until a live
// response corroborates it - nostr restore inherits that rule from // response corroborates it - nostr restore inherits that rule from
// applyBackup unchanged // applyBackup unchanged
expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true) expect(isMintUnconfirmed('mint.example', OWNER_ID)).toBe(true);
}) });
it('unions records by id and lets a spent copy win after decrypt', async () => { it('unions records by id and lets a spent copy win after decrypt', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY) const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
// device A publishes its store holding the spendable note // device A publishes its store holding the spendable note
await persistBearer(aesKey, bearerFixture({id: 'rec-a'})) await persistBearer(aesKey, bearerFixture({ id: 'rec-a' }));
await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, { await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{
transport, transport,
createdAt: 1000, createdAt: 1000,
}) },
);
// device B restores, then marks the same note spent under its OWN // device B restores, then marks the same note spent under its OWN
// record id, and republishes its full store // record id, and republishes its full store
stubLocalStorage() stubLocalStorage();
await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000})) await persistBearer(aesKey, bearerFixture({ id: 'rec-b', spent: true, updatedAt: 2000 }));
await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, { await publishBackup(
secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS,
{
transport, transport,
createdAt: 2000, createdAt: 2000,
}) },
);
// device C restores from the final published state // device C restores from the final published state
stubLocalStorage() stubLocalStorage();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
// union by record id: both copies landed // union by record id: both copies landed
expect(result.added).toBe(2) expect(result.added).toBe(2);
expect( expect(
readEncryptedBearers() readEncryptedBearers()
.map((r) => r.id) .map((r) => r.id)
.sort(), .sort(),
).toEqual(['rec-a', 'rec-b']) ).toEqual(['rec-a', 'rec-b']);
// after decrypt, the note-level merge (same server + k1) collapses // after decrypt, the note-level merge (same server + k1) collapses
// them, and the spent copy wins even though its record is the newer // them, and the spent copy wins even though its record is the newer
// arrival - a restored backup must never resurrect spendable money // arrival - a restored backup must never resurrect spendable money
const merged = mergeBearers([], await loadBearers(aesKey)) const merged = mergeBearers([], await loadBearers(aesKey));
expect(merged).toHaveLength(1) expect(merged).toHaveLength(1);
expect(requiredValue(merged[0]).id).toBe('rec-b') expect(requiredValue(merged[0]).id).toBe('rec-b');
expect(requiredValue(merged[0]).spent).toBe(true) expect(requiredValue(merged[0]).spent).toBe(true);
}) });
it('never overwrites local state: records union, settings keep local values', async () => { it('never overwrites local state: records union, settings keep local values', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY) const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
await persistBearer(aesKey, bearerFixture({id: 'remote'})) await persistBearer(aesKey, bearerFixture({ id: 'remote' }));
persistSettings({defaultMint: 'remote.example'}) persistSettings({ defaultMint: 'remote.example' });
await publishBackup( await publishBackup(
secretKey, secretKey,
{notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}, settings: loadSettings()}, {
notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() },
settings: loadSettings(),
},
RELAYS, RELAYS,
{ transport, createdAt: 1000 }, { transport, createdAt: 1000 },
) );
// this device already has its own wallet state // this device already has its own wallet state
stubLocalStorage() stubLocalStorage();
await persistBearer( await persistBearer(
aesKey, aesKey,
bearerFixture({ id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }), bearerFixture({ id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }),
) );
persistSettings({defaultMint: 'local.example'}) persistSettings({ defaultMint: 'local.example' });
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.added).toBe(1) expect(result.added).toBe(1);
expect( expect(
readEncryptedBearers() readEncryptedBearers()
.map((r) => r.id) .map((r) => r.id)
.sort(), .sort(),
).toEqual(['local', 'remote']) ).toEqual(['local', 'remote']);
expect(result.settingsRestored).toBe(false) expect(result.settingsRestored).toBe(false);
expect(loadSettings()).toEqual({defaultMint: 'local.example'}) expect(loadSettings()).toEqual({ defaultMint: 'local.example' });
}) });
it('reports nothing found when the relays hold no backup', async () => { it('reports nothing found when the relays hold no backup', async () => {
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual([]) expect(result.found).toEqual([]);
expect(result.added).toBe(0) expect(result.added).toBe(0);
}) });
it('merges relayed counters upward-only and never carries pending state', async () => { it('merges relayed counters upward-only and never carries pending state', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY) const aesKey = await deriveBearerAesKey(LINKING_KEY);
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
// device A: counter at 5 for mint.example, one note, all published // device A: counter at 5 for mint.example, one note, all published
await persistBearer(aesKey, bearerFixture({id: 'note-a'})) await persistBearer(aesKey, bearerFixture({ id: 'note-a' }));
writeFundsDocument({ writeFundsDocument({
version: 2, version: 2,
bearers: readEncryptedBearers(), bearers: readEncryptedBearers(),
pending: [ pending: [
{id: 'device-local-pending', kind: 'cash-allocation', phase: 'reserved', iv: '00', ciphertext: '00'}, {
id: 'device-local-pending',
kind: 'cash-allocation',
phase: 'reserved',
iv: '00',
ciphertext: '00',
},
], ],
nextByHost: { 'mint.example': 5 }, nextByHost: { 'mint.example': 5 },
revision: 3, revision: 3,
}) });
await publishBackup( await publishBackup(
secretKey, secretKey,
{ notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } }, { notes: { bearers: readEncryptedBearers(), nextByHost: readNextByHost() } },
RELAYS, RELAYS,
{ transport, createdAt: 1000 }, { transport, createdAt: 1000 },
) );
// device B: same seed, a HIGHER local counter for one host and a lower // device B: same seed, a HIGHER local counter for one host and a lower
// incoming one for the other; the pending record must not cross over // incoming one for the other; the pending record must not cross over
stubLocalStorage() stubLocalStorage();
writeFundsDocument({ writeFundsDocument({
version: 2, version: 2,
bearers: [], bearers: [],
pending: [], pending: [],
nextByHost: { 'mint.example': 2, 'local-only.example': 7 }, nextByHost: { 'mint.example': 2, 'local-only.example': 7 },
revision: 1, revision: 1,
}) });
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, { transport });
expect(result.found).toEqual(['notes']) expect(result.found).toEqual(['notes']);
expect(result.added).toBe(1) expect(result.added).toBe(1);
expect(readFundsDocument().nextByHost).toEqual({ expect(readFundsDocument().nextByHost).toEqual({
'mint.example': 5, 'mint.example': 5,
'local-only.example': 7, 'local-only.example': 7,
}) });
expect(readFundsDocument().pending).toEqual([]) expect(readFundsDocument().pending).toEqual([]);
expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})]) expect(await loadBearers(aesKey)).toEqual([bearerFixture({ id: 'note-a' })]);
}) });
}) });
+5 -5
View File
@@ -1,5 +1,5 @@
import './nostrBackup.keys.cases' import './nostrBackup.keys.cases';
import './nostrBackup.codec.cases' import './nostrBackup.codec.cases';
import './nostrBackup.transport.cases' import './nostrBackup.transport.cases';
import './nostrBackup.restore.cases' import './nostrBackup.restore.cases';
import './nostrBackup.publisher.cases' import './nostrBackup.publisher.cases';
+61 -61
View File
@@ -2,14 +2,14 @@
// tamper rejection, publish/fetch and restore against an in-memory relay // tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher. // (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {NostrEvent} from 'nostr-tools/core' import type { NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import { deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey } from './keys';
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -21,8 +21,8 @@ import {
parseBackupEvent, parseBackupEvent,
publishBackup, publishBackup,
restoreFromNostr, restoreFromNostr,
} from './nostrBackup' } from './nostrBackup';
import type {BackupPartPayload, BackupTransport} from './nostrBackup' import type { BackupPartPayload, BackupTransport } from './nostrBackup';
import { import {
loadBearers, loadBearers,
loadSettings, loadSettings,
@@ -30,21 +30,21 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {Bearer} from './types' import type { Bearer } from './types';
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import { addTrustedMint, isMintUnconfirmed, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const MINT_PUBKEY = 'ab'.repeat(33) const MINT_PUBKEY = 'ab'.repeat(33);
// never connected - the recording transport below stands in for the relays // never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'];
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture', id: 'fixture',
@@ -55,20 +55,20 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
// an in-memory relay set. It serves EVERY event it ever accepted, older // an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which // addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for // is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): { const createRecordingTransport = (): {
transport: BackupTransport transport: BackupTransport;
events: NostrEvent[] events: NostrEvent[];
} => { } => {
const events: NostrEvent[] = [] const events: NostrEvent[] = [];
const transport: BackupTransport = { const transport: BackupTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
events.push(event) events.push(event);
return Promise.resolve() return Promise.resolve();
}, },
fetch: (_relays, filter) => fetch: (_relays, filter) =>
Promise.resolve( Promise.resolve(
@@ -78,27 +78,27 @@ const createRecordingTransport = (): {
(!filter.authors || filter.authors.includes(e.pubkey)), (!filter.authors || filter.authors.includes(e.pubkey)),
), ),
), ),
} };
return {transport, events} return { transport, events };
} };
// flips the end of a base64 payload to different-but-valid characters // flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string => const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA');
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
describe('publishBackup / fetchBackup', () => { describe('publishBackup / fetchBackup', () => {
const secretKey = deriveBackupKey(LINKING_KEY) const secretKey = deriveBackupKey(LINKING_KEY);
it('publishes every part and fetches them back decrypted', async () => { it('publishes every part and fetches them back decrypted', async () => {
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
const parts = { const parts = {
notes: { notes: {
bearers: [{ id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }], bearers: [{ id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }],
@@ -106,58 +106,58 @@ describe('publishBackup / fetchBackup', () => {
}, },
mints: [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false }], mints: [{ server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false }],
settings: { defaultMint: 'mint.example' }, settings: { defaultMint: 'mint.example' },
} };
const published = await publishBackup(secretKey, parts, RELAYS, {transport}) const published = await publishBackup(secretKey, parts, RELAYS, { transport });
expect(published.published).toEqual(['notes', 'mints', 'settings']) expect(published.published).toEqual(['notes', 'mints', 'settings']);
const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, { const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey, secretKey,
transport, transport,
}) });
expect(fetched).toEqual(parts) expect(fetched).toEqual(parts);
}) });
it('publishes nothing when no parts are given', async () => { it('publishes nothing when no parts are given', async () => {
const {transport, events} = createRecordingTransport() const { transport, events } = createRecordingTransport();
const result = await publishBackup(secretKey, {}, RELAYS, {transport}) const result = await publishBackup(secretKey, {}, RELAYS, { transport });
expect(result.published).toEqual([]) expect(result.published).toEqual([]);
expect(events).toEqual([]) expect(events).toEqual([]);
}) });
it('picks the newest event per d-tag when a relay serves stale copies', async () => { it('picks the newest event per d-tag when a relay serves stale copies', async () => {
const {transport} = createRecordingTransport() const { transport } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, { await publishBackup(secretKey, { settings: { defaultMint: 'old.example' } }, RELAYS, {
transport, transport,
createdAt: 1000, createdAt: 1000,
}) });
await publishBackup(secretKey, { settings: { defaultMint: 'new.example' } }, RELAYS, { await publishBackup(secretKey, { settings: { defaultMint: 'new.example' } }, RELAYS, {
transport, transport,
createdAt: 2000, createdAt: 2000,
}) });
// the recording transport serves BOTH - the newer must win // the recording transport serves BOTH - the newer must win
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey, secretKey,
transport, transport,
}) });
expect(parts.settings).toEqual({defaultMint: 'new.example'}) expect(parts.settings).toEqual({ defaultMint: 'new.example' });
}) });
it('falls back to an older valid copy when the newest event is tampered', async () => { it('falls back to an older valid copy when the newest event is tampered', async () => {
const {transport, events} = createRecordingTransport() const { transport, events } = createRecordingTransport();
await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, { await publishBackup(secretKey, { settings: { defaultMint: 'mint.example' } }, RELAYS, {
transport, transport,
createdAt: 1000, createdAt: 1000,
}) });
const latest = requiredValue(events[0]) const latest = requiredValue(events[0]);
events.push({ events.push({
...latest, ...latest,
content: tamperContent(latest.content), content: tamperContent(latest.content),
created_at: 3000, created_at: 3000,
}) });
const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, {
secretKey, secretKey,
transport, transport,
}) });
expect(parts.settings).toEqual({defaultMint: 'mint.example'}) expect(parts.settings).toEqual({ defaultMint: 'mint.example' });
}) });
}) });
+7 -7
View File
@@ -25,18 +25,18 @@ export {
buildBackupEvent, buildBackupEvent,
buildBackupEvents, buildBackupEvents,
parseBackupEvent, parseBackupEvent,
} from './nostr/events' } from './nostr/events';
export type {NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent} from './nostr/events' export type { NostrEvent, BackupPart, BackupPartPayload, ParsedBackupEvent } from './nostr/events';
export type {BackupTransport, NostrFilter} from './nostr/transport' export type { BackupTransport, NostrFilter } from './nostr/transport';
export {publishBackup, fetchBackup, restoreFromNostr} from './nostr/sync' export { publishBackup, fetchBackup, restoreFromNostr } from './nostr/sync';
export type { export type {
PublishBackupOptions, PublishBackupOptions,
PublishBackupResult, PublishBackupResult,
FetchBackupOptions, FetchBackupOptions,
NostrRestoreResult, NostrRestoreResult,
} from './nostr/sync' } from './nostr/sync';
export {createBackupPublisher} from './nostr/publisher' export { createBackupPublisher } from './nostr/publisher';
export type {BackupPublisher, BackupPublisherOptions} from './nostr/publisher' export type { BackupPublisher, BackupPublisherOptions } from './nostr/publisher';
+46 -46
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,90 +55,90 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('connection strings', () => { describe('connection strings', () => {
it('round-trips build -> parse, including several relays', () => { it('round-trips build -> parse, including several relays', () => {
const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [ const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [
'wss://relay-a.example', 'wss://relay-a.example',
'wss://relay-b.example/path?q=1', 'wss://relay-b.example/path?q=1',
]) ]);
expect(uri).toBe( expect(uri).toBe(
`nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`, `nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`,
) );
expect(parseConnectionString(uri)).toEqual({ expect(parseConnectionString(uri)).toEqual({
walletServicePubkey: 'ab'.repeat(32), walletServicePubkey: 'ab'.repeat(32),
clientSecret: 'cd'.repeat(32), clientSecret: 'cd'.repeat(32),
relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'], relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'],
}) });
}) });
it('rejects strings that are not connection strings', () => { it('rejects strings that are not connection strings', () => {
expect(parseConnectionString('not a uri')).toBeNull() expect(parseConnectionString('not a uri')).toBeNull();
expect(parseConnectionString('https://example.com')).toBeNull() expect(parseConnectionString('https://example.com')).toBeNull();
// missing secret // missing secret
expect( expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`), parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`),
).toBeNull() ).toBeNull();
// missing relay // missing relay
expect( expect(
parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`), parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`),
).toBeNull() ).toBeNull();
// a non-hex pubkey // a non-hex pubkey
expect( expect(
parseConnectionString( parseConnectionString(
'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32), 'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32),
), ),
).toBeNull() ).toBeNull();
}) });
it('createConnection returns a string that parses back to the same connection', () => { it('createConnection returns a string that parses back to the same connection', () => {
const connection = createConnection(LINKING_KEY, { const connection = createConnection(LINKING_KEY, {
relays: RELAYS, relays: RELAYS,
budget: { maxMsat: 100_000, periodMs: 86_400_000 }, budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
}) });
const parsed = parseConnectionString(connection.connectionString) const parsed = parseConnectionString(connection.connectionString);
expect(parsed).toEqual({ expect(parsed).toEqual({
walletServicePubkey: connection.walletServicePubkey, walletServicePubkey: connection.walletServicePubkey,
clientSecret: '11'.repeat(32), clientSecret: '11'.repeat(32),
relays: RELAYS, relays: RELAYS,
}) });
// the record persisted WITHOUT the client secret - it is handed out // the record persisted WITHOUT the client secret - it is handed out
// once, in the connection string, and never stored // once, in the connection string, and never stored
const records = readNwcConnections(OWNER_ID) const records = readNwcConnections(OWNER_ID);
expect(records).toHaveLength(1) expect(records).toHaveLength(1);
expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY) expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY);
expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32)) expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32));
}) });
}) });
describe('deriveNwcWalletKey', () => { describe('deriveNwcWalletKey', () => {
it('is pinned: derivation changes would silently orphan every connection', () => { it('is pinned: derivation changes would silently orphan every connection', () => {
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d', '71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d',
) );
expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
) );
}) });
it('derives a distinct key per client and per linking key', () => { it('derives a distinct key per client and per linking key', () => {
const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY)) const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY));
expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe( expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe(
base, base,
) );
expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base) expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base);
}) });
it('re-derives the same wallet identity from a persisted record after a reinstall', () => { it('re-derives the same wallet identity from a persisted record after a reinstall', () => {
const first = createConnection(LINKING_KEY, { const first = createConnection(LINKING_KEY, {
relays: RELAYS, relays: RELAYS,
budget: { maxMsat: 100_000, periodMs: 86_400_000 }, budget: { maxMsat: 100_000, periodMs: 86_400_000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
}) });
expect(first.walletServicePubkey).toBe( expect(first.walletServicePubkey).toBe(
'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf',
) );
}) });
}) });
+24 -24
View File
@@ -4,29 +4,29 @@
// no longer possible. What must not happen is any stale-owner WRITE: // no longer possible. What must not happen is any stale-owner WRITE:
// no bearer changeset, no budget debit, no success response. // no bearer changeset, no budget debit, no success response.
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import {readNwcConnections} from './nwc' import { readNwcConnections } from './nwc';
import { import {
methodRequest, methodRequest,
OTHER_LINKING_KEY, OTHER_LINKING_KEY,
OTHER_OWNER_ID, OTHER_OWNER_ID,
OWNER_ID, OWNER_ID,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: post-boundary stale-owner fencing', () => { describe('service: post-boundary stale-owner fencing', () => {
it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => { it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => {
// Given a running service whose next mint call coincides with a second // Given a running service whose next mint call coincides with a second
// tab installing its own wallet (the first fetch is the melt itself: // tab installing its own wallet (the first fetch is the melt itself:
// the exact-match carve performs no mint call of its own) // the exact-match carve performs no mint call of its own)
const m = await mint() const m = await mint();
let swapped = false let swapped = false;
const swappingFetch: typeof fetch = (input, init) => { const swappingFetch: typeof fetch = (input, init) => {
if (!swapped) { if (!swapped) {
swapped = true swapped = true;
localStorage.setItem( localStorage.setItem(
'sattle_linking_key', 'sattle_linking_key',
JSON.stringify({ JSON.stringify({
@@ -35,32 +35,32 @@ describe('service: post-boundary stale-owner fencing', () => {
ownerId: OTHER_OWNER_ID, ownerId: OTHER_OWNER_ID,
version: 1, version: 1,
}), }),
) );
}
return fetch(input, init)
} }
return fetch(input, init);
};
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
kit: { fetch: swappingFetch }, kit: { fetch: swappingFetch },
}) });
state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
// When the pay flows past the irreversible boundary and reaches the // When the pay flows past the irreversible boundary and reaches the
// first owner-bound persistence (the conservative budget debit) // first owner-bound persistence (the conservative budget debit)
relay.emit(request) relay.emit(request);
await waitFor(() => state.errors.length > 0) await waitFor(() => state.errors.length > 0);
// Then the melt genuinely happened (we are past the boundary) ... // Then the melt genuinely happened (we are past the boundary) ...
expect(swapped).toBe(true) expect(swapped).toBe(true);
expect(m.state.noteState('d7'.repeat(32))).toBe('burned') expect(m.state.noteState('d7'.repeat(32))).toBe('burned');
// ... but nothing under the stale owner moved: no budget debit, no // ... but nothing under the stale owner moved: no budget debit, no
// bearer changeset, no success response (the failure surfaced through // bearer changeset, no success response (the failure surfaced through
// onError instead) // onError instead)
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0) expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0);
expect(state.changesets).toEqual([]) expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
await stop() await stop();
}) });
}) });
+109 -109
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, hashK1, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredString, requiredValue, stubLocalStorage} from './test-utils' import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,36 +55,36 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
import {invoiceRetireAfter} from './nwc/invoices' import { invoiceRetireAfter } from './nwc/invoices';
export const SIGNED_BOLT11_FIXTURE = export const SIGNED_BOLT11_FIXTURE =
'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz' 'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz';
describe('service: make_invoice / lookup_invoice', () => { describe('service: make_invoice / lookup_invoice', () => {
it('derives a safe retirement time from a signed BOLT11 invoice', () => { it('derives a safe retirement time from a signed BOLT11 invoice', () => {
expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0) expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0);
}) });
it('rejects a retirement time outside the safe integer range', () => { it('rejects a retirement time outside the safe integer range', () => {
const timestamp = 'q'.repeat(7) const timestamp = 'q'.repeat(7);
const expiryTag = `xqv${'l'.repeat(12)}` const expiryTag = `xqv${'l'.repeat(12)}`;
expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull() expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull();
}) });
it('stages an output, settles it in the background, and reports the preimage', async () => { it('stages an output, settles it in the background, and reports the preimage', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
description: 'nwc test', description: 'nwc test',
expiry: 3600, expiry: 3600,
}) });
expect(made.error).toBeNull() expect(made.error).toBeNull();
expect(made.result).toMatchObject({ expect(made.result).toMatchObject({
type: 'incoming', type: 'incoming',
state: 'pending', state: 'pending',
@@ -92,131 +92,131 @@ describe('service: make_invoice / lookup_invoice', () => {
description: 'nwc test', description: 'nwc test',
created_at: nowSeconds(), created_at: nowSeconds(),
expires_at: nowSeconds() + 3600, expires_at: nowSeconds() + 3600,
}) });
const invoice = requiredValue(made.result).invoice const invoice = requiredValue(made.result).invoice;
if (typeof invoice !== 'string') { if (typeof invoice !== 'string') {
throw new TypeError('make_invoice did not return an invoice') throw new TypeError('make_invoice did not return an invoice');
} }
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash') throw new TypeError('make_invoice did not return a payment hash');
} }
expect(invoice).toMatch(/^lnbc/) expect(invoice).toMatch(/^lnbc/);
expect(paymentHash).toMatch(/^[0-9a-f]{64}$/) expect(paymentHash).toMatch(/^[0-9a-f]{64}$/);
const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-'))) const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-')));
expect(staged.verified).toBe(false) expect(staged.verified).toBe(false);
expect(staged.callback).toBe('') expect(staged.callback).toBe('');
// before settlement the lookup reports the pending invoice // before settlement the lookup reports the pending invoice
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(pending.error).toBeNull() expect(pending.error).toBeNull();
expect(pending.result?.state).toBe('pending') expect(pending.result?.state).toBe('pending');
expect(pending.result?.preimage).toBeUndefined() expect(pending.result?.preimage).toBeUndefined();
// the "payer" pays the invoice; the background claim settles and // the "payer" pays the invoice; the background claim settles and
// mints the note // mints the note
const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleRes.ok).toBe(true) expect(settleRes.ok).toBe(true);
await waitFor(() => state.bearers.some((bearer) => bearer.verified)) await waitFor(() => state.bearers.some((bearer) => bearer.verified));
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(settled.error).toBeNull() expect(settled.error).toBeNull();
expect(settled.result?.state).toBe('settled') expect(settled.result?.state).toBe('settled');
expect(settled.result?.settled_at).toBe(nowSeconds()) expect(settled.result?.settled_at).toBe(nowSeconds());
const preimage = requiredString(settled.result?.preimage) const preimage = requiredString(settled.result?.preimage);
expect(preimage).toMatch(/^[0-9a-f]{64}$/) expect(preimage).toMatch(/^[0-9a-f]{64}$/);
// The staged record was updated in place before settlement became // The staged record was updated in place before settlement became
// visible. The payment preimage is only a receipt and keys no note. // visible. The payment preimage is only a receipt and keys no note.
expect(m.state.noteState(preimage)).toBeNull() expect(m.state.noteState(preimage)).toBeNull();
const minted = requiredValue(state.bearers.find((b) => b.id === staged.id)) const minted = requiredValue(state.bearers.find((b) => b.id === staged.id));
expect(minted.amount).toBe(21_000) expect(minted.amount).toBe(21_000);
expect(minted.verified).toBe(true) expect(minted.verified).toBe(true);
expect(noteK1(minted.url)).not.toBe(preimage) expect(noteK1(minted.url)).not.toBe(preimage);
expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding') expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding');
await stop() await stop();
}) });
it('keeps a paid invoice pending while its bearer commit is deferred', async () => { it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const commit = deferred() const commit = deferred();
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash') throw new TypeError('make_invoice did not return a payment hash');
} }
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true) expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { const pending = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(pending.result?.state).toBe('pending') expect(pending.result?.state).toBe('pending');
expect(pending.result?.settled_at).toBeUndefined() expect(pending.result?.settled_at).toBeUndefined();
expect(pending.result?.preimage).toBeUndefined() expect(pending.result?.preimage).toBeUndefined();
commit.resolve() commit.resolve();
await waitFor(() => state.changesets.length === 1) await waitFor(() => state.changesets.length === 1);
const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { const settled = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(settled.result?.state).toBe('settled') expect(settled.result?.state).toBe('settled');
expect(settled.result?.settled_at).toBe(nowSeconds()) expect(settled.result?.settled_at).toBe(nowSeconds());
expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/) expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/);
await stop() await stop();
}) });
it('keeps repeated stops pending until an already-started invoice settlement commits', async () => { it('keeps repeated stops pending until an already-started invoice settlement commits', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const commit = deferred() const commit = deferred();
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash') throw new TypeError('make_invoice did not return a payment hash');
} }
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true) expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
let stopped = false let stopped = false;
const firstStop = stop().then(() => { const firstStop = stop().then(() => {
stopped = true stopped = true;
return state.changesets.length return state.changesets.length;
}) });
const repeatedStop = stop() const repeatedStop = stop();
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false) expect(stopped).toBe(false);
expect(state.changesets).toHaveLength(0) expect(state.changesets).toHaveLength(0);
commit.resolve() commit.resolve();
const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]) const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]);
expect(changesetsAtStop).toBe(1) expect(changesetsAtStop).toBe(1);
expect(state.changesets).toHaveLength(1) expect(state.changesets).toHaveLength(1);
}) });
}) });
+150 -158
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,186 +55,186 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
import {SIGNED_BOLT11_FIXTURE} from './nwc.invoice-a.cases' import { SIGNED_BOLT11_FIXTURE } from './nwc.invoice-a.cases';
describe('service: make_invoice / lookup_invoice (continued)', () => { describe('service: make_invoice / lookup_invoice (continued)', () => {
it('drains a rejected invoice settlement and reports it before stop resolves', async () => { it('drains a rejected invoice settlement and reports it before stop resolves', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const commit = deferred() const commit = deferred();
const commitError = new Error('invoice commit rejected during stop') const commitError = new Error('invoice commit rejected during stop');
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash') throw new TypeError('make_invoice did not return a payment hash');
} }
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true) expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
let stopped = false let stopped = false;
const stopping = stop().then(() => { const stopping = stop().then(() => {
stopped = true stopped = true;
return state.errors.length return state.errors.length;
}) });
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false) expect(stopped).toBe(false);
commit.reject(commitError) commit.reject(commitError);
expect(await stopping).toBe(1) expect(await stopping).toBe(1);
expect(state.errors).toEqual([commitError]) expect(state.errors).toEqual([commitError]);
expect(state.changesets).toHaveLength(0) expect(state.changesets).toHaveLength(0);
await stop() await stop();
}) });
it('does not block stop on an invoice whose claim is still polling', async () => { it('does not block stop on an invoice whose claim is still polling', async () => {
// an unpaid invoice's claim poll can legally run for minutes (the // an unpaid invoice's claim poll can legally run for minutes (the
// client pays whenever it pays) - stop must interrupt the wait, not // client pays whenever it pays) - stop must interrupt the wait, not
// sit on it; a settlement that already REACHED the commit phase is // sit on it; a settlement that already REACHED the commit phase is
// still awaited (see the drain tests above) // still awaited (see the drain tests above)
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: { intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000 }, claimPoll: { intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000 },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000, amount: 5_000,
}) });
expect(made.error).toBeNull() expect(made.error).toBeNull();
// nobody pays the invoice; the claim keeps polling. stop must resolve // nobody pays the invoice; the claim keeps polling. stop must resolve
// promptly regardless (an un-interrupted stop would wait out the // promptly regardless (an un-interrupted stop would wait out the
// whole 60s claim budget) // whole 60s claim budget)
await stop() await stop();
expect(state.changesets).toHaveLength(0) expect(state.changesets).toHaveLength(0);
expect(state.errors).toHaveLength(0) expect(state.errors).toHaveLength(0);
}) });
it('does not start invoice settlement after stop begins during preparation', async () => { it('does not start invoice settlement after stop begins during preparation', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const prepare = deferred() const prepare = deferred();
let prepareStarted = false let prepareStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
kit: { kit: {
fetch: async (input, init) => { fetch: async (input, init) => {
prepareStarted = true prepareStarted = true;
await prepare.promise await prepare.promise;
return fetch(input, init) return fetch(input, init);
}, },
}, },
}) });
const request = methodRequest(walletServicePubkey, 'make_invoice', { const request = methodRequest(walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
relay.emit(request) relay.emit(request);
await waitFor(() => prepareStarted) await waitFor(() => prepareStarted);
let stopped = false let stopped = false;
const stopping = stop().then(() => { const stopping = stop().then(() => {
stopped = true stopped = true;
}) });
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
expect(stopped).toBe(false) expect(stopped).toBe(false);
prepare.resolve() prepare.resolve();
await stopping await stopping;
expect(state.changesets).toHaveLength(0) expect(state.changesets).toHaveLength(0);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL') expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
}) });
it('marks a paid invoice failed when its bearer commit rejects', async () => { it('marks a paid invoice failed when its bearer commit rejects', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const commit = deferred() const commit = deferred();
const commitError = new Error('invoice bearer commit failed') const commitError = new Error('invoice bearer commit failed');
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
const paymentHash = made.result?.payment_hash const paymentHash = made.result?.payment_hash;
if (typeof paymentHash !== 'string') { if (typeof paymentHash !== 'string') {
throw new TypeError('make_invoice did not return a payment hash') throw new TypeError('make_invoice did not return a payment hash');
} }
const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) const settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`);
expect(settleResponse.ok).toBe(true) expect(settleResponse.ok).toBe(true);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
commit.reject(commitError) commit.reject(commitError);
await waitFor(() => state.errors.length === 1) await waitFor(() => state.errors.length === 1);
const failed = await call(relay, walletServicePubkey, 'lookup_invoice', { const failed = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: paymentHash, payment_hash: paymentHash,
}) });
expect(failed.result?.state).toBe('failed') expect(failed.result?.state).toBe('failed');
expect(failed.result?.settled_at).toBeUndefined() expect(failed.result?.settled_at).toBeUndefined();
expect(failed.result?.preimage).toBeUndefined() expect(failed.result?.preimage).toBeUndefined();
expect(state.errors).toEqual([commitError]) expect(state.errors).toEqual([commitError]);
await stop() await stop();
}) });
it('finds an invoice by its invoice string too', async () => { it('finds an invoice by its invoice string too', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, stop } = await startTestService({ const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
claimPoll: { intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10 }, claimPoll: { intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10 },
}) });
const made = await call(relay, walletServicePubkey, 'make_invoice', { const made = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 5_000, amount: 5_000,
}) });
const invoice = made.result?.invoice const invoice = made.result?.invoice;
if (typeof invoice !== 'string') { if (typeof invoice !== 'string') {
throw new TypeError('make_invoice did not return an invoice') throw new TypeError('make_invoice did not return an invoice');
} }
const found = await call(relay, walletServicePubkey, 'lookup_invoice', { const found = await call(relay, walletServicePubkey, 'lookup_invoice', {
invoice: invoice.toUpperCase(), invoice: invoice.toUpperCase(),
}) });
expect(found.error).toBeNull() expect(found.error).toBeNull();
expect(found.result?.payment_hash).toBe(made.result?.payment_hash) expect(found.result?.payment_hash).toBe(made.result?.payment_hash);
await stop() await stop();
}) });
it('answers an unknown invoice with NOT_FOUND', async () => { it('answers an unknown invoice with NOT_FOUND', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'lookup_invoice', { const response = await call(relay, walletServicePubkey, 'lookup_invoice', {
payment_hash: 'ab'.repeat(32), payment_hash: 'ab'.repeat(32),
}) });
expect(response.error?.code).toBe('NOT_FOUND') expect(response.error?.code).toBe('NOT_FOUND');
await stop() await stop();
}) });
it('answers make_invoice without a default mint with INTERNAL', async () => { it('answers make_invoice without a default mint with INTERNAL', async () => {
const { relay, walletServicePubkey, stop } = await startTestService({ const { relay, walletServicePubkey, stop } = await startTestService({
defaultMint: null, defaultMint: null,
}) });
const response = await call(relay, walletServicePubkey, 'make_invoice', { const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
expect(response.error?.code).toBe('INTERNAL') expect(response.error?.code).toBe('INTERNAL');
await stop() await stop();
}) });
it('rejects invoice creation when the durable pending-output cap is reached', async () => { it('rejects invoice creation when the durable pending-output cap is reached', async () => {
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: 'mint@example.com', defaultMint: 'mint@example.com',
}) });
for (let index = 0; index < 20; index += 1) { for (let index = 0; index < 20; index += 1) {
state.bearers.push({ state.bearers.push({
id: `pending-${index}`, id: `pending-${index}`,
@@ -245,91 +245,83 @@ describe('service: make_invoice / lookup_invoice (continued)', () => {
pendingMint: {}, pendingMint: {},
createdAt: index, createdAt: index,
updatedAt: index, updatedAt: index,
}) });
} }
const response = await call(relay, walletServicePubkey, 'make_invoice', { const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: 21_000, amount: 21_000,
}) });
expect(response.error?.code).toBe('QUOTA_EXCEEDED') expect(response.error?.code).toBe('QUOTA_EXCEEDED');
expect(state.bearers).toHaveLength(20) expect(state.bearers).toHaveLength(20);
await stop() await stop();
}) });
it('removes staged outputs when invoice preparation fails before publication', async () => { it('removes staged outputs when invoice preparation fails before publication', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
let rejectQuotes = true let rejectQuotes = true;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
kit: { kit: {
fetch: (input, init) => { fetch: (input, init) => {
const url = const url =
typeof input === 'string' typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
? input
: input instanceof URL
? input.href
: input.url
if (rejectQuotes && url.includes('comment=')) { if (rejectQuotes && url.includes('comment=')) {
return Promise.resolve(new Response('quote failed', {status: 500})) return Promise.resolve(new Response('quote failed', { status: 500 }));
} }
return fetch(input, init) return fetch(input, init);
}, },
}, },
}) });
for (let attempt = 0; attempt < 20; attempt += 1) { for (let attempt = 0; attempt < 20; attempt += 1) {
const failed = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000}) const failed = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
expect(failed.error?.code).toBe('INTERNAL') expect(failed.error?.code).toBe('INTERNAL');
expect(state.bearers).toHaveLength(0) expect(state.bearers).toHaveLength(0);
} }
rejectQuotes = false rejectQuotes = false;
const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000}) const made = await call(relay, walletServicePubkey, 'make_invoice', { amount: 21_000 });
expect(made.error).toBeNull() expect(made.error).toBeNull();
expect(state.bearers).toHaveLength(1) expect(state.bearers).toHaveLength(1);
await stop() await stop();
}) });
it('removes an unpublished stage when retirement persistence fails', async () => { it('removes an unpublished stage when retirement persistence fails', async () => {
const m = await mint({testHooks: true}) const m = await mint({ testHooks: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
defaultMint: `mint@127.0.0.1:${m.port}`, defaultMint: `mint@127.0.0.1:${m.port}`,
setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')), setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')),
kit: { kit: {
fetch: (input, init) => { fetch: (input, init) => {
const url = const url =
typeof input === 'string' typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
? input
: input instanceof URL
? input.href
: input.url
if (url.includes('comment=')) { if (url.includes('comment=')) {
return Promise.resolve( return Promise.resolve(
new Response(JSON.stringify({ pr: SIGNED_BOLT11_FIXTURE }), { new Response(JSON.stringify({ pr: SIGNED_BOLT11_FIXTURE }), {
status: 200, status: 200,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
}), }),
) );
} }
return fetch(input, init) return fetch(input, init);
}, },
}, },
}) });
const response = await call(relay, walletServicePubkey, 'make_invoice', {amount: 25_000}) const response = await call(relay, walletServicePubkey, 'make_invoice', { amount: 25_000 });
expect(response.error?.code).toBe('INTERNAL') expect(response.error?.code).toBe('INTERNAL');
expect(state.bearers).toHaveLength(0) expect(state.bearers).toHaveLength(0);
await stop() await stop();
}) });
it('answers a make_invoice with a bad amount with OTHER', async () => { it('answers a make_invoice with a bad amount with OTHER', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'make_invoice', { const response = await call(relay, walletServicePubkey, 'make_invoice', {
amount: -5, amount: -5,
}) });
expect(response.error?.code).toBe('OTHER') expect(response.error?.code).toBe('OTHER');
await stop() await stop();
}) });
}) });
+61 -61
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,8 +55,8 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service ownership', () => { describe('service ownership', () => {
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => { it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
const current = createConnection(LINKING_KEY, { const current = createConnection(LINKING_KEY, {
@@ -64,10 +64,10 @@ describe('service ownership', () => {
budget: { maxMsat: 1000, periodMs: 1000 }, budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
now: 0, now: 0,
}) });
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0) const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
storeForeignConnection(foreign.record) storeForeignConnection(foreign.record);
const relay = createFakeRelay() const relay = createFakeRelay();
const service = await startService(LINKING_KEY, { const service = await startService(LINKING_KEY, {
assertCurrentOwner: () => undefined, assertCurrentOwner: () => undefined,
@@ -87,14 +87,14 @@ describe('service ownership', () => {
applyChangeset: () => Promise.resolve(), applyChangeset: () => Promise.resolve(),
transport: relay.transport, transport: relay.transport,
nowSeconds, nowSeconds,
}) });
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]) expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
expect(relay.subscriptionCount()).toBe(1) expect(relay.subscriptionCount()).toBe(1);
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0) expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
expect(service.connections).not.toContainEqual(foreign) expect(service.connections).not.toContainEqual(foreign);
await service.stop() await service.stop();
}) });
it('ignores foreign records handed in through the records snapshot', async () => { it('ignores foreign records handed in through the records snapshot', async () => {
// the injected-records path bypasses storage, so the service's own // the injected-records path bypasses storage, so the service's own
@@ -105,9 +105,9 @@ describe('service ownership', () => {
budget: { maxMsat: 1000, periodMs: 1000 }, budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
now: 0, now: 0,
}) });
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0) const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 0);
const relay = createFakeRelay() const relay = createFakeRelay();
const service = await startService( const service = await startService(
LINKING_KEY, LINKING_KEY,
@@ -131,51 +131,51 @@ describe('service ownership', () => {
nowSeconds, nowSeconds,
}, },
[current.record, foreign.record], [current.record, foreign.record],
) );
expect(service.connections.map((connection) => connection.record)).toEqual([current.record]) expect(service.connections.map((connection) => connection.record)).toEqual([current.record]);
expect(relay.subscriptionCount()).toBe(1) expect(relay.subscriptionCount()).toBe(1);
// the foreign snapshot record must not be persisted for the new owner // the foreign snapshot record must not be persisted for the new owner
expect(readNwcConnections(OWNER_ID)).toEqual([current.record]) expect(readNwcConnections(OWNER_ID)).toEqual([current.record]);
await service.stop() await service.stop();
}) });
}) });
describe('service: info and balance', () => { describe('service: info and balance', () => {
it('publishes a kind-13194 info event on startup', async () => { it('publishes a kind-13194 info event on startup', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND)) const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND));
expect(info.pubkey).toBe(walletServicePubkey) expect(info.pubkey).toBe(walletServicePubkey);
expect(info.content).toContain('pay_invoice') expect(info.content).toContain('pay_invoice');
expect(info.content).toContain('make_invoice') expect(info.content).toContain('make_invoice');
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']) expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04']);
await stop() await stop();
}) });
it('answers get_info with the connection identity and method list', async () => { it('answers get_info with the connection identity and method list', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'get_info', {}) const response = await call(relay, walletServicePubkey, 'get_info', {});
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(response.result_type).toBe('get_info') expect(response.result_type).toBe('get_info');
expect(response.result).toMatchObject({ expect(response.result).toMatchObject({
alias: 'sattle', alias: 'sattle',
pubkey: walletServicePubkey, pubkey: walletServicePubkey,
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'], methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'],
}) });
await stop() await stop();
}) });
it('answers get_balance with the spendable total only', async () => { it('answers get_balance with the spendable total only', async () => {
const m = await mint() const m = await mint();
const {relay, walletServicePubkey, state, stop} = await startTestService({}) const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [ state.bearers = [
await makeBearer(m, 'aa'.repeat(32), 21_000), await makeBearer(m, 'aa'.repeat(32), 21_000),
await makeBearer(m, 'bb'.repeat(32), 5_000), await makeBearer(m, 'bb'.repeat(32), 5_000),
{ ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true }, { ...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true },
] ];
const response = await call(relay, walletServicePubkey, 'get_balance', {}) const response = await call(relay, walletServicePubkey, 'get_balance', {});
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(response.result).toEqual({balance: 26_000}) expect(response.result).toEqual({ balance: 26_000 });
await stop() await stop();
}) });
}) });
+114 -114
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredString, requiredValue, stubLocalStorage} from './test-utils' import { requiredString, requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,159 +55,159 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice', () => { describe('service: pay_invoice', () => {
it('applies the settled changeset before publishing success', async () => { it('applies the settled changeset before publishing success', async () => {
const m = await mint() const m = await mint();
const {relay, walletServicePubkey, state, stop} = await startTestService({}) const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(state.changesets).toHaveLength(1) expect(state.changesets).toHaveLength(1);
expect(state.bearers[0]?.spent).toBe(true) expect(state.bearers[0]?.spent).toBe(true);
await stop() await stop();
}) });
it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => { it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => {
const m = await mint() const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 50_000, budgetMsat: 50_000,
}) });
const foreign = foreignConnectionFixture( const foreign = foreignConnectionFixture(
{ maxMsat: 99_000, periodMs: 86_400_000 }, { maxMsat: 99_000, periodMs: 86_400_000 },
nowSeconds() * 1000, nowSeconds() * 1000,
) );
storeForeignConnection(foreign.record) storeForeignConnection(foreign.record);
state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(requiredString(response.result?.preimage)).toHaveLength(64) expect(requiredString(response.result?.preimage)).toHaveLength(64);
// the note is gone (melted) and locked spent via the changeset // the note is gone (melted) and locked spent via the changeset
expect(m.state.noteState('dd'.repeat(32))).toBe('burned') expect(m.state.noteState('dd'.repeat(32))).toBe('burned');
expect(requiredValue(state.bearers[0]).spent).toBe(true) expect(requiredValue(state.bearers[0]).spent).toBe(true);
// the spend was recorded against the budget, persisted // the spend was recorded against the budget, persisted
expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000) expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000);
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0) expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0);
await stop() await stop();
}) });
it('waits for a deferred bearer commit before publishing payment success', async () => { it('waits for a deferred bearer commit before publishing payment success', async () => {
const m = await mint() const m = await mint();
const commit = deferred() const commit = deferred();
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
relay.emit(request) relay.emit(request);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
// a non-awaiting engine publishes within milliseconds of the commit // a non-awaiting engine publishes within milliseconds of the commit
// call (one local verify re-read + encrypt); this window is far wider // call (one local verify re-read + encrypt); this window is far wider
// than that, so an early response here can only mean a missing barrier // than that, so an early response here can only mean a missing barrier
await new Promise((resolve) => setTimeout(resolve, 250)) await new Promise((resolve) => setTimeout(resolve, 250));
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
commit.resolve() commit.resolve();
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
await stop() await stop();
}) });
it('reports a rejected bearer commit without publishing payment success', async () => { it('reports a rejected bearer commit without publishing payment success', async () => {
const m = await mint() const m = await mint();
const commit = deferred() const commit = deferred();
const commitError = new Error('bearer commit failed') const commitError = new Error('bearer commit failed');
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
relay.emit(request) relay.emit(request);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
commit.reject(commitError) commit.reject(commitError);
await waitFor( await waitFor(
() => () =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null, state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
) );
expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull();
expect(state.errors).toEqual([commitError]) expect(state.errors).toEqual([commitError]);
// the conservative budget debit is persisted separately from bearer // the conservative budget debit is persisted separately from bearer
// storage and deliberately NOT rolled back: the budget was debited, // storage and deliberately NOT rolled back: the budget was debited,
// the bearer was never locked spent // the bearer was never locked spent
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
expect(state.bearers[0]?.spent).toBeUndefined() expect(state.bearers[0]?.spent).toBeUndefined();
await stop() await stop();
}) });
it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => { it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => {
const m = await mint() const m = await mint();
const commit = deferred() const commit = deferred();
let commitStarted = false let commitStarted = false;
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
commitChangeset: () => { commitChangeset: () => {
commitStarted = true commitStarted = true;
return commit.promise return commit.promise;
}, },
}) });
state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)];
const request = methodRequest(walletServicePubkey, 'pay_invoice', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
relay.emit(request) relay.emit(request);
await waitFor(() => commitStarted) await waitFor(() => commitStarted);
// stop closes subscriptions; in-flight handlers still finish (their // stop closes subscriptions; in-flight handlers still finish (their
// changesets hold money) - a repeated stop interrupts nothing twice // changesets hold money) - a repeated stop interrupts nothing twice
let stopped = false let stopped = false;
const firstStop = stop().then(() => { const firstStop = stop().then(() => {
stopped = true stopped = true;
}) });
const repeatedStop = stop() const repeatedStop = stop();
await Promise.resolve() await Promise.resolve();
expect(stopped).toBe(false) expect(stopped).toBe(false);
// subscriptions close IMMEDIATELY, before the drain completes // subscriptions close IMMEDIATELY, before the drain completes
expect(relay.subscriptionCount()).toBe(0) expect(relay.subscriptionCount()).toBe(0);
const afterStop = methodRequest(walletServicePubkey, 'get_balance', {}) const afterStop = methodRequest(walletServicePubkey, 'get_balance', {});
relay.emitAfterClose(afterStop) relay.emitAfterClose(afterStop);
await new Promise((resolve) => setTimeout(resolve, 25)) await new Promise((resolve) => setTimeout(resolve, 25));
expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull() expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull();
commit.resolve() commit.resolve();
await Promise.all([firstStop, repeatedStop]) await Promise.all([firstStop, repeatedStop]);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull() expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull();
expect(state.bearers[0]?.spent).toBe(true) expect(state.bearers[0]?.spent).toBe(true);
}) });
it('rejects a stale saved owner before touching the mint', async () => { it('rejects a stale saved owner before touching the mint', async () => {
const m = await mint() const m = await mint();
const {relay, walletServicePubkey, state, stop} = await startTestService({}) const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)];
localStorage.setItem( localStorage.setItem(
'sattle_linking_key', 'sattle_linking_key',
JSON.stringify({ JSON.stringify({
@@ -216,20 +216,20 @@ describe('service: pay_invoice', () => {
ownerId: OTHER_OWNER_ID, ownerId: OTHER_OWNER_ID,
version: 1, version: 1,
}), }),
) );
const request = methodRequest(walletServicePubkey, 'pay_invoice', { const request = methodRequest(walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
relay.emit(request) relay.emit(request);
await waitFor( await waitFor(
() => () =>
state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null, state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null,
) );
expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding') expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding');
expect(state.changesets).toEqual([]) expect(state.changesets).toEqual([]);
expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL') expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL');
await stop() await stop();
}) });
}) });
+90 -80
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,121 +55,131 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: pay_invoice (continued)', () => { describe('service: pay_invoice (continued)', () => {
it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => { it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => {
const m = await mint() const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 20_000, budgetMsat: 20_000,
}) });
state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error?.code).toBe('QUOTA_EXCEEDED') expect(response.error?.code).toBe('QUOTA_EXCEEDED');
// nothing moved: the note is untouched, no spend recorded // nothing moved: the note is untouched, no spend recorded
expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding') expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding');
expect(requiredValue(state.bearers[0]).spent).toBeUndefined() expect(requiredValue(state.bearers[0]).spent).toBeUndefined();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
await stop() await stop();
}) });
it('resets the allowance once the budget period has rolled over', async () => { it('resets the allowance once the budget period has rolled over', async () => {
const m = await mint() const m = await mint();
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
budgetMsat: 21_000, budgetMsat: 21_000,
periodMs: 60_000, periodMs: 60_000,
}) });
// simulate a fully spent budget from a period that ended long ago // simulate a fully spent budget from a period that ended long ago
const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0]) const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0]);
writeNwcConnections(OWNER_ID, [ writeNwcConnections(OWNER_ID, [
{ ...record, spent: { periodStart: Date.now() - 120_000, msat: 21_000 } }, { ...record, spent: { periodStart: Date.now() - 120_000, msat: 21_000 } },
]) ]);
state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)] state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000);
await stop() await stop();
}) });
it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => { it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => {
const m = await mint() const m = await mint();
const {relay, walletServicePubkey, state, stop} = await startTestService({}) const { relay, walletServicePubkey, state, stop } = await startTestService({});
state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)] state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error?.code).toBe('INSUFFICIENT_BALANCE') expect(response.error?.code).toBe('INSUFFICIENT_BALANCE');
expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding') expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding');
await stop() await stop();
}) });
it('rejects a request amount that mismatches the invoice amount', async () => { it('rejects a request amount that mismatches the invoice amount', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
amount: 5_000, amount: 5_000,
}) });
expect(response.error?.code).toBe('OTHER') expect(response.error?.code).toBe('OTHER');
expect(response.error?.message).toMatch(/match/i) expect(response.error?.message).toMatch(/match/i);
await stop() await stop();
}) });
it('rejects an amount-less invoice instead of guessing', async () => { it('rejects an amount-less invoice instead of guessing', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc1pjqrstuvwxyz', invoice: 'lnbc1pjqrstuvwxyz',
}) });
expect(response.error?.code).toBe('OTHER') expect(response.error?.code).toBe('OTHER');
expect(response.error?.message).toMatch(/amount/i) expect(response.error?.message).toMatch(/amount/i);
await stop() await stop();
}) });
it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => { it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => {
const m = await mint({meltAlwaysFails: true}) const m = await mint({ meltAlwaysFails: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
// a short verify budget: the failed melt is classified by the poll // a short verify budget: the failed melt is classified by the poll
// running out, and that wait is the test's own clock // running out, and that wait is the test's own clock
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) });
const original = await makeBearer(m, '01'.repeat(32), 21_000) const original = await makeBearer(m, '01'.repeat(32), 21_000);
state.bearers = [original] state.bearers = [original];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error?.code).toBe('PAYMENT_FAILED') expect(response.error?.code).toBe('PAYMENT_FAILED');
expect(m.state.noteState('01'.repeat(32))).toBe('burned') expect(m.state.noteState('01'.repeat(32))).toBe('burned');
expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true) expect(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true);
expect( expect(
state.bearers.find((bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent), state.bearers.find(
).toBeDefined() (bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent,
expect(state.bearers.filter((bearer) => !bearer.spent).reduce((sum, bearer) => sum + bearer.amount, 0)).toBe(21_000) ),
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) ).toBeDefined();
await stop() expect(
}) state.bearers
.filter((bearer) => !bearer.spent)
.reduce((sum, bearer) => sum + bearer.amount, 0),
).toBe(21_000);
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0);
await stop();
});
it('conserves balance when a split payment fails and returns its carved funds', async () => { it('conserves balance when a split payment fails and returns its carved funds', async () => {
const m = await mint({meltAlwaysFails: true}) const m = await mint({ meltAlwaysFails: true });
const { relay, walletServicePubkey, state, stop } = await startTestService({ const { relay, walletServicePubkey, state, stop } = await startTestService({
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) });
state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)] state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)];
const response = await call(relay, walletServicePubkey, 'pay_invoice', { const response = await call(relay, walletServicePubkey, 'pay_invoice', {
invoice: 'lnbc210n1pjqrstuvwxyz', invoice: 'lnbc210n1pjqrstuvwxyz',
}) });
expect(response.error?.code).toBe('PAYMENT_FAILED') expect(response.error?.code).toBe('PAYMENT_FAILED');
expect(state.bearers.filter((bearer) => !bearer.spent).reduce((sum, bearer) => sum + bearer.amount, 0)).toBe(30_000) expect(
expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined() state.bearers
await stop() .filter((bearer) => !bearer.spent)
}) .reduce((sum, bearer) => sum + bearer.amount, 0),
}) ).toBe(30_000);
expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined();
await stop();
});
});
+56 -56
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,8 +55,8 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('storage validation', () => { describe('storage validation', () => {
it('drops malformed records instead of throwing', () => { it('drops malformed records instead of throwing', () => {
localStorage.setItem( localStorage.setItem(
@@ -73,15 +73,15 @@ describe('storage validation', () => {
createdAt: 0, createdAt: 0,
}, },
]), ]),
) );
expect(readNwcConnections(OWNER_ID)).toHaveLength(1) expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY);
}) });
it('returns nothing for garbage json', () => { it('returns nothing for garbage json', () => {
localStorage.setItem('sattle_nwc_connections', '{{{') localStorage.setItem('sattle_nwc_connections', '{{{');
expect(readNwcConnections(OWNER_ID)).toEqual([]) expect(readNwcConnections(OWNER_ID)).toEqual([]);
}) });
it('returns only strictly parsed records belonging to the requested owner', () => { it('returns only strictly parsed records belonging to the requested owner', () => {
const current = createConnection(LINKING_KEY, { const current = createConnection(LINKING_KEY, {
@@ -89,10 +89,10 @@ describe('storage validation', () => {
budget: { maxMsat: 1000, periodMs: 1000 }, budget: { maxMsat: 1000, periodMs: 1000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
now: 10, now: 10,
}).record }).record;
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 20).record const foreign = foreignConnectionFixture({ maxMsat: 2000, periodMs: 2000 }, 20).record;
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]') const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records') if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem( localStorage.setItem(
'sattle_nwc_connections', 'sattle_nwc_connections',
JSON.stringify([ JSON.stringify([
@@ -107,21 +107,21 @@ describe('storage validation', () => {
createdAt: 30, createdAt: 30,
}, },
]), ]),
) );
expect(readNwcConnections(OWNER_ID)).toEqual([current]) expect(readNwcConnections(OWNER_ID)).toEqual([current]);
expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]) expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign]);
}) });
it('adopts ownerless connections and enabled state only after owner proof', async () => { it('adopts ownerless connections and enabled state only after owner proof', async () => {
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}') const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
if (typeof saved !== 'object' || saved === null) { if (typeof saved !== 'object' || saved === null) {
throw new TypeError('Expected a saved linking-key record') throw new TypeError('Expected a saved linking-key record');
} }
Reflect.deleteProperty(saved, 'ownerId') Reflect.deleteProperty(saved, 'ownerId');
Reflect.deleteProperty(saved, 'version') Reflect.deleteProperty(saved, 'version');
localStorage.setItem('sattle_linking_key', JSON.stringify(saved)) localStorage.setItem('sattle_linking_key', JSON.stringify(saved));
localStorage.setItem( localStorage.setItem(
'sattle_nwc_connections', 'sattle_nwc_connections',
JSON.stringify([ JSON.stringify([
@@ -133,32 +133,32 @@ describe('storage validation', () => {
createdAt: 0, createdAt: 0,
}, },
]), ]),
) );
localStorage.setItem('sattle_nwc_enabled', 'true') localStorage.setItem('sattle_nwc_enabled', 'true');
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow() expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow();
expect(readNwcConnections(OWNER_ID)).toEqual([]) expect(readNwcConnections(OWNER_ID)).toEqual([]);
expect(readNwcEnabled(OWNER_ID)).toBe(false) expect(readNwcEnabled(OWNER_ID)).toBe(false);
ensureSavedKeyOwner(LINKING_KEY) ensureSavedKeyOwner(LINKING_KEY);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({ expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 1, connections: 1,
enabled: true, enabled: true,
}) });
expect(readNwcConnections(OWNER_ID)).toHaveLength(1) expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(readNwcEnabled(OWNER_ID)).toBe(true) expect(readNwcEnabled(OWNER_ID)).toBe(true);
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({ expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
connections: 0, connections: 0,
enabled: false, enabled: false,
}) });
}) });
it('does not expose one wallet enabled state to another owner', () => { it('does not expose one wallet enabled state to another owner', () => {
writeNwcEnabled(OWNER_ID, true) writeNwcEnabled(OWNER_ID, true);
expect(readNwcEnabled(OWNER_ID)).toBe(true) expect(readNwcEnabled(OWNER_ID)).toBe(true);
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false) expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false);
}) });
it('treats malformed owner-bearing enabled records as disabled', () => { it('treats malformed owner-bearing enabled records as disabled', () => {
for (const value of [ for (const value of [
@@ -166,8 +166,8 @@ describe('storage validation', () => {
{ version: 2, ownerId: OWNER_ID, enabled: true }, { version: 2, ownerId: OWNER_ID, enabled: true },
{ version: 1, ownerId: OWNER_ID, enabled: 'true' }, { version: 1, ownerId: OWNER_ID, enabled: 'true' },
]) { ]) {
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value)) localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value));
expect(readNwcEnabled(OWNER_ID)).toBe(false) expect(readNwcEnabled(OWNER_ID)).toBe(false);
} }
}) });
}) });
+9 -9
View File
@@ -1,9 +1,9 @@
import './nwc.connection.cases' import './nwc.connection.cases';
import './nwc.storage.cases' import './nwc.storage.cases';
import './nwc.ownership.cases' import './nwc.ownership.cases';
import './nwc.validation.cases' import './nwc.validation.cases';
import './nwc.pay-a.cases' import './nwc.pay-a.cases';
import './nwc.pay-b.cases' import './nwc.pay-b.cases';
import './nwc.fencing.cases' import './nwc.fencing.cases';
import './nwc.invoice-a.cases' import './nwc.invoice-a.cases';
import './nwc.invoice-b.cases' import './nwc.invoice-b.cases';
+78 -78
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,29 +29,29 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
export const LINKING_KEY = new Uint8Array(32).fill(7) export const LINKING_KEY = new Uint8Array(32).fill(7);
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
export const CLIENT_SECRET = hexToBytes('11'.repeat(32)) export const CLIENT_SECRET = hexToBytes('11'.repeat(32));
export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET) export const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET);
export const STRANGER_SECRET = hexToBytes('22'.repeat(32)) export const STRANGER_SECRET = hexToBytes('22'.repeat(32));
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY) export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY) export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
// never connected - the in-memory relay below stands in // never connected - the in-memory relay below stands in
export const RELAYS = ['wss://relay-a.example'] export const RELAYS = ['wss://relay-a.example'];
let NOW = 1_800_000_000 let NOW = 1_800_000_000;
export const nowSeconds = (): number => NOW export const nowSeconds = (): number => NOW;
export const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} export const FAST_POLL = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) => export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'], now: number) =>
connectionInfoOf(OTHER_LINKING_KEY, { connectionInfoOf(OTHER_LINKING_KEY, {
@@ -62,67 +62,67 @@ export const foreignConnectionFixture = (budget: NwcConnectionRecord['budget'],
budget, budget,
spent: { periodStart: now, msat: 0 }, spent: { periodStart: now, msat: 0 },
createdAt: now, createdAt: now,
}) });
export const storeForeignConnection = (record: NwcConnectionRecord): void => { export const storeForeignConnection = (record: NwcConnectionRecord): void => {
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]') const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]');
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records') if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records');
localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record])) localStorage.setItem('sattle_nwc_connections', JSON.stringify([...raw, record]));
} };
// an in-memory relay set: subscriptions register, emit delivers to every // an in-memory relay set: subscriptions register, emit delivers to every
// matching one, publish records // matching one, publish records
export const createFakeRelay = (): { export const createFakeRelay = (): {
transport: NwcTransport transport: NwcTransport;
published: NostrEvent[] published: NostrEvent[];
emit: (event: NostrEvent) => void emit: (event: NostrEvent) => void;
emitAfterClose: (event: NostrEvent) => void emitAfterClose: (event: NostrEvent) => void;
subscriptionCount: () => number subscriptionCount: () => number;
} => { } => {
const published: NostrEvent[] = [] const published: NostrEvent[] = [];
const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = [] const subs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const allSubs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = [] const allSubs: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[] = [];
const transport: NwcTransport = { const transport: NwcTransport = {
publish: (_relays, event) => { publish: (_relays, event) => {
published.push(event) published.push(event);
return Promise.resolve() return Promise.resolve();
}, },
subscribe: (_relays, filter, onEvent) => { subscribe: (_relays, filter, onEvent) => {
const sub = {filter, onEvent} const sub = { filter, onEvent };
subs.push(sub) subs.push(sub);
allSubs.push(sub) allSubs.push(sub);
return { return {
close: () => { close: () => {
const index = subs.indexOf(sub) const index = subs.indexOf(sub);
if (index >= 0) subs.splice(index, 1) if (index >= 0) subs.splice(index, 1);
}, },
} };
}, },
} };
const deliver = ( const deliver = (
targets: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[], targets: { filter: NostrFilter; onEvent: (event: NostrEvent) => void }[],
event: NostrEvent, event: NostrEvent,
): void => { ): void => {
for (const sub of [...targets]) { for (const sub of [...targets]) {
const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind) const kindsMatch = !sub.filter.kinds || sub.filter.kinds.includes(event.kind);
const wanted = sub.filter['#p'] const wanted = sub.filter['#p'];
const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? '')) const pMatch = !wanted || event.tags.some((t) => t[0] === 'p' && wanted.includes(t[1] ?? ''));
const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since const sinceMatch = sub.filter.since === undefined || event.created_at >= sub.filter.since;
if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event) if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event);
} }
} };
const emit = (event: NostrEvent): void => deliver(subs, event) const emit = (event: NostrEvent): void => deliver(subs, event);
const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event) const emitAfterClose = (event: NostrEvent): void => deliver(allSubs, event);
return { return {
transport, transport,
published, published,
emit, emit,
emitAfterClose, emitAfterClose,
subscriptionCount: () => subs.length, subscriptionCount: () => subs.length,
} };
} };
export type Encryption = 'nip44_v2' | 'nip04' | 'none' export type Encryption = 'nip44_v2' | 'nip04' | 'none';
// a NIP-47 request exactly as a real client would build it, signed by the // a NIP-47 request exactly as a real client would build it, signed by the
// connection's client secret // connection's client secret
@@ -132,8 +132,8 @@ export const clientRequest = (
scheme: Encryption = 'nip44_v2', scheme: Encryption = 'nip44_v2',
createdAt: number = NOW, createdAt: number = NOW,
): NostrEvent => { ): NostrEvent => {
const tags: string[][] = [['p', walletServicePubkey]] const tags: string[][] = [['p', walletServicePubkey]];
if (scheme !== 'none') tags.push(['encryption', scheme]) if (scheme !== 'none') tags.push(['encryption', scheme]);
return finalizeEvent( return finalizeEvent(
{ {
kind: NWC_REQUEST_KIND, kind: NWC_REQUEST_KIND,
@@ -148,8 +148,8 @@ export const clientRequest = (
: nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content), : nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content),
}, },
CLIENT_SECRET, CLIENT_SECRET,
) );
} };
export const methodRequest = ( export const methodRequest = (
walletServicePubkey: string, walletServicePubkey: string,
@@ -158,31 +158,31 @@ export const methodRequest = (
scheme: Encryption = 'nip44_v2', scheme: Encryption = 'nip44_v2',
createdAt?: number, createdAt?: number,
): NostrEvent => ): NostrEvent =>
clientRequest(walletServicePubkey, JSON.stringify({method, params}), scheme, createdAt) clientRequest(walletServicePubkey, JSON.stringify({ method, params }), scheme, createdAt);
// generous: a failed/never-settling melt is only classified after the // generous: a failed/never-settling melt is only classified after the
// verify-poll budget (seconds) runs out // verify-poll budget (seconds) runs out
export const waitFor = async (cond: () => boolean): Promise<void> => { export const waitFor = async (cond: () => boolean): Promise<void> => {
for (let i = 0; i < 3000 && !cond(); i++) { for (let i = 0; i < 3000 && !cond(); i++) {
await new Promise((resolve) => setTimeout(resolve, 5)) await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(cond()).toBe(true)
} }
expect(cond()).toBe(true);
};
export const deferred = (): { export const deferred = (): {
promise: Promise<void> promise: Promise<void>;
resolve: () => void resolve: () => void;
reject: (reason: Error) => void reject: (reason: Error) => void;
} => { } => {
let resolve = (): void => undefined let resolve = (): void => undefined;
let reject = (_reason: Error): void => undefined let reject = (_reason: Error): void => undefined;
const promise = new Promise<void>((resolvePromise, rejectPromise) => { const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise resolve = resolvePromise;
reject = rejectPromise reject = rejectPromise;
}) });
return {promise, resolve, reject} return { promise, resolve, reject };
} };
export const resetNow = (): void => { export const resetNow = (): void => {
NOW = 1_800_000_000 NOW = 1_800_000_000;
} };
+135 -130
View File
@@ -6,14 +6,14 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1, serverOf} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1, serverOf } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -30,14 +30,14 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import {isJsonObject} from './jsonParsing' import { isJsonObject } from './jsonParsing';
import { import {
CLIENT_SECRET, CLIENT_SECRET,
@@ -50,13 +50,13 @@ import {
nowSeconds, nowSeconds,
resetNow, resetNow,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import type {Encryption} from './nwc.testProtocol' import type { Encryption } from './nwc.testProtocol';
export type NwcResponsePayload = { export type NwcResponsePayload = {
result_type: string result_type: string;
error: {code: string; message: string} | null error: { code: string; message: string } | null;
result: Record<string, unknown> | null result: Record<string, unknown> | null;
} };
const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload => const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
isJsonObject(value) && isJsonObject(value) &&
@@ -65,7 +65,7 @@ const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload =>
(isJsonObject(value.error) && (isJsonObject(value.error) &&
typeof value.error.code === 'string' && typeof value.error.code === 'string' &&
typeof value.error.message === 'string')) && typeof value.error.message === 'string')) &&
(value.result === null || isJsonObject(value.result)) (value.result === null || isJsonObject(value.result));
export const readResponse = ( export const readResponse = (
published: NostrEvent[], published: NostrEvent[],
@@ -74,19 +74,19 @@ export const readResponse = (
): NwcResponsePayload | null => { ): NwcResponsePayload | null => {
const event = published.find( const event = published.find(
(e) => e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === requestId), (e) => e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === requestId),
) );
if (!event) return null if (!event) return null;
const plaintext = const plaintext =
scheme === 'nip44_v2' scheme === 'nip44_v2'
? nip44v2.decrypt( ? nip44v2.decrypt(
event.content, event.content,
nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey), nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey),
) )
: nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content) : nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content);
const parsed: unknown = JSON.parse(plaintext) const parsed: unknown = JSON.parse(plaintext);
if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.') if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.');
return parsed return parsed;
} };
// drives one full request/response round trip over the fake relay // drives one full request/response round trip over the fake relay
export const call = async ( export const call = async (
@@ -96,31 +96,31 @@ export const call = async (
params: Record<string, unknown>, params: Record<string, unknown>,
scheme: Encryption = 'nip44_v2', scheme: Encryption = 'nip44_v2',
): Promise<NwcResponsePayload> => { ): Promise<NwcResponsePayload> => {
const request = methodRequest(walletServicePubkey, method, params, scheme) const request = methodRequest(walletServicePubkey, method, params, scheme);
relay.emit(request) relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, scheme) !== null) await waitFor(() => readResponse(relay.published, request.id, scheme) !== null);
return requiredValue(readResponse(relay.published, request.id, scheme)) return requiredValue(readResponse(relay.published, request.id, scheme));
} };
export type Mint = Awaited<ReturnType<typeof createMockMint>> export type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [] const mints: Mint[] = [];
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => { export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const m = await createMockMint(options) const m = await createMockMint(options);
mints.push(m) mints.push(m);
return m return m;
} };
afterEach(async () => { afterEach(async () => {
await Promise.all(mints.splice(0).map((m) => m.close())) await Promise.all(mints.splice(0).map((m) => m.close()));
}) });
let bearerCounter = 0 let bearerCounter = 0;
let allocationCounter = 0 let allocationCounter = 0;
export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => { export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise<Bearer> => {
m.state.creditNote(k1, amountMsat) m.state.creditNote(k1, amountMsat);
const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat) const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat);
const info = await fetchNoteInfo(url) const info = await fetchNoteInfo(url);
bearerCounter += 1 bearerCounter += 1;
return { return {
id: `bearer-${bearerCounter}`, id: `bearer-${bearerCounter}`,
url, url,
@@ -130,50 +130,50 @@ export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promi
mintPubkey: m.state.pubkey, mintPubkey: m.state.pubkey,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
} };
} };
// the harness around startService: a fake relay, an in-memory "store" // the harness around startService: a fake relay, an in-memory "store"
// applying changesets the way the Pinia layer will, and a created // applying changesets the way the Pinia layer will, and a created
// connection with a pinned client secret // connection with a pinned client secret
export const startTestService = async (options: { export const startTestService = async (options: {
budgetMsat?: number budgetMsat?: number;
periodMs?: number periodMs?: number;
defaultMint?: string | null defaultMint?: string | null;
linkingKey?: Uint8Array linkingKey?: Uint8Array;
poll?: typeof FAST_POLL poll?: typeof FAST_POLL;
claimPoll?: typeof FAST_POLL claimPoll?: typeof FAST_POLL;
kit?: NwcServiceDeps['kit'] kit?: NwcServiceDeps['kit'];
commitChangeset?: (changeset: NwcChangeset) => Promise<void> commitChangeset?: (changeset: NwcChangeset) => Promise<void>;
setMintOutputRetirement?: () => Promise<void> setMintOutputRetirement?: () => Promise<void>;
}): Promise<{ }): Promise<{
relay: ReturnType<typeof createFakeRelay> relay: ReturnType<typeof createFakeRelay>;
walletServicePubkey: string walletServicePubkey: string;
state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]} state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] };
stop: () => Promise<void> stop: () => Promise<void>;
}> => { }> => {
const budgetMsat = options.budgetMsat ?? 1_000_000_000 const budgetMsat = options.budgetMsat ?? 1_000_000_000;
const connection = createConnection(options.linkingKey ?? LINKING_KEY, { const connection = createConnection(options.linkingKey ?? LINKING_KEY, {
relays: RELAYS, relays: RELAYS,
budget: { maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000 }, budget: { maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000 },
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
now: nowSeconds() * 1000, now: nowSeconds() * 1000,
}) });
const relay = createFakeRelay() const relay = createFakeRelay();
const state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] } = { const state: { bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[] } = {
bearers: [], bearers: [],
changesets: [], changesets: [],
errors: [], errors: [],
} };
const service = await startService(options.linkingKey ?? LINKING_KEY, { const service = await startService(options.linkingKey ?? LINKING_KEY, {
assertCurrentOwner: () => undefined, assertCurrentOwner: () => undefined,
// deterministic stand-in for the wallet's BIP-32 allocation path // deterministic stand-in for the wallet's BIP-32 allocation path
allocateOutputSecrets: (_server, count) => { allocateOutputSecrets: (_server, count) => {
const secrets = Array.from({ length: count }, () => { const secrets = Array.from({ length: count }, () => {
allocationCounter += 1 allocationCounter += 1;
return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`))) return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`)));
}) });
return Promise.resolve(secrets) return Promise.resolve(secrets);
}, },
// the keys a production caller serves from the trusted-mint registry: // the keys a production caller serves from the trusted-mint registry:
// each created mock mint's current key plus any it retired // each created mock mint's current key plus any it retired
@@ -185,38 +185,38 @@ export const startTestService = async (options: {
getBearers: () => state.bearers, getBearers: () => state.bearers,
getDefaultMint: () => options.defaultMint ?? null, getDefaultMint: () => options.defaultMint ?? null,
persistMintOutput: (note) => { persistMintOutput: (note) => {
bearerCounter += 1 bearerCounter += 1;
const bearer: Bearer = { const bearer: Bearer = {
...note, ...note,
id: `staged-${bearerCounter}`, id: `staged-${bearerCounter}`,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
} };
state.bearers.push(bearer) state.bearers.push(bearer);
return Promise.resolve(bearer) return Promise.resolve(bearer);
}, },
discardMintOutput: (staged) => { discardMintOutput: (staged) => {
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id) state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
return Promise.resolve() return Promise.resolve();
}, },
setMintOutputRetirement: (staged, retireAfter) => { setMintOutputRetirement: (staged, retireAfter) => {
const override = options.setMintOutputRetirement?.() const override = options.setMintOutputRetirement?.();
if (override) return override if (override) return override;
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) return Promise.reject(new Error('The staged bearer is missing.')) if (index < 0) return Promise.reject(new Error('The staged bearer is missing.'));
const updated: Bearer = { const updated: Bearer = {
...staged, ...staged,
pendingMint: { ...staged.pendingMint, retireAfter }, pendingMint: { ...staged.pendingMint, retireAfter },
updatedAt: Date.now(), updatedAt: Date.now(),
} };
state.bearers[index] = updated state.bearers[index] = updated;
return Promise.resolve() return Promise.resolve();
}, },
reserveMintOutputSource: (staged, sourceId, recoverySecret) => { reserveMintOutputSource: (staged, sourceId, recoverySecret) => {
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
const source = state.bearers.find((bearer) => bearer.id === sourceId) const source = state.bearers.find((bearer) => bearer.id === sourceId);
if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.')) if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.'));
source.spent = true source.spent = true;
state.bearers[index] = { state.bearers[index] = {
...state.bearers[index]!, ...state.bearers[index]!,
pendingMint: { pendingMint: {
@@ -224,30 +224,35 @@ export const startTestService = async (options: {
sourceBearerId: sourceId, sourceBearerId: sourceId,
sourceRecoverySecret: recoverySecret, sourceRecoverySecret: recoverySecret,
}, },
} };
return Promise.resolve() return Promise.resolve();
}, },
finalizeMintOutput: async (staged, note) => { finalizeMintOutput: async (staged, note) => {
const changeset: NwcChangeset = {add: [], markSpent: []} const changeset: NwcChangeset = { add: [], markSpent: [] };
await options.commitChangeset?.(changeset) await options.commitChangeset?.(changeset);
state.changesets.push(changeset) state.changesets.push(changeset);
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) throw new Error('The staged bearer is missing.') if (index < 0) throw new Error('The staged bearer is missing.');
state.bearers[index] = {...staged, ...note, pendingMint: undefined, updatedAt: Date.now()} state.bearers[index] = { ...staged, ...note, pendingMint: undefined, updatedAt: Date.now() };
}, },
finalizePaymentReturn: async (staged, note) => { finalizePaymentReturn: async (staged, note) => {
const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) const index = state.bearers.findIndex((bearer) => bearer.id === staged.id);
if (index < 0) throw new Error('The staged bearer is missing.') if (index < 0) throw new Error('The staged bearer is missing.');
const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId;
if (sourceId) { if (sourceId) {
const source = state.bearers.find((bearer) => bearer.id === sourceId) const source = state.bearers.find((bearer) => bearer.id === sourceId);
if (source) source.spent = true if (source) source.spent = true;
} }
state.bearers[index] = {...state.bearers[index]!, ...note, pendingMint: undefined, updatedAt: Date.now()} state.bearers[index] = {
...state.bearers[index]!,
...note,
pendingMint: undefined,
updatedAt: Date.now(),
};
}, },
finalizeSpentMintOutput: (staged) => { finalizeSpentMintOutput: (staged) => {
state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id) state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id);
return Promise.resolve() return Promise.resolve();
}, },
commitCarve: async (carve) => { commitCarve: async (carve) => {
// a checkpoint is identified by its output secret: the landed phase's // a checkpoint is identified by its output secret: the landed phase's
@@ -255,68 +260,68 @@ export const startTestService = async (options: {
const sameOutput = (url: string, noteUrl: string): boolean => const sameOutput = (url: string, noteUrl: string): boolean =>
serverOf(url) === serverOf(noteUrl) && serverOf(url) === serverOf(noteUrl) &&
noteK1(url) !== null && noteK1(url) !== null &&
noteK1(url) === noteK1(noteUrl) noteK1(url) === noteK1(noteUrl);
const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter( const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter(
(note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)), (note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)),
) );
const changeset: NwcChangeset = { const changeset: NwcChangeset = {
add: additions, add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id), markSpent: carve.consumed.map((bearer) => bearer.id),
} };
await options.commitChangeset?.(changeset) await options.commitChangeset?.(changeset);
state.changesets.push(changeset) state.changesets.push(changeset);
for (const bearer of state.bearers) { for (const bearer of state.bearers) {
if (changeset.markSpent.includes(bearer.id)) bearer.spent = true if (changeset.markSpent.includes(bearer.id)) bearer.spent = true;
} }
for (const note of additions) { for (const note of additions) {
bearerCounter += 1 bearerCounter += 1;
state.bearers.unshift({ state.bearers.unshift({
...note, ...note,
id: `carve-${bearerCounter}`, id: `carve-${bearerCounter}`,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
}) });
} }
const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url)) const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url));
if (!committed) throw new Error('The carved note was not committed.') if (!committed) throw new Error('The carved note was not committed.');
return committed return committed;
}, },
applyChangeset: async (changeset: NwcChangeset) => { applyChangeset: async (changeset: NwcChangeset) => {
await options.commitChangeset?.(changeset) await options.commitChangeset?.(changeset);
state.changesets.push(changeset) state.changesets.push(changeset);
for (const id of changeset.markSpent) { for (const id of changeset.markSpent) {
const bearer = state.bearers.find((b) => b.id === id) const bearer = state.bearers.find((b) => b.id === id);
if (bearer) bearer.spent = true if (bearer) bearer.spent = true;
} }
for (const note of changeset.add) { for (const note of changeset.add) {
bearerCounter += 1 bearerCounter += 1;
state.bearers.push({ state.bearers.push({
...note, ...note,
id: `added-${bearerCounter}`, id: `added-${bearerCounter}`,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
}) });
} }
}, },
onError: (err) => { onError: (err) => {
state.errors.push(err) state.errors.push(err);
}, },
transport: relay.transport, transport: relay.transport,
kit: options.kit, kit: options.kit,
poll: options.poll ?? FAST_POLL, poll: options.poll ?? FAST_POLL,
claimPoll: options.claimPoll ?? FAST_POLL, claimPoll: options.claimPoll ?? FAST_POLL,
nowSeconds, nowSeconds,
}) });
return { return {
relay, relay,
walletServicePubkey: connection.walletServicePubkey, walletServicePubkey: connection.walletServicePubkey,
state, state,
stop: service.stop, stop: service.stop,
} };
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
resetNow() resetNow();
}) });
+16 -16
View File
@@ -37,7 +37,7 @@
// per-connection request queues // per-connection request queues
// Budget/connection persistence lives in storage/nwcConnections.ts. // Budget/connection persistence lives in storage/nwcConnections.ts.
export type {NwcBudget, NwcBudgetSpend, NwcConnectionRecord} from './storage/nwcConnections' export type { NwcBudget, NwcBudgetSpend, NwcConnectionRecord } from './storage/nwcConnections';
export { export {
clearNwcStorageForOwner, clearNwcStorageForOwner,
clearUnownedNwcStorage, clearUnownedNwcStorage,
@@ -46,9 +46,9 @@ export {
readNwcConnections, readNwcConnections,
removeNwcConnection, removeNwcConnection,
writeNwcConnections, writeNwcConnections,
} from './storage/nwcConnections' } from './storage/nwcConnections';
export {readNwcEnabled, writeNwcEnabled} from './storage/nwcEnabled' export { readNwcEnabled, writeNwcEnabled } from './storage/nwcEnabled';
export type {NwcLegacyMigrationResult} from './storage/nwcConnections' export type { NwcLegacyMigrationResult } from './storage/nwcConnections';
export { export {
buildConnectionString, buildConnectionString,
@@ -57,13 +57,13 @@ export {
deriveNwcWalletKey, deriveNwcWalletKey,
nwcWalletPubkey, nwcWalletPubkey,
parseConnectionString, parseConnectionString,
} from './nwc/connection' } from './nwc/connection';
export type { export type {
CreateConnectionOptions, CreateConnectionOptions,
CreatedConnection, CreatedConnection,
NwcConnectionInfo, NwcConnectionInfo,
ParsedConnectionString, ParsedConnectionString,
} from './nwc/connection' } from './nwc/connection';
export { export {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -75,7 +75,7 @@ export {
decryptRequest, decryptRequest,
errResult, errResult,
okResult, okResult,
} from './nwc/protocol' } from './nwc/protocol';
export type { export type {
DecryptedNwcRequest, DecryptedNwcRequest,
NostrEvent, NostrEvent,
@@ -84,15 +84,15 @@ export type {
NwcMethod, NwcMethod,
NwcRequest, NwcRequest,
NwcResponse, NwcResponse,
} from './nwc/protocol' } from './nwc/protocol';
export {defaultNwcTransport} from './nwc/transport' export { defaultNwcTransport } from './nwc/transport';
export type {NostrFilter, NwcSubscription, NwcTransport} from './nwc/transport' export type { NostrFilter, NwcSubscription, NwcTransport } from './nwc/transport';
export {budgetRemainingMsat, recordSpend} from './nwc/budget' export { budgetRemainingMsat, recordSpend } from './nwc/budget';
export type {PendingInvoice, RequestContext} from './nwc/context' export type { PendingInvoice, RequestContext } from './nwc/context';
export {invoiceResult, resolvePaymentHash} from './nwc/invoices' export { invoiceResult, resolvePaymentHash } from './nwc/invoices';
export {payChangeset} from './nwc/pay' export { payChangeset } from './nwc/pay';
export {startService} from './nwc/service' export { startService } from './nwc/service';
export type {NwcChangeset, NwcService, NwcServiceDeps} from './nwc/service' export type { NwcChangeset, NwcService, NwcServiceDeps } from './nwc/service';
+80 -80
View File
@@ -6,13 +6,13 @@
// requests never execute, and a settled preimage only ever reveals an // requests never execute, and a settled preimage only ever reveals an
// already-rotated (burned) note secret. // already-rotated (burned) note secret.
import {afterEach, beforeEach, describe, expect, it} from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo, noteK1 } from 'lnurlcash-kit';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { import {
NWC_INFO_KIND, NWC_INFO_KIND,
@@ -29,13 +29,13 @@ import {
startService, startService,
writeNwcEnabled, writeNwcEnabled,
writeNwcConnections, writeNwcConnections,
} from './nwc' } from './nwc';
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' import type { NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport } from './nwc';
import type {NostrFilter} from './nwc/transport' import type { NostrFilter } from './nwc/transport';
import type {NwcChangeset} from './nwc' import type { NwcChangeset } from './nwc';
import type {Bearer} from './types' import type { Bearer } from './types';
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' import { ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey } from './keys';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
import { import {
CLIENT_PUBKEY, CLIENT_PUBKEY,
@@ -55,45 +55,45 @@ import {
nowSeconds, nowSeconds,
storeForeignConnection, storeForeignConnection,
waitFor, waitFor,
} from './nwc.testProtocol' } from './nwc.testProtocol';
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' import { call, makeBearer, mint, readResponse, startTestService } from './nwc.testService';
describe('service: request validation', () => { describe('service: request validation', () => {
it('answers an unknown method with NOT_IMPLEMENTED', async () => { it('answers an unknown method with NOT_IMPLEMENTED', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const response = await call(relay, walletServicePubkey, 'get_payments', {}) const response = await call(relay, walletServicePubkey, 'get_payments', {});
expect(response.result_type).toBe('get_payments') expect(response.result_type).toBe('get_payments');
expect(response.error?.code).toBe('NOT_IMPLEMENTED') expect(response.error?.code).toBe('NOT_IMPLEMENTED');
expect(response.result).toBeNull() expect(response.result).toBeNull();
await stop() await stop();
}) });
it('answers a malformed (non-JSON) request with an error, not a crash', async () => { it('answers a malformed (non-JSON) request with an error, not a crash', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const request = clientRequest(walletServicePubkey, 'this is not json') const request = clientRequest(walletServicePubkey, 'this is not json');
relay.emit(request) relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2')) const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2'));
expect(response.error?.code).toBe('OTHER') expect(response.error?.code).toBe('OTHER');
await stop() await stop();
}) });
it('answers a JSON request without a method with an error', async () => { it('answers a JSON request without a method with an error', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}})) const request = clientRequest(walletServicePubkey, JSON.stringify({ params: {} }));
relay.emit(request) relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe( expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe(
'OTHER', 'OTHER',
) );
await stop() await stop();
}) });
it('ignores a request signed by a stranger key - silently', async () => { it('ignores a request signed by a stranger key - silently', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip44v2.encrypt( const content = nip44v2.encrypt(
JSON.stringify({ method: 'get_balance', params: {} }), JSON.stringify({ method: 'get_balance', params: {} }),
nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey), nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey),
) );
const forged = finalizeEvent( const forged = finalizeEvent(
{ {
kind: NWC_REQUEST_KIND, kind: NWC_REQUEST_KIND,
@@ -105,20 +105,20 @@ describe('service: request validation', () => {
content, content,
}, },
STRANGER_SECRET, STRANGER_SECRET,
) );
relay.emit(forged) relay.emit(forged);
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
await stop() await stop();
}) });
it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => { it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const content = nip04Encrypt( const content = nip04Encrypt(
CLIENT_SECRET, CLIENT_SECRET,
walletServicePubkey, walletServicePubkey,
JSON.stringify({ method: 'get_balance', params: {} }), JSON.stringify({ method: 'get_balance', params: {} }),
) );
const request = finalizeEvent( const request = finalizeEvent(
{ {
kind: NWC_REQUEST_KIND, kind: NWC_REQUEST_KIND,
@@ -130,58 +130,58 @@ describe('service: request validation', () => {
content, content,
}, },
CLIENT_SECRET, CLIENT_SECRET,
) );
relay.emit(request) relay.emit(request);
// the error answer goes out in the legacy scheme every client reads // the error answer goes out in the legacy scheme every client reads
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe( expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe(
'UNSUPPORTED_ENCRYPTION', 'UNSUPPORTED_ENCRYPTION',
) );
await stop() await stop();
}) });
it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => { it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
for (const scheme of ['none', 'nip04'] as const) { for (const scheme of ['none', 'nip04'] as const) {
const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme) const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme);
relay.emit(request) relay.emit(request);
await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null) await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null);
const response = requiredValue(readResponse(relay.published, request.id, 'nip04')) const response = requiredValue(readResponse(relay.published, request.id, 'nip04'));
expect(response.error).toBeNull() expect(response.error).toBeNull();
expect(response.result).toEqual({balance: 0}) expect(response.result).toEqual({ balance: 0 });
// the response mirrors the request's scheme // the response mirrors the request's scheme
const event = requiredValue( const event = requiredValue(
relay.published.find( relay.published.find(
(e) => (e) =>
e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id), e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id),
), ),
) );
expect(event.tags).toContainEqual(['encryption', 'nip04']) expect(event.tags).toContainEqual(['encryption', 'nip04']);
} }
await stop() await stop();
}) });
it('drops requests older than the replay window unanswered', async () => { it('drops requests older than the replay window unanswered', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
const stale = methodRequest( const stale = methodRequest(
walletServicePubkey, walletServicePubkey,
'get_balance', 'get_balance',
{}, {},
'nip44_v2', 'nip44_v2',
nowSeconds() - 1200, nowSeconds() - 1200,
) );
relay.emit(stale) relay.emit(stale);
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
await stop() await stop();
}) });
it('picks up no new requests after stop', async () => { it('picks up no new requests after stop', async () => {
const {relay, walletServicePubkey, stop} = await startTestService({}) const { relay, walletServicePubkey, stop } = await startTestService({});
await stop() await stop();
const request = methodRequest(walletServicePubkey, 'get_balance', {}) const request = methodRequest(walletServicePubkey, 'get_balance', {});
relay.emitAfterClose(request) relay.emitAfterClose(request);
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100));
expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0);
}) });
}) });
+9 -9
View File
@@ -5,14 +5,14 @@
// check + record below atomic enough: no two pays of one connection ever // check + record below atomic enough: no two pays of one connection ever
// run this concurrently. // run this concurrently.
import type {NwcConnectionRecord} from '../storage/nwcConnections' import type { NwcConnectionRecord } from '../storage/nwcConnections';
import {persistNwcConnection} from '../storage/nwcConnections' import { persistNwcConnection } from '../storage/nwcConnections';
export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => { export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => {
const {maxMsat, periodMs} = record.budget const { maxMsat, periodMs } = record.budget;
if (nowMs - record.spent.periodStart >= periodMs) return maxMsat if (nowMs - record.spent.periodStart >= periodMs) return maxMsat;
return Math.max(0, maxMsat - record.spent.msat) return Math.max(0, maxMsat - record.spent.msat);
} };
// rolls the period when it expired, then adds the spend; persists (the // rolls the period when it expired, then adds the spend; persists (the
// caller's queue serialized this read-modify-write) // caller's queue serialized this read-modify-write)
@@ -22,7 +22,7 @@ export const recordSpend = (
amountMsat: number, amountMsat: number,
nowMs: number, nowMs: number,
): NwcConnectionRecord => { ): NwcConnectionRecord => {
const expired = nowMs - record.spent.periodStart >= record.budget.periodMs const expired = nowMs - record.spent.periodStart >= record.budget.periodMs;
return persistNwcConnection(ownerId, { return persistNwcConnection(ownerId, {
...record, ...record,
spent: expired spent: expired
@@ -31,5 +31,5 @@ export const recordSpend = (
periodStart: record.spent.periodStart, periodStart: record.spent.periodStart,
msat: record.spent.msat + amountMsat, msat: record.spent.msat + amountMsat,
}, },
}) });
} };
+48 -48
View File
@@ -20,17 +20,17 @@
// The connection string follows NIP-47 exactly: // The connection string follows NIP-47 exactly:
// nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex> // nostr+walletconnect://<wallet-service-pubkey>?relay=wss://...&secret=<client-secret-hex>
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import {getPublicKey} from 'nostr-tools/pure' import { getPublicKey } from 'nostr-tools/pure';
import {linkingPubKeyHex} from '../keys' import { linkingPubKeyHex } from '../keys';
import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections' import type { NwcBudget, NwcConnectionRecord } from '../storage/nwcConnections';
import {persistNwcConnection} from '../storage/nwcConnections' import { persistNwcConnection } from '../storage/nwcConnections';
const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1' const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1';
const HEX_64 = /^[0-9a-f]{64}$/i const HEX_64 = /^[0-9a-f]{64}$/i;
// Deterministic: sha256(linking key || context || client pubkey). The // Deterministic: sha256(linking key || context || client pubkey). The
// result is a secp256k1 secret key used ONLY as this connection's // result is a secp256k1 secret key used ONLY as this connection's
@@ -43,16 +43,16 @@ export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: str
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT), ...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...hexToBytes(clientPubkey), ...hexToBytes(clientPubkey),
]), ]),
) );
// the x-only nostr pubkey the client addresses its requests to // the x-only nostr pubkey the client addresses its requests to
export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string => export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string =>
getPublicKey(walletSecretKey) getPublicKey(walletSecretKey);
export type NwcConnectionInfo = { export type NwcConnectionInfo = {
record: NwcConnectionRecord record: NwcConnectionRecord;
walletServicePubkey: string walletServicePubkey: string;
} };
// the runtime view of a persisted record: the record plus its re-derived // the runtime view of a persisted record: the record plus its re-derived
// wallet-service identity // wallet-service identity
@@ -62,21 +62,21 @@ export const connectionInfoOf = (
): NwcConnectionInfo => ({ ): NwcConnectionInfo => ({
record, record,
walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)), walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
}) });
export type CreatedConnection = NwcConnectionInfo & { export type CreatedConnection = NwcConnectionInfo & {
// shown to the holder exactly once - it carries the client secret, // shown to the holder exactly once - it carries the client secret,
// which the wallet deliberately does NOT store // which the wallet deliberately does NOT store
connectionString: string connectionString: string;
} };
export type CreateConnectionOptions = { export type CreateConnectionOptions = {
relays: string[] relays: string[];
budget: NwcBudget budget: NwcBudget;
// test hook: a fixed client secret (32 bytes) instead of a random one // test hook: a fixed client secret (32 bytes) instead of a random one
clientSecret?: Uint8Array clientSecret?: Uint8Array;
now?: number now?: number;
} };
// Creates and persists a connection. The client secret is random by // Creates and persists a connection. The client secret is random by
// default; the wallet-service key falls out of the derivation above. // default; the wallet-service key falls out of the derivation above.
@@ -85,11 +85,11 @@ export const createConnection = (
options: CreateConnectionOptions, options: CreateConnectionOptions,
): CreatedConnection => { ): CreatedConnection => {
if (options.relays.length === 0) { if (options.relays.length === 0) {
throw new Error('A connection needs at least one relay.') throw new Error('A connection needs at least one relay.');
} }
const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32)) const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32));
const clientPubkey = getPublicKey(clientSecret) const clientPubkey = getPublicKey(clientSecret);
const ownerId = linkingPubKeyHex(linkingPrivKey) const ownerId = linkingPubKeyHex(linkingPrivKey);
const record = persistNwcConnection(ownerId, { const record = persistNwcConnection(ownerId, {
version: 1, version: 1,
ownerId, ownerId,
@@ -98,8 +98,8 @@ export const createConnection = (
budget: options.budget, budget: options.budget,
spent: { periodStart: options.now ?? Date.now(), msat: 0 }, spent: { periodStart: options.now ?? Date.now(), msat: 0 },
createdAt: options.now ?? Date.now(), createdAt: options.now ?? Date.now(),
}) });
const info = connectionInfoOf(linkingPrivKey, record) const info = connectionInfoOf(linkingPrivKey, record);
return { return {
...info, ...info,
connectionString: buildConnectionString( connectionString: buildConnectionString(
@@ -107,43 +107,43 @@ export const createConnection = (
bytesToHex(clientSecret), bytesToHex(clientSecret),
record.relays, record.relays,
), ),
} };
} };
export const buildConnectionString = ( export const buildConnectionString = (
walletServicePubkey: string, walletServicePubkey: string,
clientSecretHex: string, clientSecretHex: string,
relays: string[], relays: string[],
): string => { ): string => {
const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&') const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&');
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}` return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}`;
} };
export type ParsedConnectionString = { export type ParsedConnectionString = {
walletServicePubkey: string walletServicePubkey: string;
clientSecret: string clientSecret: string;
relays: string[] relays: string[];
} };
// parses a NIP-47 connection string; returns null for anything that isn't // parses a NIP-47 connection string; returns null for anything that isn't
// exactly one (a client-side counterpart of buildConnectionString, here so // exactly one (a client-side counterpart of buildConnectionString, here so
// the format has a tested inverse) // the format has a tested inverse)
export const parseConnectionString = (uri: string): ParsedConnectionString | null => { export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
let url: URL let url: URL;
try { try {
url = new URL(uri.trim()) url = new URL(uri.trim());
} catch { } catch {
return null return null;
} }
if (url.protocol !== 'nostr+walletconnect:') return null if (url.protocol !== 'nostr+walletconnect:') return null;
// the host is the wallet-service pubkey; normalize case so a // the host is the wallet-service pubkey; normalize case so a
// hand-typed uppercase string still parses (nostr pubkeys are // hand-typed uppercase string still parses (nostr pubkeys are
// conventionally lowercase hex) // conventionally lowercase hex)
const walletServicePubkey = url.host.toLowerCase() const walletServicePubkey = url.host.toLowerCase();
if (!HEX_64.test(walletServicePubkey)) return null if (!HEX_64.test(walletServicePubkey)) return null;
const secret = url.searchParams.get('secret') const secret = url.searchParams.get('secret');
if (!secret || !HEX_64.test(secret)) return null if (!secret || !HEX_64.test(secret)) return null;
const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay)) const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay));
if (relays.length === 0) return null if (relays.length === 0) return null;
return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays} return { walletServicePubkey, clientSecret: secret.toLowerCase(), relays };
} };
+57 -57
View File
@@ -5,119 +5,119 @@
// no logic, so every other nwc/ module can import from here without // no logic, so every other nwc/ module can import from here without
// cycles. // cycles.
import type {LnurlcashOptions} from 'lnurlcash-kit' import type { LnurlcashOptions } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {NwcConnectionRecord} from '../storage/nwcConnections' import type { NwcConnectionRecord } from '../storage/nwcConnections';
import type {PreparedMint} from '../ops' import type { PreparedMint } from '../ops';
import type {CarveResult} from '../ops' import type { CarveResult } from '../ops';
import type {OutputSecretAllocator} from '../ops/allocation' import type { OutputSecretAllocator } from '../ops/allocation';
import type {MintSignatureKeys} from '../ops/shared' import type { MintSignatureKeys } from '../ops/shared';
import type {PollOptions} from '../ops/shared' import type { PollOptions } from '../ops/shared';
import type {NwcConnectionInfo} from './connection' import type { NwcConnectionInfo } from './connection';
import type {NwcMethod} from './protocol' import type { NwcMethod } from './protocol';
import type {NwcTransport} from './transport' import type { NwcTransport } from './transport';
// the state delta the caller's store applies after an op ran: new notes // the state delta the caller's store applies after an op ran: new notes
// to track, existing bearer ids to lock spent. Born-spent notes (a // to track, existing bearer ids to lock spent. Born-spent notes (a
// freshly carved note that was melted away) are deliberately NOT part of // freshly carved note that was melted away) are deliberately NOT part of
// it - they were never the wallet's money in a trackable state // it - they were never the wallet's money in a trackable state
export type NwcChangeset = { export type NwcChangeset = {
add: NewBearer[] add: NewBearer[];
markSpent: string[] markSpent: string[];
} };
export type NwcServiceDeps = { export type NwcServiceDeps = {
getBearers: () => Bearer[] getBearers: () => Bearer[];
// the mint make_invoice issues invoices against (the wallet's default // the mint make_invoice issues invoices against (the wallet's default
// mint - NIP-47's make_invoice carries no mint choice) // mint - NIP-47's make_invoice carries no mint choice)
getDefaultMint: () => string | null getDefaultMint: () => string | null;
assertCurrentOwner: () => void assertCurrentOwner: () => void;
// the wallet's durable allocation path (BIP-32 reservation) for every // the wallet's durable allocation path (BIP-32 reservation) for every
// note output secret a request stages // note output secret a request stages
allocateOutputSecrets: ( allocateOutputSecrets: (
server: string, server: string,
count: number, count: number,
assertOwner: () => void, assertOwner: () => void,
) => Promise<readonly string[]> ) => Promise<readonly string[]>;
// trusted current+previous signing keys a landed carve output verifies // trusted current+previous signing keys a landed carve output verifies
// against - the trusted-mint registry, owner-scoped // against - the trusted-mint registry, owner-scoped
mintSignatureKeys: MintSignatureKeys mintSignatureKeys: MintSignatureKeys;
recoverPendingMints: (assertOwner: () => void) => Promise<void> recoverPendingMints: (assertOwner: () => void) => Promise<void>;
persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise<Bearer> persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise<Bearer>;
discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void> discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>;
setMintOutputRetirement: ( setMintOutputRetirement: (
staged: Bearer, staged: Bearer,
retireAfter: number, retireAfter: number,
assertOwner: () => void, assertOwner: () => void,
) => Promise<void> ) => Promise<void>;
reserveMintOutputSource: ( reserveMintOutputSource: (
staged: Bearer, staged: Bearer,
sourceId: string, sourceId: string,
recoverySecret: string, recoverySecret: string,
assertOwner: () => void, assertOwner: () => void,
) => Promise<void> ) => Promise<void>;
finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise<void> finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise<void>;
finalizePaymentReturn: ( finalizePaymentReturn: (
staged: Bearer, staged: Bearer,
note: NewBearer, note: NewBearer,
assertOwner: () => void, assertOwner: () => void,
) => Promise<void> ) => Promise<void>;
finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void> finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise<void>;
commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise<Bearer> commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise<Bearer>;
applyChangeset: ( applyChangeset: (
changeset: NwcChangeset, changeset: NwcChangeset,
connection: NwcConnectionInfo, connection: NwcConnectionInfo,
method: NwcMethod, method: NwcMethod,
assertOwner: () => void, assertOwner: () => void,
) => Promise<void> ) => Promise<void>;
transport?: NwcTransport transport?: NwcTransport;
// kit transport overrides (fetch injection, timeouts) // kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions kit?: LnurlcashOptions;
// verify-poll budget for pay_invoice outcome classification // verify-poll budget for pay_invoice outcome classification
poll?: PollOptions poll?: PollOptions;
// verify-poll budget for the background claim after make_invoice - // verify-poll budget for the background claim after make_invoice -
// generous by default: the client pays the invoice whenever it pays it // generous by default: the client pays the invoice whenever it pays it
claimPoll?: PollOptions claimPoll?: PollOptions;
// background failures (a lost claim, a rejected publish) have no caller // background failures (a lost claim, a rejected publish) have no caller
// to throw to - they surface here // to throw to - they surface here
onError?: (error: unknown, connection: NwcConnectionInfo) => void onError?: (error: unknown, connection: NwcConnectionInfo) => void;
// test hook: pinned clock for event timestamps and expirations // test hook: pinned clock for event timestamps and expirations
nowSeconds?: () => number nowSeconds?: () => number;
} };
export type PendingInvoice = { export type PendingInvoice = {
invoice: string invoice: string;
paymentHash: string paymentHash: string;
// the gross invoiced amount (net + mint fee) - what the payer pays // the gross invoiced amount (net + mint fee) - what the payer pays
amountMsat: number amountMsat: number;
description?: string description?: string;
createdAt: number // unix seconds createdAt: number; // unix seconds
expiresAt?: number expiresAt?: number;
prepared: PreparedMint prepared: PreparedMint;
stagedOutput: Bearer stagedOutput: Bearer;
state: 'pending' | 'settled' | 'failed' state: 'pending' | 'settled' | 'failed';
preimage?: string preimage?: string;
settledAt?: number settledAt?: number;
} };
// what a method handler sees of its connection's runtime: the deps, the // what a method handler sees of its connection's runtime: the deps, the
// (fresh) connection info - budget updates replace the record, so it's a // (fresh) connection info - budget updates replace the record, so it's a
// getter, not a snapshot - the invoice registry, and the clock // getter, not a snapshot - the invoice registry, and the clock
export type RequestContext = { export type RequestContext = {
deps: NwcServiceDeps deps: NwcServiceDeps;
connection: () => NwcConnectionInfo connection: () => NwcConnectionInfo;
updateRecord: (record: NwcConnectionRecord) => void updateRecord: (record: NwcConnectionRecord) => void;
invoices: Map<string, PendingInvoice> invoices: Map<string, PendingInvoice>;
nowSeconds: () => number nowSeconds: () => number;
assertOwner: () => void assertOwner: () => void;
// Starts service-owned work only while the service accepts new work. // Starts service-owned work only while the service accepts new work.
// Accepted tasks become part of stop's drain before key cleanup. // Accepted tasks become part of stop's drain before key cleanup.
startBackground: (work: () => Promise<void>) => boolean startBackground: (work: () => Promise<void>) => boolean;
// fires when the service stops: long OBSERVATION waits (the invoice // fires when the service stops: long OBSERVATION waits (the invoice
// claim poll) must interrupt themselves on it. Work that has already // claim poll) must interrupt themselves on it. Work that has already
// reached a fund-critical commit must NOT consult it - stop awaits // reached a fund-critical commit must NOT consult it - stop awaits
// those tasks through its drain // those tasks through its drain
stopSignal: AbortSignal stopSignal: AbortSignal;
} };
+64 -68
View File
@@ -4,25 +4,25 @@
// results go OUT through deps.applyChangeset - this engine never touches // results go OUT through deps.applyChangeset - this engine never touches
// wallet state itself. // wallet state itself.
import {noteK1, sameInvoice} from 'lnurlcash-kit' import { noteK1, sameInvoice } from 'lnurlcash-kit';
import type {Bearer} from '../types' import type { Bearer } from '../types';
import type {PreparedMint} from '../ops' import type { PreparedMint } from '../ops';
import {prepareMint} from '../ops' import { prepareMint } from '../ops';
import {PollAbortedError} from '../ops/shared' import { PollAbortedError } from '../ops/shared';
import type {PendingInvoice, RequestContext} from './context' import type { PendingInvoice, RequestContext } from './context';
import {invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim} from './invoices' import { invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim } from './invoices';
import {handlePayInvoice} from './pay' import { handlePayInvoice } from './pay';
import type {NwcRequest, NwcResponse} from './protocol' import type { NwcRequest, NwcResponse } from './protocol';
import {NWC_METHODS, errResult, okResult} from './protocol' import { NWC_METHODS, errResult, okResult } from './protocol';
const MAX_PENDING_MINT_OUTPUTS = 20 const MAX_PENDING_MINT_OUTPUTS = 20;
// the same eligibility carve applies - the balance answers "what could // the same eligibility carve applies - the balance answers "what could
// this wallet actually pay with right now" // this wallet actually pay with right now"
const spendable = (bearer: Bearer): boolean => const spendable = (bearer: Bearer): boolean =>
!bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url) !bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url);
const handleGetInfo = (ctx: RequestContext): NwcResponse => const handleGetInfo = (ctx: RequestContext): NwcResponse =>
okResult('get_info', { okResult('get_info', {
@@ -35,7 +35,7 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse =>
network: 'mainnet', network: 'mainnet',
methods: [...NWC_METHODS], methods: [...NWC_METHODS],
notifications: [], notifications: [],
}) });
const handleGetBalance = (ctx: RequestContext): NwcResponse => const handleGetBalance = (ctx: RequestContext): NwcResponse =>
okResult('get_balance', { okResult('get_balance', {
@@ -43,21 +43,21 @@ const handleGetBalance = (ctx: RequestContext): NwcResponse =>
.getBearers() .getBearers()
.filter(spendable) .filter(spendable)
.reduce((sum, b) => sum + b.amount, 0), .reduce((sum, b) => sum + b.amount, 0),
}) });
const handleMakeInvoice = async ( const handleMakeInvoice = async (
ctx: RequestContext, ctx: RequestContext,
params: Record<string, unknown>, params: Record<string, unknown>,
): Promise<NwcResponse> => { ): Promise<NwcResponse> => {
const amountMsat = Number(params.amount) const amountMsat = Number(params.amount);
if (!Number.isInteger(amountMsat) || amountMsat <= 0) { if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.') return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.');
} }
const mint = ctx.deps.getDefaultMint() const mint = ctx.deps.getDefaultMint();
if (!mint) { if (!mint) {
return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.') return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.');
} }
const now = ctx.nowSeconds() const now = ctx.nowSeconds();
if ( if (
ctx.deps ctx.deps
.getBearers() .getBearers()
@@ -68,16 +68,16 @@ const handleMakeInvoice = async (
bearer.pendingMint.retireAfter <= now, bearer.pendingMint.retireAfter <= now,
) )
) { ) {
await ctx.deps.recoverPendingMints(ctx.assertOwner) await ctx.deps.recoverPendingMints(ctx.assertOwner);
} }
const pendingOutputs = ctx.deps const pendingOutputs = ctx.deps
.getBearers() .getBearers()
.filter((bearer) => bearer.pendingMint && !bearer.spent).length .filter((bearer) => bearer.pendingMint && !bearer.spent).length;
if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) { if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) {
return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.') return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.');
} }
let prepared: PreparedMint let prepared: PreparedMint;
let stagedOutput: Bearer | undefined let stagedOutput: Bearer | undefined;
try { try {
prepared = await prepareMint(mint, amountMsat, { prepared = await prepareMint(mint, amountMsat, {
...(ctx.deps.kit ?? {}), ...(ctx.deps.kit ?? {}),
@@ -85,49 +85,45 @@ const handleMakeInvoice = async (
allocateOutputSecrets: (server, count) => allocateOutputSecrets: (server, count) =>
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner), ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
persistOutput: async (note) => { persistOutput: async (note) => {
stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner) stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner);
}, },
}) });
} catch (err) { } catch (err) {
if (stagedOutput) { if (stagedOutput) {
try { try {
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner) await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
} catch (cleanupError) { } catch (cleanupError) {
return errResult( return errResult(
'make_invoice', 'make_invoice',
'INTERNAL', 'INTERNAL',
cleanupError instanceof Error ? cleanupError.message : String(cleanupError), cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
) );
} }
} }
return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err)) return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err));
} }
if (!stagedOutput) { if (!stagedOutput) {
return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.') return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.');
} }
const retireAfter = invoiceRetireAfter(prepared.invoice) const retireAfter = invoiceRetireAfter(prepared.invoice);
if (retireAfter !== null) { if (retireAfter !== null) {
try { try {
await ctx.deps.setMintOutputRetirement( await ctx.deps.setMintOutputRetirement(stagedOutput, retireAfter, ctx.assertOwner);
stagedOutput,
retireAfter,
ctx.assertOwner,
)
} catch (error) { } catch (error) {
try { try {
await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner) await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner);
} catch (cleanupError) { } catch (cleanupError) {
return errResult( return errResult(
'make_invoice', 'make_invoice',
'INTERNAL', 'INTERNAL',
cleanupError instanceof Error ? cleanupError.message : String(cleanupError), cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
) );
} }
return errResult( return errResult(
'make_invoice', 'make_invoice',
'INTERNAL', 'INTERNAL',
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
) );
} }
} }
const entry: PendingInvoice = { const entry: PendingInvoice = {
@@ -138,68 +134,68 @@ const handleMakeInvoice = async (
prepared, prepared,
stagedOutput, stagedOutput,
state: 'pending', state: 'pending',
} };
if (typeof params.description === 'string' && params.description) { if (typeof params.description === 'string' && params.description) {
entry.description = params.description entry.description = params.description;
} }
const expiry = Number(params.expiry) const expiry = Number(params.expiry);
if (Number.isInteger(expiry) && expiry > 0) { if (Number.isInteger(expiry) && expiry > 0) {
entry.expiresAt = entry.createdAt + expiry entry.expiresAt = entry.createdAt + expiry;
} }
ctx.invoices.set(entry.paymentHash, entry) ctx.invoices.set(entry.paymentHash, entry);
// phase two runs in the background; the invoice goes out now and // phase two runs in the background; the invoice goes out now and
// lookup_invoice reports the settlement the service-owned claim observes // lookup_invoice reports the settlement the service-owned claim observes
const started = ctx.startBackground(() => const started = ctx.startBackground(() =>
settleAndClaim(ctx, entry).catch((err) => { settleAndClaim(ctx, entry).catch((err) => {
entry.state = 'failed' entry.state = 'failed';
// an interrupted claim poll is normal service teardown (stop // an interrupted claim poll is normal service teardown (stop
// aborted it), not a background failure worth surfacing // aborted it), not a background failure worth surfacing
if (err instanceof PollAbortedError) return if (err instanceof PollAbortedError) return;
ctx.deps.onError?.(err, ctx.connection()) ctx.deps.onError?.(err, ctx.connection());
}), }),
) );
if (!started) { if (!started) {
entry.state = 'failed' entry.state = 'failed';
return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.') return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.');
}
return okResult('make_invoice', invoiceResult(entry))
} }
return okResult('make_invoice', invoiceResult(entry));
};
const handleLookupInvoice = (ctx: RequestContext, params: Record<string, unknown>): NwcResponse => { const handleLookupInvoice = (ctx: RequestContext, params: Record<string, unknown>): NwcResponse => {
const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined;
const hashParam = const hashParam =
typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined;
if (!invoiceParam && !hashParam) { if (!invoiceParam && !hashParam) {
return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.') return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.');
} }
let entry = hashParam ? ctx.invoices.get(hashParam) : undefined let entry = hashParam ? ctx.invoices.get(hashParam) : undefined;
if (!entry && invoiceParam) { if (!entry && invoiceParam) {
for (const candidate of ctx.invoices.values()) { for (const candidate of ctx.invoices.values()) {
if (sameInvoice(candidate.invoice, invoiceParam)) { if (sameInvoice(candidate.invoice, invoiceParam)) {
entry = candidate entry = candidate;
break break;
} }
} }
} }
if (!entry) { if (!entry) {
return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.') return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.');
}
return okResult('lookup_invoice', invoiceResult(entry))
} }
return okResult('lookup_invoice', invoiceResult(entry));
};
export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise<NwcResponse> => { export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise<NwcResponse> => {
switch (request.method) { switch (request.method) {
case 'get_info': case 'get_info':
return handleGetInfo(ctx) return handleGetInfo(ctx);
case 'get_balance': case 'get_balance':
return handleGetBalance(ctx) return handleGetBalance(ctx);
case 'make_invoice': case 'make_invoice':
return await handleMakeInvoice(ctx, request.params) return await handleMakeInvoice(ctx, request.params);
case 'pay_invoice': case 'pay_invoice':
return await handlePayInvoice(ctx, request.params) return await handlePayInvoice(ctx, request.params);
case 'lookup_invoice': case 'lookup_invoice':
return handleLookupInvoice(ctx, request.params) return handleLookupInvoice(ctx, request.params);
default: default:
return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`) return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`);
}
} }
};
+65 -65
View File
@@ -4,73 +4,73 @@
// receipt. Settlement becomes visible only after the staged bearer has // receipt. Settlement becomes visible only after the staged bearer has
// been replaced with the confirmed note details. // been replaced with the confirmed note details.
import {isPreimage, sameInvoice} from 'lnurlcash-kit' import { isPreimage, sameInvoice } from 'lnurlcash-kit';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import type {PreparedMint} from '../ops' import type { PreparedMint } from '../ops';
import {MintedNoteSpentError} from '../ops' import { MintedNoteSpentError } from '../ops';
import {claimFromSecret} from '../ops/mint' import { claimFromSecret } from '../ops/mint';
import {pollVerifyUntilSettled} from '../ops/shared' import { pollVerifyUntilSettled } from '../ops/shared';
import type {PollOptions} from '../ops/shared' import type { PollOptions } from '../ops/shared';
import type {PendingInvoice, RequestContext} from './context' import type { PendingInvoice, RequestContext } from './context';
export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = { export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 2000, intervalMs: 2000,
intervalCapMs: 10_000, intervalCapMs: 10_000,
maxWaitMs: 15 * 60_000, maxWaitMs: 15 * 60_000,
} };
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x') const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x');
const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600 const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600;
const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60 const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60;
// The kit has already validated the invoice before this parser runs. Read // The kit has already validated the invoice before this parser runs. Read
// only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays // only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays
// durable forever rather than risking retirement while it could still settle. // durable forever rather than risking retirement while it could still settle.
export const invoiceRetireAfter = (invoice: string): number | null => { export const invoiceRetireAfter = (invoice: string): number | null => {
const normalized = invoice.toLowerCase() const normalized = invoice.toLowerCase();
const separator = normalized.lastIndexOf('1') const separator = normalized.lastIndexOf('1');
if (separator < 1 || normalized.length - separator <= 6) return null if (separator < 1 || normalized.length - separator <= 6) return null;
const words = [...normalized.slice(separator + 1, -6)].map((character) => const words = [...normalized.slice(separator + 1, -6)].map((character) =>
BECH32_CHARSET.indexOf(character), BECH32_CHARSET.indexOf(character),
) );
if (words.length < 7 || words.some((word) => word < 0)) return null if (words.length < 7 || words.some((word) => word < 0)) return null;
const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0) const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0);
if (!Number.isSafeInteger(timestamp)) return null if (!Number.isSafeInteger(timestamp)) return null;
let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS;
let offset = 7 let offset = 7;
// Conformance invoices omit the 65-byte signature; real BOLT11 invoices // Conformance invoices omit the 65-byte signature; real BOLT11 invoices
// include its fixed 104 words after the tagged fields. // include its fixed 104 words after the tagged fields.
const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length;
while (offset + 3 <= taggedEnd) { while (offset + 3 <= taggedEnd) {
const tag = words[offset] const tag = words[offset];
const firstLength = words[offset + 1] const firstLength = words[offset + 1];
const secondLength = words[offset + 2] const secondLength = words[offset + 2];
if (tag === undefined || firstLength === undefined || secondLength === undefined) return null if (tag === undefined || firstLength === undefined || secondLength === undefined) return null;
const length = firstLength * 32 + secondLength const length = firstLength * 32 + secondLength;
const valueStart = offset + 3 const valueStart = offset + 3;
const valueEnd = valueStart + length const valueEnd = valueStart + length;
if (valueEnd > taggedEnd) return null if (valueEnd > taggedEnd) return null;
if (tag === BOLT11_EXPIRY_TAG) { if (tag === BOLT11_EXPIRY_TAG) {
expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0) expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0);
if (!Number.isSafeInteger(expiry)) return null if (!Number.isSafeInteger(expiry)) return null;
} }
offset = valueEnd offset = valueEnd;
}
const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS
return Number.isSafeInteger(retireAfter) ? retireAfter : null
} }
const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS;
return Number.isSafeInteger(retireAfter) ? retireAfter : null;
};
// LUD-21 verify URLs conventionally end in the payment hash. A service // LUD-21 verify URLs conventionally end in the payment hash. A service
// that uses another shape gets a stable wallet-local correlation id. // that uses another shape gets a stable wallet-local correlation id.
export const resolvePaymentHash = (prepared: PreparedMint): string => { export const resolvePaymentHash = (prepared: PreparedMint): string => {
const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1] const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1];
if (fromVerify) return fromVerify.toLowerCase() if (fromVerify) return fromVerify.toLowerCase();
return bytesToHex(sha256(utf8ToBytes(prepared.invoice))) return bytesToHex(sha256(utf8ToBytes(prepared.invoice)));
} };
export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => { export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => {
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
@@ -81,18 +81,18 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
amount: entry.amountMsat, amount: entry.amountMsat,
created_at: entry.createdAt, created_at: entry.createdAt,
metadata: {}, metadata: {},
} };
if (entry.description) result.description = entry.description if (entry.description) result.description = entry.description;
if (entry.expiresAt) result.expires_at = entry.expiresAt if (entry.expiresAt) result.expires_at = entry.expiresAt;
if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage;
if (entry.settledAt) result.settled_at = entry.settledAt if (entry.settledAt) result.settled_at = entry.settledAt;
return result return result;
} };
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => { export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
const prepared = entry.prepared const prepared = entry.prepared;
const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL;
let preimage: string | null = null let preimage: string | null = null;
if (prepared.verifyUrl) { if (prepared.verifyUrl) {
// Observation remains interruptible. Once settlement is observed, the // Observation remains interruptible. Once settlement is observed, the
// claim and durable update ignore shutdown while stop drains the task. // claim and durable update ignore shutdown while stop drains the task.
@@ -100,13 +100,13 @@ export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice)
prepared.verifyUrl, prepared.verifyUrl,
{ ...claimPoll, signal: ctx.stopSignal }, { ...claimPoll, signal: ctx.stopSignal },
ctx.deps.kit ?? {}, ctx.deps.kit ?? {},
) );
if (!sameInvoice(result.pr, prepared.invoice)) { if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.") throw new Error("The service's verify response is for a different invoice than requested.");
} }
if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage;
} }
let claimed let claimed;
try { try {
claimed = await claimFromSecret( claimed = await claimFromSecret(
prepared, prepared,
@@ -115,14 +115,14 @@ export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice)
...(ctx.deps.kit ?? {}), ...(ctx.deps.kit ?? {}),
assertOwner: ctx.assertOwner, assertOwner: ctx.assertOwner,
}, },
) );
} catch (error) { } catch (error) {
if (!(error instanceof MintedNoteSpentError)) throw error if (!(error instanceof MintedNoteSpentError)) throw error;
await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner) await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner);
throw error throw error;
}
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner)
entry.settledAt = ctx.nowSeconds()
entry.state = 'settled'
if (preimage) entry.preimage = preimage
} }
await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, ctx.assertOwner);
entry.settledAt = ctx.nowSeconds();
entry.state = 'settled';
if (preimage) entry.preimage = preimage;
};
+60 -60
View File
@@ -11,16 +11,16 @@ import {
isBolt11Invoice, isBolt11Invoice,
noteK1, noteK1,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {PayResult} from '../ops' import type { PayResult } from '../ops';
import {UncertainOutcomeError, payWithBearers} from '../ops' import { UncertainOutcomeError, payWithBearers } from '../ops';
import {budgetRemainingMsat, recordSpend} from './budget' import { budgetRemainingMsat, recordSpend } from './budget';
import type {NwcChangeset, RequestContext} from './context' import type { NwcChangeset, RequestContext } from './context';
import type {NwcResponse} from './protocol' import type { NwcResponse } from './protocol';
import {errResult, okResult} from './protocol' import { errResult, okResult } from './protocol';
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's // maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
// semantics: carve inputs lock spent, change is tracked, returned funds // semantics: carve inputs lock spent, change is tracked, returned funds
@@ -32,66 +32,66 @@ import {errResult, okResult} from './protocol'
// the next refresh to reconcile, exactly as the UI leaves it - the money // the next refresh to reconcile, exactly as the UI leaves it - the money
// itself sits in the re-secured note, which IS tracked. // itself sits in the re-secured note, which IS tracked.
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => { export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
const add: NewBearer[] = [] const add: NewBearer[] = [];
const markSpent: string[] = result.carve.consumed.map((b) => b.id) const markSpent: string[] = result.carve.consumed.map((b) => b.id);
// the staged change's URL may lack the signature the landed result's URL // the staged change's URL may lack the signature the landed result's URL
// carries - identify outputs by their secret, not the whole URL // carries - identify outputs by their secret, not the whole URL
const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null;
if ( if (
result.carve.change && result.carve.change &&
!bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1) !bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1)
) { ) {
add.push(result.carve.change) add.push(result.carve.change);
} }
const carvedK1 = noteK1(result.carve.note.url) const carvedK1 = noteK1(result.carve.note.url);
const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined;
if (result.outcome === 'failed-funds-returned') { if (result.outcome === 'failed-funds-returned') {
if (existing) { if (existing) {
const index = markSpent.indexOf(existing.id) const index = markSpent.indexOf(existing.id);
if (index >= 0) markSpent.splice(index, 1) if (index >= 0) markSpent.splice(index, 1);
} }
} else { } else {
// settled / still-pending / already-spent: the carved note is gone or // settled / still-pending / already-spent: the carved note is gone or
// locked. When it was one of the wallet's own bearers (an exact-match // locked. When it was one of the wallet's own bearers (an exact-match
// carve), lock that bearer; a freshly carved note is never added - // carve), lock that bearer; a freshly carved note is never added -
// it was born spent // it was born spent
if (existing) markSpent.push(existing.id) if (existing) markSpent.push(existing.id);
}
return {add, markSpent}
} }
return { add, markSpent };
};
export const handlePayInvoice = async ( export const handlePayInvoice = async (
ctx: RequestContext, ctx: RequestContext,
params: Record<string, unknown>, params: Record<string, unknown>,
): Promise<NwcResponse> => { ): Promise<NwcResponse> => {
const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '' const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '';
if (!isBolt11Invoice(invoice)) { if (!isBolt11Invoice(invoice)) {
return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.') return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.');
} }
// the melt must match the invoice's amount exactly, so the amount is // the melt must match the invoice's amount exactly, so the amount is
// read from the invoice itself - never trusted from the request // read from the invoice itself - never trusted from the request
const amountMsat = decodeBolt11AmountMsat(invoice) const amountMsat = decodeBolt11AmountMsat(invoice);
if (amountMsat === null || amountMsat <= 0) { if (amountMsat === null || amountMsat <= 0) {
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'OTHER', 'OTHER',
"Could not read this invoice's amount - amount-less invoices are not supported.", "Could not read this invoice's amount - amount-less invoices are not supported.",
) );
} }
if (params.amount !== undefined && params.amount !== amountMsat) { if (params.amount !== undefined && params.amount !== amountMsat) {
return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.") return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.");
} }
if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) { if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) {
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'QUOTA_EXCEEDED', 'QUOTA_EXCEEDED',
"This payment exceeds the connection's budget.", "This payment exceeds the connection's budget.",
) );
} }
const bearers = ctx.deps.getBearers() const bearers = ctx.deps.getBearers();
let result: PayResult let result: PayResult;
let committedCarve: Bearer | undefined let committedCarve: Bearer | undefined;
let returnStage: Bearer | undefined let returnStage: Bearer | undefined;
try { try {
result = await payWithBearers(bearers, invoice, { result = await payWithBearers(bearers, invoice, {
poll: ctx.deps.poll ?? {}, poll: ctx.deps.poll ?? {},
@@ -101,12 +101,12 @@ export const handlePayInvoice = async (
ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner), ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner),
mintSignatureKeys: ctx.deps.mintSignatureKeys, mintSignatureKeys: ctx.deps.mintSignatureKeys,
onCarve: async (carve) => { onCarve: async (carve) => {
committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner) committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner);
}, },
onReturnReady: async (carve, recoverySecret) => { onReturnReady: async (carve, recoverySecret) => {
const source = const source =
committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url) committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url);
if (!source) throw new Error('The carved note was not tracked.') if (!source) throw new Error('The carved note was not tracked.');
returnStage = await ctx.deps.persistMintOutput( returnStage = await ctx.deps.persistMintOutput(
{ {
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount), url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
@@ -116,15 +116,15 @@ export const handlePayInvoice = async (
pendingMint: { retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60 }, pendingMint: { retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60 },
}, },
ctx.assertOwner, ctx.assertOwner,
) );
await ctx.deps.reserveMintOutputSource( await ctx.deps.reserveMintOutputSource(
returnStage, returnStage,
source.id, source.id,
recoverySecret, recoverySecret,
ctx.assertOwner, ctx.assertOwner,
) );
}, },
}) });
} catch (err) { } catch (err) {
if (err instanceof UncertainOutcomeError) { if (err instanceof UncertainOutcomeError) {
// the carve's answer was lost and the probe couldn't tell: the // the carve's answer was lost and the probe couldn't tell: the
@@ -135,58 +135,58 @@ export const handlePayInvoice = async (
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'INTERNAL', 'INTERNAL',
'The payment preparation could not be confirmed; possible new notes were stored unverified.', 'The payment preparation could not be confirmed; possible new notes were stored unverified.',
) );
} }
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err);
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
/enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL', /enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL',
message, message,
) );
} }
if (returnStage) { if (returnStage) {
const returned = result.rotatedNote ?? result.rescuedNote const returned = result.rotatedNote ?? result.rescuedNote;
if (returned) { if (returned) {
await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner) await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner);
} else if (result.outcome !== 'unknown-still-pending') { } else if (result.outcome !== 'unknown-still-pending') {
await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner) await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner);
} }
} }
const spendRecorded = (): void => { const spendRecorded = (): void => {
// This conservative budget debit is persisted separately from bearer // This conservative budget debit is persisted separately from bearer
// storage. A later bearer commit failure does not roll it back. // storage. A later bearer commit failure does not roll it back.
const connection = ctx.connection().record const connection = ctx.connection().record;
ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now())) ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now()));
} };
switch (result.outcome) { switch (result.outcome) {
case 'settled': { case 'settled': {
spendRecorded() spendRecorded();
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
payChangeset(ctx.deps.getBearers(), result), payChangeset(ctx.deps.getBearers(), result),
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
// the receipt NIP-47 clients expect: the melt's own payment // the receipt NIP-47 clients expect: the melt's own payment
// preimage, re-read from the settle proof. A mint that reveals // preimage, re-read from the settle proof. A mint that reveals
// none yields an empty preimage rather than a fabricated one. // none yields an empty preimage rather than a fabricated one.
let preimage = '' let preimage = '';
if (result.verifyUrl) { if (result.verifyUrl) {
try { try {
const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {}) const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {});
preimage = proof.preimage ?? '' preimage = proof.preimage ?? '';
} catch (error) { } catch (error) {
// the settle proof was already polled inside payWithBearers; // the settle proof was already polled inside payWithBearers;
// a failed re-read must not flip the outcome // a failed re-read must not flip the outcome
if (!(error instanceof Error)) throw error if (!(error instanceof Error)) throw error;
} }
} }
return okResult('pay_invoice', {preimage}) return okResult('pay_invoice', { preimage });
} }
case 'failed-funds-returned': case 'failed-funds-returned':
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
@@ -194,36 +194,36 @@ export const handlePayInvoice = async (
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'PAYMENT_FAILED', 'PAYMENT_FAILED',
'The payment failed; the funds are back in the wallet.', 'The payment failed; the funds are back in the wallet.',
) );
case 'note-already-spent': case 'note-already-spent':
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
payChangeset(ctx.deps.getBearers(), result), payChangeset(ctx.deps.getBearers(), result),
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'PAYMENT_FAILED', 'PAYMENT_FAILED',
'The note backing this payment was already spent; nothing was paid.', 'The note backing this payment was already spent; nothing was paid.',
) );
case 'unknown-still-pending': case 'unknown-still-pending':
spendRecorded() spendRecorded();
await ctx.deps.applyChangeset( await ctx.deps.applyChangeset(
payChangeset(ctx.deps.getBearers(), result), payChangeset(ctx.deps.getBearers(), result),
ctx.connection(), ctx.connection(),
'pay_invoice', 'pay_invoice',
ctx.assertOwner, ctx.assertOwner,
) );
return errResult( return errResult(
'pay_invoice', 'pay_invoice',
'OTHER', 'OTHER',
'The payment is still in flight; the note stays locked until it reconciles.', 'The payment is still in flight; the note stays locked until it reconciles.',
) );
}
} }
};
+51 -50
View File
@@ -13,16 +13,16 @@
// - a kind 13194 replaceable info event advertises the supported methods // - a kind 13194 replaceable info event advertises the supported methods
// and encryption schemes // and encryption schemes
import type {Event as NostrEvent} from 'nostr-tools/core' import type { Event as NostrEvent } from 'nostr-tools/core';
import {finalizeEvent, verifyEvent} from 'nostr-tools/pure' import { finalizeEvent, verifyEvent } from 'nostr-tools/pure';
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import { encrypt as nip04Encrypt, decrypt as nip04Decrypt } from 'nostr-tools/nip04';
import {v2 as nip44v2} from 'nostr-tools/nip44' import { v2 as nip44v2 } from 'nostr-tools/nip44';
export type {NostrEvent} export type { NostrEvent };
export const NWC_REQUEST_KIND = 23194 export const NWC_REQUEST_KIND = 23194;
export const NWC_RESPONSE_KIND = 23195 export const NWC_RESPONSE_KIND = 23195;
export const NWC_INFO_KIND = 13194 export const NWC_INFO_KIND = 13194;
export const NWC_METHODS = [ export const NWC_METHODS = [
'get_info', 'get_info',
@@ -30,9 +30,9 @@ export const NWC_METHODS = [
'make_invoice', 'make_invoice',
'pay_invoice', 'pay_invoice',
'lookup_invoice', 'lookup_invoice',
] as const ] as const;
export type NwcMethod = (typeof NWC_METHODS)[number] export type NwcMethod = (typeof NWC_METHODS)[number];
export type NwcErrorCode = export type NwcErrorCode =
| 'RATE_LIMITED' | 'RATE_LIMITED'
@@ -45,38 +45,38 @@ export type NwcErrorCode =
| 'UNSUPPORTED_ENCRYPTION' | 'UNSUPPORTED_ENCRYPTION'
| 'PAYMENT_FAILED' | 'PAYMENT_FAILED'
| 'NOT_FOUND' | 'NOT_FOUND'
| 'OTHER' | 'OTHER';
export type NwcRequest = { export type NwcRequest = {
method: string method: string;
params: Record<string, unknown> params: Record<string, unknown>;
} };
export type NwcResponse = { export type NwcResponse = {
result_type: string result_type: string;
error: {code: NwcErrorCode; message: string} | null error: { code: NwcErrorCode; message: string } | null;
result: unknown result: unknown;
} };
export const okResult = (method: string, result: unknown): NwcResponse => ({ export const okResult = (method: string, result: unknown): NwcResponse => ({
result_type: method, result_type: method,
error: null, error: null,
result, result,
}) });
export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({ export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({
result_type: method, result_type: method,
error: { code, message }, error: { code, message },
result: null, result: null,
}) });
// the two encryption schemes this service speaks; the scheme of a request // the two encryption schemes this service speaks; the scheme of a request
// decides the scheme of its response (NIP-47: "Encrypted using the scheme // decides the scheme of its response (NIP-47: "Encrypted using the scheme
// requested by the client") // requested by the client")
export type NwcEncryption = 'nip44_v2' | 'nip04' export type NwcEncryption = 'nip44_v2' | 'nip04';
const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array => const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array =>
nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey) nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey);
export const encryptFor = ( export const encryptFor = (
scheme: NwcEncryption, scheme: NwcEncryption,
@@ -86,7 +86,7 @@ export const encryptFor = (
): string => ): string =>
scheme === 'nip44_v2' scheme === 'nip44_v2'
? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey)) ? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey))
: nip04Encrypt(walletSecretKey, clientPubkey, plaintext) : nip04Encrypt(walletSecretKey, clientPubkey, plaintext);
const decryptFrom = ( const decryptFrom = (
scheme: NwcEncryption, scheme: NwcEncryption,
@@ -96,10 +96,10 @@ const decryptFrom = (
): string => ): string =>
scheme === 'nip44_v2' scheme === 'nip44_v2'
? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey)) ? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey))
: nip04Decrypt(walletSecretKey, clientPubkey, content) : nip04Decrypt(walletSecretKey, clientPubkey, content);
const tagValue = (event: NostrEvent, name: string): string | undefined => const tagValue = (event: NostrEvent, name: string): string | undefined =>
event.tags.find((t) => t[0] === name)?.[1] event.tags.find((t) => t[0] === name)?.[1];
// The outcome of validating + decrypting a candidate request event: // The outcome of validating + decrypting a candidate request event:
// - a request to dispatch (encryption scheme carried so the response can // - a request to dispatch (encryption scheme carried so the response can
@@ -113,7 +113,7 @@ const tagValue = (event: NostrEvent, name: string): string | undefined =>
// demands // demands
export type DecryptedNwcRequest = export type DecryptedNwcRequest =
| { respond: false; request: NwcRequest; encryption: NwcEncryption } | { respond: false; request: NwcRequest; encryption: NwcEncryption }
| {respond: true; response: NwcResponse; encryption: NwcEncryption} | { respond: true; response: NwcResponse; encryption: NwcEncryption };
export const decryptRequest = ( export const decryptRequest = (
walletSecretKey: Uint8Array, walletSecretKey: Uint8Array,
@@ -122,24 +122,24 @@ export const decryptRequest = (
event: NostrEvent, event: NostrEvent,
nowSeconds: number = Math.floor(Date.now() / 1000), nowSeconds: number = Math.floor(Date.now() / 1000),
): DecryptedNwcRequest | null => { ): DecryptedNwcRequest | null => {
if (event.kind !== NWC_REQUEST_KIND) return null if (event.kind !== NWC_REQUEST_KIND) return null;
// only the authorized client may talk to this connection, and the // only the authorized client may talk to this connection, and the
// request must actually be addressed to it // request must actually be addressed to it
if (event.pubkey !== clientPubkey) return null if (event.pubkey !== clientPubkey) return null;
if (tagValue(event, 'p') !== walletServicePubkey) return null if (tagValue(event, 'p') !== walletServicePubkey) return null;
if (!verifyEvent(event)) return null if (!verifyEvent(event)) return null;
// an expired request is ignored, never answered (NIP-47) // an expired request is ignored, never answered (NIP-47)
const expiration = tagValue(event, 'expiration') const expiration = tagValue(event, 'expiration');
if (expiration !== undefined && Number(expiration) < nowSeconds) { if (expiration !== undefined && Number(expiration) < nowSeconds) {
return null return null;
} }
// encryption negotiation: no tag means legacy NIP-04 (NIP-47) // encryption negotiation: no tag means legacy NIP-04 (NIP-47)
const advertised = tagValue(event, 'encryption') const advertised = tagValue(event, 'encryption');
let encryption: NwcEncryption let encryption: NwcEncryption;
if (advertised === undefined || advertised === 'nip04') { if (advertised === undefined || advertised === 'nip04') {
encryption = 'nip04' encryption = 'nip04';
} else if (advertised === 'nip44_v2') { } else if (advertised === 'nip44_v2') {
encryption = 'nip44_v2' encryption = 'nip44_v2';
} else { } else {
// the client asked for a scheme we don't speak - answer in the // the client asked for a scheme we don't speak - answer in the
// legacy default, the one scheme every NIP-47 client must read // legacy default, the one scheme every NIP-47 client must read
@@ -151,24 +151,24 @@ export const decryptRequest = (
'UNSUPPORTED_ENCRYPTION', 'UNSUPPORTED_ENCRYPTION',
`Unsupported encryption scheme: ${advertised}.`, `Unsupported encryption scheme: ${advertised}.`,
), ),
};
} }
} let plaintext: string;
let plaintext: string
try { try {
plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content) plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content);
} catch { } catch {
// undecryptable - indistinguishable from relay noise; stay silent // undecryptable - indistinguishable from relay noise; stay silent
return null return null;
} }
let data: unknown let data: unknown;
try { try {
data = JSON.parse(plaintext) data = JSON.parse(plaintext);
} catch { } catch {
return { return {
respond: true, respond: true,
encryption, encryption,
response: errResult('', 'OTHER', 'The request is not valid JSON.'), response: errResult('', 'OTHER', 'The request is not valid JSON.'),
} };
} }
if ( if (
typeof data !== 'object' || typeof data !== 'object' ||
@@ -180,12 +180,13 @@ export const decryptRequest = (
respond: true, respond: true,
encryption, encryption,
response: errResult('', 'OTHER', 'The request has no method.'), response: errResult('', 'OTHER', 'The request has no method.'),
};
} }
} const request = data as NwcRequest;
const request = data as NwcRequest const params =
const params = typeof request.params === 'object' && request.params !== null ? request.params : {} typeof request.params === 'object' && request.params !== null ? request.params : {};
return {respond: false, request: {method: request.method, params}, encryption} return { respond: false, request: { method: request.method, params }, encryption };
} };
// signs the kind-23195 answer to a request, mirroring its encryption // signs the kind-23195 answer to a request, mirroring its encryption
// scheme and referencing it via the 'e' tag // scheme and referencing it via the 'e' tag
@@ -209,7 +210,7 @@ export const buildResponseEvent = (
content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)), content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)),
}, },
walletSecretKey, walletSecretKey,
) );
// the replaceable info event advertising this service's capabilities // the replaceable info event advertising this service's capabilities
export const buildInfoEvent = ( export const buildInfoEvent = (
@@ -224,4 +225,4 @@ export const buildInfoEvent = (
content: NWC_METHODS.join(' '), content: NWC_METHODS.join(' '),
}, },
walletSecretKey, walletSecretKey,
) );
+96 -94
View File
@@ -14,60 +14,60 @@
// foreground-only design itself is documented in the nwc.ts façade // foreground-only design itself is documented in the nwc.ts façade
// header. // header.
import {linkingPubKeyHex} from '../keys' import { linkingPubKeyHex } from '../keys';
import type {NwcConnectionRecord} from '../storage/nwcConnections' import type { NwcConnectionRecord } from '../storage/nwcConnections';
import {readNwcConnections} from '../storage/nwcConnections' import { readNwcConnections } from '../storage/nwcConnections';
import {assertSavedKeyOwner} from '../storage/currentOwner' import { assertSavedKeyOwner } from '../storage/currentOwner';
import type {NwcConnectionInfo} from './connection' import type { NwcConnectionInfo } from './connection';
import {deriveNwcWalletKey, nwcWalletPubkey} from './connection' import { deriveNwcWalletKey, nwcWalletPubkey } from './connection';
import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context' import type { NwcServiceDeps, PendingInvoice, RequestContext } from './context';
import {dispatch} from './dispatch' import { dispatch } from './dispatch';
import type {NostrEvent, NwcEncryption, NwcRequest, NwcResponse} from './protocol' import type { NostrEvent, NwcEncryption, NwcRequest, NwcResponse } from './protocol';
import {NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest} from './protocol' import { NWC_REQUEST_KIND, buildInfoEvent, buildResponseEvent, decryptRequest } from './protocol';
import type {NwcSubscription} from './transport' import type { NwcSubscription } from './transport';
import {defaultNwcTransport} from './transport' import { defaultNwcTransport } from './transport';
export type {NwcConnectionInfo} export type { NwcConnectionInfo };
export type {NwcChangeset, NwcServiceDeps} from './context' export type { NwcChangeset, NwcServiceDeps } from './context';
// requests older than this are dropped unanswered (see the header) // requests older than this are dropped unanswered (see the header)
const MAX_REQUEST_AGE_SECONDS = 600 const MAX_REQUEST_AGE_SECONDS = 600;
type ConnectionRuntime = { type ConnectionRuntime = {
info: NwcConnectionInfo info: NwcConnectionInfo;
// nulled and zeroed only after every tracked handler drains // nulled and zeroed only after every tracked handler drains
walletSecret: Uint8Array | null walletSecret: Uint8Array | null;
// invoices this connection issued, by payment hash - in-memory only: // invoices this connection issued, by payment hash - in-memory only:
// pending invoices don't survive a restart (lookup then answers // pending invoices don't survive a restart (lookup then answers
// NOT_FOUND), same as any foreground-only wallet // NOT_FOUND), same as any foreground-only wallet
invoices: Map<string, PendingInvoice> invoices: Map<string, PendingInvoice>;
// serializes pay_invoice handling per connection - the budget check and // serializes pay_invoice handling per connection - the budget check and
// the spend record can't interleave // the spend record can't interleave
queue: Promise<unknown> queue: Promise<unknown>;
sub: NwcSubscription sub: NwcSubscription;
} };
export type NwcService = { export type NwcService = {
// a snapshot of the served connections at startup; the persisted // a snapshot of the served connections at startup; the persisted
// records (storage/nwcConnections) carry the live budget state // records (storage/nwcConnections) carry the live budget state
connections: NwcConnectionInfo[] connections: NwcConnectionInfo[];
// closes every relay subscription. In-flight handlers still finish - // closes every relay subscription. In-flight handlers still finish -
// their changesets hold money - but no new requests are picked up // their changesets hold money - but no new requests are picked up
stop: () => Promise<void> stop: () => Promise<void>;
} };
export const startService = async ( export const startService = async (
linkingPrivKey: Uint8Array, linkingPrivKey: Uint8Array,
deps: NwcServiceDeps, deps: NwcServiceDeps,
records?: NwcConnectionRecord[], records?: NwcConnectionRecord[],
): Promise<NwcService> => { ): Promise<NwcService> => {
const transport = deps.transport ?? (await defaultNwcTransport()) const transport = deps.transport ?? (await defaultNwcTransport());
const ownerId = linkingPubKeyHex(linkingPrivKey) const ownerId = linkingPubKeyHex(linkingPrivKey);
const ownedRecords = (records ?? readNwcConnections(ownerId)).filter( const ownedRecords = (records ?? readNwcConnections(ownerId)).filter(
(record) => record.ownerId === ownerId, (record) => record.ownerId === ownerId,
) );
const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000) const nowSeconds = (): number => deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
const publishResponse = async ( const publishResponse = async (
runtime: ConnectionRuntime, runtime: ConnectionRuntime,
@@ -86,10 +86,10 @@ export const startService = async (
response, response,
nowSeconds(), nowSeconds(),
), ),
) );
} };
let invoiceQueue: Promise<unknown> = Promise.resolve() let invoiceQueue: Promise<unknown> = Promise.resolve();
// Payments serialize per connection for budget atomicity. Invoice // Payments serialize per connection for budget atomicity. Invoice
// creation serializes across the service so different clients cannot // creation serializes across the service so different clients cannot
@@ -103,37 +103,37 @@ export const startService = async (
const run = invoiceQueue.then( const run = invoiceQueue.then(
() => dispatch(ctx, request), () => dispatch(ctx, request),
() => dispatch(ctx, request), () => dispatch(ctx, request),
) );
invoiceQueue = run.catch(() => undefined) invoiceQueue = run.catch(() => undefined);
return run return run;
} }
if (request.method !== 'pay_invoice') return dispatch(ctx, request) if (request.method !== 'pay_invoice') return dispatch(ctx, request);
const run = runtime.queue.then( const run = runtime.queue.then(
() => dispatch(ctx, request), () => dispatch(ctx, request),
() => dispatch(ctx, request), () => dispatch(ctx, request),
) );
runtime.queue = run.catch(() => undefined) runtime.queue = run.catch(() => undefined);
return run return run;
} };
const handleEvent = async ( const handleEvent = async (
runtime: ConnectionRuntime, runtime: ConnectionRuntime,
ctx: RequestContext, ctx: RequestContext,
event: NostrEvent, event: NostrEvent,
): Promise<void> => { ): Promise<void> => {
const walletSecret = runtime.walletSecret const walletSecret = runtime.walletSecret;
if (walletSecret === null) return if (walletSecret === null) return;
const at = nowSeconds() const at = nowSeconds();
// replay safety (see the header): too-old requests are dropped // replay safety (see the header): too-old requests are dropped
if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return;
const decrypted = decryptRequest( const decrypted = decryptRequest(
walletSecret, walletSecret,
runtime.info.walletServicePubkey, runtime.info.walletServicePubkey,
runtime.info.record.clientPubkey, runtime.info.record.clientPubkey,
event, event,
at, at,
) );
if (decrypted === null) return if (decrypted === null) return;
if (decrypted.respond) { if (decrypted.respond) {
await publishResponse( await publishResponse(
runtime, runtime,
@@ -141,32 +141,32 @@ export const startService = async (
event.id, event.id,
decrypted.encryption, decrypted.encryption,
decrypted.response, decrypted.response,
) );
return return;
}
const response = await dispatchSerialized(runtime, ctx, decrypted.request)
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response)
} }
const response = await dispatchSerialized(runtime, ctx, decrypted.request);
await publishResponse(runtime, walletSecret, event.id, decrypted.encryption, response);
};
let accepting = true let accepting = true;
// interrupts long observation waits (the invoice claim poll) at stop; // interrupts long observation waits (the invoice claim poll) at stop;
// the drain below still awaits tasks that reached a fund-critical commit // the drain below still awaits tasks that reached a fund-critical commit
const stopController = new AbortController() const stopController = new AbortController();
const inFlight = new Set<Promise<void>>() const inFlight = new Set<Promise<void>>();
const track = (task: Promise<void>): void => { const track = (task: Promise<void>): void => {
inFlight.add(task) inFlight.add(task);
void task.then( void task.then(
() => inFlight.delete(task), () => inFlight.delete(task),
() => inFlight.delete(task), () => inFlight.delete(task),
) );
} };
const startBackground = (work: () => Promise<void>): boolean => { const startBackground = (work: () => Promise<void>): boolean => {
if (!accepting) return false if (!accepting) return false;
track(work()) track(work());
return true return true;
} };
let runtimes = ownedRecords.map((record) => { let runtimes = ownedRecords.map((record) => {
const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey) const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey);
const runtime: ConnectionRuntime = { const runtime: ConnectionRuntime = {
info: { record, walletServicePubkey: nwcWalletPubkey(walletSecret) }, info: { record, walletServicePubkey: nwcWalletPubkey(walletSecret) },
walletSecret, walletSecret,
@@ -175,22 +175,22 @@ export const startService = async (
// replaced below, immediately - the field exists because the // replaced below, immediately - the field exists because the
// subscription callback closes over the runtime // subscription callback closes over the runtime
sub: { close: () => undefined }, sub: { close: () => undefined },
} };
const ctx: RequestContext = { const ctx: RequestContext = {
deps, deps,
connection: () => runtime.info, connection: () => runtime.info,
updateRecord: (updated) => { updateRecord: (updated) => {
runtime.info = {...runtime.info, record: updated} runtime.info = { ...runtime.info, record: updated };
}, },
invoices: runtime.invoices, invoices: runtime.invoices,
nowSeconds, nowSeconds,
assertOwner: () => { assertOwner: () => {
deps.assertCurrentOwner() deps.assertCurrentOwner();
assertSavedKeyOwner(ownerId) assertSavedKeyOwner(ownerId);
}, },
startBackground, startBackground,
stopSignal: stopController.signal, stopSignal: stopController.signal,
} };
runtime.sub = transport.subscribe( runtime.sub = transport.subscribe(
record.relays, record.relays,
{ {
@@ -199,60 +199,62 @@ export const startService = async (
since: nowSeconds(), since: nowSeconds(),
}, },
(event) => { (event) => {
if (!accepting) return if (!accepting) return;
const handler = handleEvent(runtime, ctx, event).catch((err) => { const handler = handleEvent(runtime, ctx, event).catch((err) => {
deps.onError?.(err, runtime.info) deps.onError?.(err, runtime.info);
}) });
track(handler) track(handler);
}, },
) );
return runtime return runtime;
}) });
// info events: best-effort - a rejected publish must not sink startup; // info events: best-effort - a rejected publish must not sink startup;
// the client learns capabilities from its first error-free exchange too // the client learns capabilities from its first error-free exchange too
for (const runtime of runtimes) { for (const runtime of runtimes) {
const walletSecret = runtime.walletSecret const walletSecret = runtime.walletSecret;
if (walletSecret === null) continue if (walletSecret === null) continue;
try { try {
await transport.publish( await transport.publish(
runtime.info.record.relays, runtime.info.record.relays,
buildInfoEvent(walletSecret, nowSeconds()), buildInfoEvent(walletSecret, nowSeconds()),
) );
} catch (error) { } catch (error) {
deps.onError?.( deps.onError?.(
error instanceof Error ? error : new Error('NWC info publication failed.', {cause: error}), error instanceof Error
? error
: new Error('NWC info publication failed.', { cause: error }),
runtime.info, runtime.info,
) );
} }
} }
const connections = runtimes.map((runtime) => runtime.info) const connections = runtimes.map((runtime) => runtime.info);
let stopPromise: Promise<void> | null = null let stopPromise: Promise<void> | null = null;
const stop = (): Promise<void> => { const stop = (): Promise<void> => {
if (stopPromise !== null) return stopPromise if (stopPromise !== null) return stopPromise;
accepting = false accepting = false;
for (const runtime of runtimes) runtime.sub.close() for (const runtime of runtimes) runtime.sub.close();
stopController.abort() stopController.abort();
stopPromise = Promise.allSettled([...inFlight]) stopPromise = Promise.allSettled([...inFlight])
.then((results) => { .then((results) => {
for (const result of results) { for (const result of results) {
if (result.status === 'rejected') throw result.reason if (result.status === 'rejected') throw result.reason;
} }
}) })
.finally(() => { .finally(() => {
for (const runtime of runtimes) { for (const runtime of runtimes) {
runtime.walletSecret?.fill(0) runtime.walletSecret?.fill(0);
runtime.walletSecret = null runtime.walletSecret = null;
runtime.invoices.clear() runtime.invoices.clear();
}
runtimes = []
})
return stopPromise
} }
runtimes = [];
});
return stopPromise;
};
return { return {
connections, connections,
stop, stop,
} };
} };
+16 -15
View File
@@ -4,36 +4,37 @@
// importing the service module never opens (or even references) a // importing the service module never opens (or even references) a
// WebSocket. // WebSocket.
import type {Filter as NostrFilter} from 'nostr-tools/filter' import type { Filter as NostrFilter } from 'nostr-tools/filter';
import type {NostrEvent} from './protocol' import type { NostrEvent } from './protocol';
export type {NostrFilter} export type { NostrFilter };
export type NwcSubscription = { export type NwcSubscription = {
close: () => void close: () => void;
} };
export type NwcTransport = { export type NwcTransport = {
publish: (relays: string[], event: NostrEvent) => Promise<void> publish: (relays: string[], event: NostrEvent) => Promise<void>;
subscribe: ( subscribe: (
relays: string[], relays: string[],
filter: NostrFilter, filter: NostrFilter,
onEvent: (event: NostrEvent) => void, onEvent: (event: NostrEvent) => void,
) => NwcSubscription ) => NwcSubscription;
} };
export const defaultNwcTransport = async (): Promise<NwcTransport> => { export const defaultNwcTransport = async (): Promise<NwcTransport> => {
const {SimplePool} = await import('nostr-tools/pool') const { SimplePool } = await import('nostr-tools/pool');
const pool = new SimplePool() const pool = new SimplePool();
return { return {
publish: async (relays, event) => { publish: async (relays, event) => {
const results = await Promise.allSettled(pool.publish(relays, event)) const results = await Promise.allSettled(pool.publish(relays, event));
// one honest relay accepting is enough - same rule as the backup // one honest relay accepting is enough - same rule as the backup
if (!results.some((r) => r.status === 'fulfilled')) { if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the event.') throw new Error('No relay accepted the event.');
} }
}, },
subscribe: (relays, filter, onEvent) => pool.subscribeMany(relays, filter, {onevent: onEvent}), subscribe: (relays, filter, onEvent) =>
} pool.subscribeMany(relays, filter, { onevent: onEvent }),
} };
};
+99 -97
View File
@@ -1,165 +1,167 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {noteK1} from 'lnurlcash-kit' import { noteK1 } from 'lnurlcash-kit';
import type {CarveResult} from './ops' import type { CarveResult } from './ops';
import {ensureExactAmount, UnsupportedMultiBatchMergeError} from './ops' import { ensureExactAmount, UnsupportedMultiBatchMergeError } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import {makeBearer, mint, secret} from './ops.testHarness' import { makeBearer, mint, secret } from './ops.testHarness';
const allocatedSecrets = (...secrets: string[]): ((server: string, count: number) => Promise<readonly string[]>) => { const allocatedSecrets = (
let served = false ...secrets: string[]
): ((server: string, count: number) => Promise<readonly string[]>) => {
let served = false;
return (_server, count) => { return (_server, count) => {
if (served) throw new Error('The test expected a single allocation.') if (served) throw new Error('The test expected a single allocation.');
served = true served = true;
return Promise.resolve(secrets.slice(0, count)) return Promise.resolve(secrets.slice(0, count));
} };
} };
describe('ensureExactAmount crash safety', () => { describe('ensureExactAmount crash safety', () => {
it('stages both split secrets before either hash reaches the mint', async () => { it('stages both split secrets before either hash reaches the mint', async () => {
const instance = await mint() const instance = await mint();
const sourceSecret = secret('70') const sourceSecret = secret('70');
const targetSecret = secret('71') const targetSecret = secret('71');
const changeSecret = secret('72') const changeSecret = secret('72');
const bearer = await makeBearer(instance, sourceSecret, 21_000) const bearer = await makeBearer(instance, sourceSecret, 21_000);
let staged: CarveResult | undefined let staged: CarveResult | undefined;
const observingFetch: typeof fetch = (input, init) => { const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) { if (url.includes('/w/cb')) {
expect(staged).toBeDefined() expect(staged).toBeDefined();
expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret) expect(noteK1(requiredValue(staged).note.url)).toBe(targetSecret);
expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret) expect(noteK1(requiredValue(requiredValue(staged).change).url)).toBe(changeSecret);
expect(instance.state.noteState(sourceSecret)).toBe('outstanding') expect(instance.state.noteState(sourceSecret)).toBe('outstanding');
}
return fetch(input, init)
} }
return fetch(input, init);
};
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
fetch: observingFetch, fetch: observingFetch,
allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret), allocateOutputSecrets: allocatedSecrets(targetSecret, changeSecret),
onCarve: (prepared) => { onCarve: (prepared) => {
staged = prepared staged = prepared;
}, },
}) });
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
}) });
it('stages the merge secret before its hash reaches the mint', async () => { it('stages the merge secret before its hash reaches the mint', async () => {
const instance = await mint() const instance = await mint();
const firstSecret = secret('73') const firstSecret = secret('73');
const secondSecret = secret('74') const secondSecret = secret('74');
const mergedSecret = secret('75') const mergedSecret = secret('75');
const first = await makeBearer(instance, firstSecret, 3_000) const first = await makeBearer(instance, firstSecret, 3_000);
const second = await makeBearer(instance, secondSecret, 4_000) const second = await makeBearer(instance, secondSecret, 4_000);
let staged: CarveResult | undefined let staged: CarveResult | undefined;
const observingFetch: typeof fetch = (input, init) => { const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) { if (url.includes('/w/cb')) {
expect(staged).toBeDefined() expect(staged).toBeDefined();
expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret) expect(noteK1(requiredValue(staged).note.url)).toBe(mergedSecret);
expect(instance.state.noteState(firstSecret)).toBe('outstanding') expect(instance.state.noteState(firstSecret)).toBe('outstanding');
expect(instance.state.noteState(secondSecret)).toBe('outstanding') expect(instance.state.noteState(secondSecret)).toBe('outstanding');
}
return fetch(input, init)
} }
return fetch(input, init);
};
await ensureExactAmount([first, second], 7_000, { await ensureExactAmount([first, second], 7_000, {
fetch: observingFetch, fetch: observingFetch,
allocateOutputSecrets: allocatedSecrets(mergedSecret), allocateOutputSecrets: allocatedSecrets(mergedSecret),
onCarve: (prepared) => { onCarve: (prepared) => {
staged = prepared staged = prepared;
}, },
}) });
expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id]) expect(staged?.consumed.map((entry) => entry.id)).toEqual([first.id, second.id]);
}) });
it('does not expose a staged split output to a second ambiguous rotation', async () => { it('does not expose a staged split output to a second ambiguous rotation', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('76'), 21_000) const bearer = await makeBearer(instance, secret('76'), 21_000);
let mutationCount = 0 let mutationCount = 0;
const dropSecondMutation: typeof fetch = async (input, init) => { const dropSecondMutation: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!url.includes('/w/cb')) return fetch(input, init) if (!url.includes('/w/cb')) return fetch(input, init);
mutationCount += 1 mutationCount += 1;
const response = await fetch(input, init) const response = await fetch(input, init);
if (mutationCount === 2) { if (mutationCount === 2) {
await response.arrayBuffer() await response.arrayBuffer();
throw new Error('second mutation response lost') throw new Error('second mutation response lost');
}
return response
} }
return response;
};
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
fetch: dropSecondMutation, fetch: dropSecondMutation,
mutationRetries: 0, mutationRetries: 0,
allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')), allocateOutputSecrets: allocatedSecrets(secret('77'), secret('78')),
onCarve: () => undefined, onCarve: () => undefined,
}) });
expect(mutationCount).toBe(1) expect(mutationCount).toBe(1);
const targetSecret = requiredValue(noteK1(result.note.url)) const targetSecret = requiredValue(noteK1(result.note.url));
const changeSecret = requiredValue(noteK1(requiredValue(result.change).url)) const changeSecret = requiredValue(noteK1(requiredValue(result.change).url));
expect(instance.state.noteState(targetSecret)).toBe('outstanding') expect(instance.state.noteState(targetSecret)).toBe('outstanding');
expect(instance.state.noteState(changeSecret)).toBe('outstanding') expect(instance.state.noteState(changeSecret)).toBe('outstanding');
}) });
it('rejects a multi-batch merge before any partial fold can land', async () => { it('rejects a multi-batch merge before any partial fold can land', async () => {
const instance = await mint() const instance = await mint();
const bearers = await Promise.all( const bearers = await Promise.all(
Array.from({ length: 21 }, (_, index) => Array.from({ length: 21 }, (_, index) =>
makeBearer(instance, secret((128 + index).toString(16)), 1_000), makeBearer(instance, secret((128 + index).toString(16)), 1_000),
), ),
) );
let mutationCount = 0 let mutationCount = 0;
const countingFetch: typeof fetch = (input, init) => { const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutationCount += 1 if (url.includes('/w/cb')) mutationCount += 1;
return fetch(input, init) return fetch(input, init);
} };
const failure = ensureExactAmount(bearers, 21_000, { const failure = ensureExactAmount(bearers, 21_000, {
fetch: countingFetch, fetch: countingFetch,
onCarve: () => undefined, onCarve: () => undefined,
}) });
await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError) await expect(failure).rejects.toBeInstanceOf(UnsupportedMultiBatchMergeError);
expect(mutationCount).toBe(0) expect(mutationCount).toBe(0);
expect( expect(
bearers.every( bearers.every(
(bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding', (bearer) => instance.state.noteState(requiredValue(noteK1(bearer.url))) === 'outstanding',
), ),
).toBe(true) ).toBe(true);
}) });
it('checks ownership again immediately before a merge mutation', async () => { it('checks ownership again immediately before a merge mutation', async () => {
const instance = await mint() const instance = await mint();
const firstSecret = secret('79') const firstSecret = secret('79');
const secondSecret = secret('7a') const secondSecret = secret('7a');
const first = await makeBearer(instance, firstSecret, 3_000) const first = await makeBearer(instance, firstSecret, 3_000);
const second = await makeBearer(instance, secondSecret, 4_000) const second = await makeBearer(instance, secondSecret, 4_000);
let ownerChecks = 0 let ownerChecks = 0;
let mutationCount = 0 let mutationCount = 0;
const countingFetch: typeof fetch = (input, init) => { const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutationCount += 1 if (url.includes('/w/cb')) mutationCount += 1;
return fetch(input, init) return fetch(input, init);
} };
await expect( await expect(
ensureExactAmount([first, second], 7_000, { ensureExactAmount([first, second], 7_000, {
fetch: countingFetch, fetch: countingFetch,
allocateOutputSecrets: allocatedSecrets(secret('7b')), allocateOutputSecrets: allocatedSecrets(secret('7b')),
assertOwner: () => { assertOwner: () => {
ownerChecks += 1 ownerChecks += 1;
if (ownerChecks === 3) throw new Error('wallet owner changed') if (ownerChecks === 3) throw new Error('wallet owner changed');
}, },
onCarve: () => undefined, onCarve: () => undefined,
}), }),
).rejects.toThrow(/owner changed/) ).rejects.toThrow(/owner changed/);
expect(mutationCount).toBe(0) expect(mutationCount).toBe(0);
expect(instance.state.noteState(firstSecret)).toBe('outstanding') expect(instance.state.noteState(firstSecret)).toBe('outstanding');
expect(instance.state.noteState(secondSecret)).toBe('outstanding') expect(instance.state.noteState(secondSecret)).toBe('outstanding');
}) });
}) });
+218 -206
View File
@@ -1,15 +1,15 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {fetchNoteInfo, noteK1, noteSignature} from 'lnurlcash-kit' import { fetchNoteInfo, noteK1, noteSignature } from 'lnurlcash-kit';
import type {Bearer} from './types' import type { Bearer } from './types';
import type {CarveResult} from './ops' import type { CarveResult } from './ops';
import { import {
CarveCheckpointRequiredError, CarveCheckpointRequiredError,
OutputSecretAllocationRequiredError, OutputSecretAllocationRequiredError,
UncertainOutcomeError, UncertainOutcomeError,
ensureExactAmount, ensureExactAmount,
} from './ops' } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import { import {
allocateOutputSecrets, allocateOutputSecrets,
makeBearer, makeBearer,
@@ -17,333 +17,345 @@ import {
noteUrl, noteUrl,
secret, secret,
signatureKeysFor, signatureKeysFor,
} from './ops.testHarness' } from './ops.testHarness';
describe('ensureExactAmount', () => { describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => { it('returns an already-exact note untouched, burning nothing', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('01') const k1 = secret('01');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 21_000) const result = await ensureExactAmount([bearer], 21_000);
expect(noteK1(result.note.url)).toBe(k1) expect(noteK1(result.note.url)).toBe(k1);
expect(result.consumed).toEqual([]) expect(result.consumed).toEqual([]);
expect(result.change).toBeUndefined() expect(result.change).toBeUndefined();
expect(instance.state.noteState(k1)).toBe('outstanding') expect(instance.state.noteState(k1)).toBe('outstanding');
}) });
it('split path: carves an exact note off a larger one, with change', async () => { it('split path: carves an exact note off a larger one, with change', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('02') const k1 = secret('02');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets, allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) });
expect(result.note.amount).toBe(5_000) expect(result.note.amount).toBe(5_000);
expect(result.note.verified).toBe(true) expect(result.note.verified).toBe(true);
expect(result.change?.amount).toBe(16_000) expect(result.change?.amount).toBe(16_000);
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(instance.state.noteState(k1)).toBe('burned') expect(instance.state.noteState(k1)).toBe('burned');
const partK1 = requiredValue(noteK1(result.note.url)) const partK1 = requiredValue(noteK1(result.note.url));
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000) expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
}) });
it('merge path: combines notes summing exactly to the target', async () => { it('merge path: combines notes summing exactly to the target', async () => {
const instance = await mint() const instance = await mint();
const first = await makeBearer(instance, secret('03'), 3_000) const first = await makeBearer(instance, secret('03'), 3_000);
const second = await makeBearer(instance, secret('04'), 4_000) const second = await makeBearer(instance, secret('04'), 4_000);
const result = await ensureExactAmount([first, second], 7_000, {onCarve: () => undefined, allocateOutputSecrets}) const result = await ensureExactAmount([first, second], 7_000, {
expect(result.note.amount).toBe(7_000) onCarve: () => undefined,
expect(result.change).toBeUndefined() allocateOutputSecrets,
expect(result.consumed).toHaveLength(2) });
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned') expect(result.note.amount).toBe(7_000);
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned') expect(result.change).toBeUndefined();
const mergedK1 = requiredValue(noteK1(result.note.url)) expect(result.consumed).toHaveLength(2);
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000) expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned');
}) expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned');
const mergedK1 = requiredValue(noteK1(result.note.url));
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000);
});
it('merge+split path: splits the target off several notes in one request', async () => { it('merge+split path: splits the target off several notes in one request', async () => {
const instance = await mint() const instance = await mint();
const first = await makeBearer(instance, secret('05'), 3_000) const first = await makeBearer(instance, secret('05'), 3_000);
const second = await makeBearer(instance, secret('06'), 4_000) const second = await makeBearer(instance, secret('06'), 4_000);
const result = await ensureExactAmount([first, second], 5_000, {onCarve: () => undefined, allocateOutputSecrets}) const result = await ensureExactAmount([first, second], 5_000, {
expect(result.note.amount).toBe(5_000) onCarve: () => undefined,
expect(result.change?.amount).toBe(2_000) allocateOutputSecrets,
expect(result.consumed).toHaveLength(2) });
const partK1 = requiredValue(noteK1(result.note.url)) expect(result.note.amount).toBe(5_000);
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) expect(result.change?.amount).toBe(2_000);
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) expect(result.consumed).toHaveLength(2);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000) const partK1 = requiredValue(noteK1(result.note.url));
}) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000);
});
it('excludes spent and unverified notes from selection', async () => { it('excludes spent and unverified notes from selection', async () => {
const instance = await mint() const instance = await mint();
const spentBearer = await makeBearer(instance, secret('07'), 50_000) const spentBearer = await makeBearer(instance, secret('07'), 50_000);
const unverified: Bearer = { const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)), ...(await makeBearer(instance, secret('08'), 50_000)),
verified: false, verified: false,
} };
await expect( await expect(
ensureExactAmount([{ ...spentBearer, spent: true }, unverified], 5_000), ensureExactAmount([{ ...spentBearer, spent: true }, unverified], 5_000),
).rejects.toThrow(/enough/) ).rejects.toThrow(/enough/);
}) });
it('refuses an amount no mint can cover', async () => { it('refuses an amount no mint can cover', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('09'), 5_000) const bearer = await makeBearer(instance, secret('09'), 5_000);
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/) await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/);
}) });
it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => { it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => {
const instance = await mint({dropAfterMutation: true}) const instance = await mint({ dropAfterMutation: true });
const k1 = secret('10') const k1 = secret('10');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, {onCarve: () => undefined, allocateOutputSecrets}) const result = await ensureExactAmount([bearer], 5_000, {
const partK1 = requiredValue(noteK1(result.note.url)) onCarve: () => undefined,
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) allocateOutputSecrets,
expect(partK1).not.toBe(k1) });
expect(instance.state.noteState(k1)).toBe('burned') const partK1 = requiredValue(noteK1(result.note.url));
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000) expect(partK1).not.toBe(k1);
}) expect(instance.state.noteState(k1)).toBe('burned');
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000);
});
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => { it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('11') const k1 = secret('11');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
// every mutation answer is lost - the first attempt's AND the forced // every mutation answer is lost - the first attempt's AND the forced
// byte-identical replay's (the mint executed both; the replay is the // byte-identical replay's (the mint executed both; the replay is the
// recorded success served again, then dropped by the wrapper) - and // recorded success served again, then dropped by the wrapper) - and
// every informational probe is unreachable, so neither the mutation // every informational probe is unreachable, so neither the mutation
// nor the probe can be confirmed // nor the probe can be confirmed
const everythingDyingFetch: typeof fetch = async (input, init) => { const everythingDyingFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) { if (url.includes('/w/cb')) {
const response = await fetch(input, init) const response = await fetch(input, init);
await response.arrayBuffer() await response.arrayBuffer();
throw new Error('mutation response lost') throw new Error('mutation response lost');
} }
return Promise.reject(new Error('probe unreachable')) return Promise.reject(new Error('probe unreachable'));
} };
const checkpoints: CarveResult[] = [] const checkpoints: CarveResult[] = [];
const failure = await ensureExactAmount([bearer], 5_000, { const failure = await ensureExactAmount([bearer], 5_000, {
fetch: everythingDyingFetch, fetch: everythingDyingFetch,
allocateOutputSecrets, allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
checkpoints.push(carve) checkpoints.push(carve);
}, },
}).catch((error: unknown) => error) }).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(UncertainOutcomeError) expect(failure).toBeInstanceOf(UncertainOutcomeError);
if (!(failure instanceof UncertainOutcomeError)) throw failure if (!(failure instanceof UncertainOutcomeError)) throw failure;
expect(failure.possibleOutputs).toEqual([]) expect(failure.possibleOutputs).toEqual([]);
expect(checkpoints).toHaveLength(1) expect(checkpoints).toHaveLength(1);
expect(checkpoints[0]?.consumed).toEqual([]) expect(checkpoints[0]?.consumed).toEqual([]);
expect(checkpoints[0]?.note.amount).toBe(5_000) expect(checkpoints[0]?.note.amount).toBe(5_000);
expect(checkpoints[0]?.change?.amount).toBe(16_000) expect(checkpoints[0]?.change?.amount).toBe(16_000);
}) });
it('stages outputs before wire and retires inputs only after the mutation lands', async () => { it('stages outputs before wire and retires inputs only after the mutation lands', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('12'), 21_000) const bearer = await makeBearer(instance, secret('12'), 21_000);
const seen: CarveResult[] = [] const seen: CarveResult[] = [];
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets, allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) seen.push(carve);
}, },
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) });
expect(seen).toHaveLength(2) expect(seen).toHaveLength(2);
expect(seen[0]?.note.verified).toBe(false) expect(seen[0]?.note.verified).toBe(false);
expect(seen[0]?.consumed).toEqual([]) expect(seen[0]?.consumed).toEqual([]);
expect(seen[0]?.change).toBeDefined() expect(seen[0]?.change).toBeDefined();
expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url)) expect(noteK1(requiredValue(seen[0]).note.url)).toBe(noteK1(result.note.url));
expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe( expect(noteK1(requiredValue(requiredValue(seen[0]).change).url)).toBe(
noteK1(requiredValue(result.change).url), noteK1(requiredValue(result.change).url),
) );
// the landed checkpoint re-reports the SAME staged output (same secret) // the landed checkpoint re-reports the SAME staged output (same secret)
// - its URL has since gained the mint's signature // - its URL has since gained the mint's signature
expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url)) expect(noteK1(requiredValue(seen[1]).note.url)).toBe(noteK1(requiredValue(seen[0]).note.url));
expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull() expect(noteSignature(requiredValue(seen[1]).note.url)).not.toBeNull();
expect(seen[1]?.note.verified).toBe(true) expect(seen[1]?.note.verified).toBe(true);
expect(seen[1]?.change).toBeUndefined() expect(seen[1]?.change).toBeUndefined();
expect(seen[1]?.consumed).toEqual(result.consumed) expect(seen[1]?.consumed).toEqual(result.consumed);
expect(result.consumed).toHaveLength(1) expect(result.consumed).toHaveLength(1);
}) });
it('does not stage the split change again during the landed checkpoint', async () => { it('does not stage the split change again during the landed checkpoint', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('17'), 21_000) const bearer = await makeBearer(instance, secret('17'), 21_000);
const trackedSecrets = new Set<string>() const trackedSecrets = new Set<string>();
const retiredIds = new Set<string>() const retiredIds = new Set<string>();
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets, allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
// checkpoints identify an output by its secret: the landed phase's // checkpoints identify an output by its secret: the landed phase's
// URL has gained a signature the staged phase could not have had // URL has gained a signature the staged phase could not have had
const k1 = noteK1(carve.note.url) const k1 = noteK1(carve.note.url);
if (k1) trackedSecrets.add(k1) if (k1) trackedSecrets.add(k1);
const changeK1 = carve.change ? noteK1(carve.change.url) : null const changeK1 = carve.change ? noteK1(carve.change.url) : null;
if (changeK1) trackedSecrets.add(changeK1) if (changeK1) trackedSecrets.add(changeK1);
for (const consumed of carve.consumed) retiredIds.add(consumed.id) for (const consumed of carve.consumed) retiredIds.add(consumed.id);
}, },
}) });
expect(trackedSecrets).toEqual( expect(trackedSecrets).toEqual(
new Set([ new Set([
requiredValue(noteK1(result.note.url)), requiredValue(noteK1(result.note.url)),
requiredValue(noteK1(requiredValue(result.change).url)), requiredValue(noteK1(requiredValue(result.change).url)),
]), ]),
) );
expect(retiredIds).toEqual(new Set([bearer.id])) expect(retiredIds).toEqual(new Set([bearer.id]));
}) });
it('keeps staged outputs recoverable when the landed checkpoint fails', async () => { it('keeps staged outputs recoverable when the landed checkpoint fails', async () => {
const instance = await mint() const instance = await mint();
const sourceSecret = secret('15') const sourceSecret = secret('15');
const bearer = await makeBearer(instance, sourceSecret, 21_000) const bearer = await makeBearer(instance, sourceSecret, 21_000);
const seen: CarveResult[] = [] const seen: CarveResult[] = [];
await expect( await expect(
ensureExactAmount([bearer], 5_000, { ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets, allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) seen.push(carve);
if (carve.consumed.length > 0) throw new Error('final commit failed') if (carve.consumed.length > 0) throw new Error('final commit failed');
}, },
}), }),
).rejects.toThrow(/final commit failed/) ).rejects.toThrow(/final commit failed/);
expect(seen).toHaveLength(2) expect(seen).toHaveLength(2);
expect(seen[0]?.consumed).toEqual([]) expect(seen[0]?.consumed).toEqual([]);
expect(seen[0]?.change).toBeDefined() expect(seen[0]?.change).toBeDefined();
expect(instance.state.noteState(sourceSecret)).toBe('burned') expect(instance.state.noteState(sourceSecret)).toBe('burned');
expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe( expect(instance.state.noteState(requiredValue(noteK1(requiredValue(seen[0]).note.url)))).toBe(
'outstanding', 'outstanding',
) );
expect( expect(
instance.state.noteState( instance.state.noteState(
requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)), requiredValue(noteK1(requiredValue(requiredValue(seen[0]).change).url)),
), ),
).toBe('outstanding') ).toBe('outstanding');
}) });
it('never fires onCarve for an already-exact note - nothing was burned', async () => { it('never fires onCarve for an already-exact note - nothing was burned', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('13'), 21_000) const bearer = await makeBearer(instance, secret('13'), 21_000);
let called = false let called = false;
const result = await ensureExactAmount([bearer], 21_000, { const result = await ensureExactAmount([bearer], 21_000, {
onCarve: () => { onCarve: () => {
called = true called = true;
}, },
}) });
expect(called).toBe(false) expect(called).toBe(false);
expect(result.consumed).toEqual([]) expect(result.consumed).toEqual([]);
}) });
it('requires a typed durable checkpoint before a mutating carve', async () => { it('requires a typed durable checkpoint before a mutating carve', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('16'), 21_000) const bearer = await makeBearer(instance, secret('16'), 21_000);
await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf( await expect(ensureExactAmount([bearer], 5_000)).rejects.toBeInstanceOf(
CarveCheckpointRequiredError, CarveCheckpointRequiredError,
) );
expect(instance.state.noteState(secret('16'))).toBe('outstanding') expect(instance.state.noteState(secret('16'))).toBe('outstanding');
}) });
it('propagates an onCarve failure before the carve reaches the mint', async () => { it('propagates an onCarve failure before the carve reaches the mint', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('14'), 21_000) const bearer = await makeBearer(instance, secret('14'), 21_000);
await expect( await expect(
ensureExactAmount([bearer], 5_000, { ensureExactAmount([bearer], 5_000, {
allocateOutputSecrets, allocateOutputSecrets,
onCarve: () => { onCarve: () => {
throw new Error('commit failed') throw new Error('commit failed');
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/commit failed/);
expect(instance.state.noteState(secret('14'))).toBe('outstanding') expect(instance.state.noteState(secret('14'))).toBe('outstanding');
}) });
it('draws the split outputs from the caller allocation path, exactly two', async () => { it('draws the split outputs from the caller allocation path, exactly two', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('80'), 21_000) const bearer = await makeBearer(instance, secret('80'), 21_000);
const allocations: Array<{server: string; count: number}> = [] const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('81'), secret('82')] const allocated = [secret('81'), secret('82')];
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets: (server, count) => { allocateOutputSecrets: (server, count) => {
allocations.push({server, count}) allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count)) return Promise.resolve(allocated.slice(0, count));
}, },
}) });
expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 2}]) expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 2 }]);
expect(noteK1(result.note.url)).toBe(allocated[0]) expect(noteK1(result.note.url)).toBe(allocated[0]);
expect(noteK1(requiredValue(result.change).url)).toBe(allocated[1]) expect(noteK1(requiredValue(result.change).url)).toBe(allocated[1]);
}) });
it('draws the merge output from the caller allocation path, exactly one', async () => { it('draws the merge output from the caller allocation path, exactly one', async () => {
const instance = await mint() const instance = await mint();
const first = await makeBearer(instance, secret('83'), 3_000) const first = await makeBearer(instance, secret('83'), 3_000);
const second = await makeBearer(instance, secret('84'), 4_000) const second = await makeBearer(instance, secret('84'), 4_000);
const allocations: Array<{server: string; count: number}> = [] const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('85')] const allocated = [secret('85')];
const result = await ensureExactAmount([first, second], 7_000, { const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets: (server, count) => { allocateOutputSecrets: (server, count) => {
allocations.push({server, count}) allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count)) return Promise.resolve(allocated.slice(0, count));
}, },
}) });
expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(noteK1(result.note.url)).toBe(allocated[0]) expect(noteK1(result.note.url)).toBe(allocated[0]);
}) });
it('allocates nothing when one note already holds the exact amount', async () => { it('allocates nothing when one note already holds the exact amount', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('86'), 21_000) const bearer = await makeBearer(instance, secret('86'), 21_000);
let allocations = 0 let allocations = 0;
await ensureExactAmount([bearer], 21_000, { await ensureExactAmount([bearer], 21_000, {
allocateOutputSecrets: () => { allocateOutputSecrets: () => {
allocations += 1 allocations += 1;
return Promise.resolve([]) return Promise.resolve([]);
}, },
}) });
expect(allocations).toBe(0) expect(allocations).toBe(0);
}) });
it('refuses a mutating carve without an allocation path, before any staging', async () => { it('refuses a mutating carve without an allocation path, before any staging', async () => {
const instance = await mint() const instance = await mint();
const split = await makeBearer(instance, secret('87'), 21_000) const split = await makeBearer(instance, secret('87'), 21_000);
const mergeA = await makeBearer(instance, secret('88'), 3_000) const mergeA = await makeBearer(instance, secret('88'), 3_000);
const mergeB = await makeBearer(instance, secret('89'), 4_000) const mergeB = await makeBearer(instance, secret('89'), 4_000);
let mutations = 0 let mutations = 0;
const countingFetch: typeof fetch = (input, init) => { const countingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) mutations += 1 if (url.includes('/w/cb')) mutations += 1;
return fetch(input, init) return fetch(input, init);
} };
await expect( await expect(
ensureExactAmount([split], 5_000, { fetch: countingFetch, onCarve: () => undefined }), ensureExactAmount([split], 5_000, { fetch: countingFetch, onCarve: () => undefined }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
await expect( await expect(
ensureExactAmount([mergeA, mergeB], 7_000, {fetch: countingFetch, onCarve: () => undefined}), ensureExactAmount([mergeA, mergeB], 7_000, {
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) fetch: countingFetch,
onCarve: () => undefined,
}),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(mutations).toBe(0) expect(mutations).toBe(0);
expect(instance.state.noteState(secret('87'))).toBe('outstanding') expect(instance.state.noteState(secret('87'))).toBe('outstanding');
expect(instance.state.noteState(secret('88'))).toBe('outstanding') expect(instance.state.noteState(secret('88'))).toBe('outstanding');
expect(instance.state.noteState(secret('89'))).toBe('outstanding') expect(instance.state.noteState(secret('89'))).toBe('outstanding');
}) });
}) });
+60 -54
View File
@@ -1,106 +1,112 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {hashK1, noteK1} from 'lnurlcash-kit' import { hashK1, noteK1 } from 'lnurlcash-kit';
import {claimMintedNote, OutputSecretAllocationRequiredError, prepareMint} from './ops' import { claimMintedNote, OutputSecretAllocationRequiredError, prepareMint } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import type {Mint} from './ops.testHarness' import type { Mint } from './ops.testHarness';
import {allocateOutputSecrets, mint, persistOutput, secret, settleLastInvoice} from './ops.testHarness' import {
allocateOutputSecrets,
mint,
persistOutput,
secret,
settleLastInvoice,
} from './ops.testHarness';
const HEX32 = /^[0-9a-f]{64}$/ const HEX32 = /^[0-9a-f]{64}$/;
const boundInvoice = (instance: Mint) => const boundInvoice = (instance: Mint) =>
[...instance.state.invoices.values()].find((invoice) => invoice.boundTo) [...instance.state.invoices.values()].find((invoice) => invoice.boundTo);
describe('mint -> claim, comment-bound output', () => { describe('mint -> claim, comment-bound output', () => {
it('names the quote with the hash of a wallet-chosen secret', async () => { it('names the quote with the hash of a wallet-chosen secret', async () => {
const instance = await mint({testHooks: true}) const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets, allocateOutputSecrets,
}) });
expect(prepared.noteSecret).toMatch(HEX32) expect(prepared.noteSecret).toMatch(HEX32);
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
}) });
it('claims the output at the wallet secret without rotating it', async () => { it('claims the output at the wallet secret without rotating it', async () => {
const instance = await mint({testHooks: true}) const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets, allocateOutputSecrets,
}) });
const preimage = await settleLastInvoice(instance) const preimage = await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) });
expect(claimed.note.verified).toBe(true) expect(claimed.note.verified).toBe(true);
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.callback).toBe(`${instance.url}/w/cb`) expect(claimed.note.callback).toBe(`${instance.url}/w/cb`);
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding') expect(instance.state.noteState(prepared.noteSecret)).toBe('outstanding');
expect(instance.state.noteState(preimage)).toBeNull() expect(instance.state.noteState(preimage)).toBeNull();
}) });
it('times out cleanly when the invoice is never paid', async () => { it('times out cleanly when the invoice is never paid', async () => {
const instance = await mint({testHooks: true}) const instance = await mint({ testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets, allocateOutputSecrets,
}) });
await expect( await expect(
claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }), claimMintedNote(prepared, { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100 }),
).rejects.toThrow(/not confirmed/i) ).rejects.toThrow(/not confirmed/i);
}) });
it('uses the required comment even when the optional h extension is advertised', async () => { it('uses the required comment even when the optional h extension is advertised', async () => {
const instance = await mint({mintToHash: true, testHooks: true}) const instance = await mint({ mintToHash: true, testHooks: true });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets, allocateOutputSecrets,
}) });
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret)) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(prepared.noteSecret));
await settleLastInvoice(instance) await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 50, intervalCapMs: 50,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) });
expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret);
expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32) expect(requiredValue(noteK1(claimed.note.url))).toMatch(HEX32);
}) });
it('draws the staged note secret from the caller allocation path', async () => { it('draws the staged note secret from the caller allocation path', async () => {
const instance = await mint({testHooks: true}) const instance = await mint({ testHooks: true });
const allocations: Array<{server: string; count: number}> = [] const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('90')] const allocated = [secret('90')];
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput, persistOutput,
allocateOutputSecrets: (server, count) => { allocateOutputSecrets: (server, count) => {
allocations.push({server, count}) allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count)) return Promise.resolve(allocated.slice(0, count));
}, },
}) });
expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(prepared.noteSecret).toBe(allocated[0]) expect(prepared.noteSecret).toBe(allocated[0]);
expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0]))) expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(allocated[0])));
}) });
it('refuses to prepare without an allocation path - nothing staged, no invoice', async () => { it('refuses to prepare without an allocation path - nothing staged, no invoice', async () => {
const instance = await mint({testHooks: true}) const instance = await mint({ testHooks: true });
let persisted = 0 let persisted = 0;
await expect( await expect(
prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, { prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000, {
persistOutput: () => { persistOutput: () => {
persisted += 1 persisted += 1;
}, },
}), }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(persisted).toBe(0) expect(persisted).toBe(0);
expect(instance.state.invoices.size).toBe(0) expect(instance.state.invoices.size).toBe(0);
}) });
}) });
+98 -98
View File
@@ -1,4 +1,4 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import { import {
NoteSpentError, NoteSpentError,
PendingNoteError, PendingNoteError,
@@ -6,7 +6,7 @@ import {
meltNote, meltNote,
noteK1, noteK1,
rotateNote, rotateNote,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import { import {
claimMintedNote, claimMintedNote,
@@ -14,9 +14,9 @@ import {
prepareMint, prepareMint,
receiveBearer, receiveBearer,
ReceiveRotationStagingRequiredError, ReceiveRotationStagingRequiredError,
} from './ops' } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import type {NewBearer} from './types' import type { NewBearer } from './types';
import { import {
allocateOutputSecrets, allocateOutputSecrets,
makeBearer, makeBearer,
@@ -26,161 +26,161 @@ import {
secret, secret,
settleLastInvoice, settleLastInvoice,
stageRotation, stageRotation,
} from './ops.testHarness' } from './ops.testHarness';
describe('mint fee', () => { describe('mint fee', () => {
it('grosses the invoice up for an advertised mint fee', async () => { it('grosses the invoice up for an advertised mint fee', async () => {
const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) const instance = await mint({ testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000 });
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, { const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000, {
persistOutput, persistOutput,
allocateOutputSecrets, allocateOutputSecrets,
}) });
expect(prepared.grossMsat).toBeGreaterThan(100_000) expect(prepared.grossMsat).toBeGreaterThan(100_000);
await settleLastInvoice(instance) await settleLastInvoice(instance);
const claimed = await claimMintedNote(prepared, { const claimed = await claimMintedNote(prepared, {
intervalMs: 10, intervalMs: 10,
intervalCapMs: 20, intervalCapMs: 20,
maxWaitMs: 5_000, maxWaitMs: 5_000,
}) });
// the service's maxWithdrawable is authoritative: the fee is withheld // the service's maxWithdrawable is authoritative: the fee is withheld
// from the gross, so the note nets between the asked value and the gross // from the gross, so the note nets between the asked value and the gross
expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000) expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000);
expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat) expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat);
}) });
}) });
describe('receiveBearer', () => { describe('receiveBearer', () => {
it('stages the rotation output before the rotate can reach the mint', async () => { it('stages the rotation output before the rotate can reach the mint', async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('94') const senderK1 = secret('94');
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000);
const rotationSecret = secret('95') const rotationSecret = secret('95');
let staged: NewBearer | undefined let staged: NewBearer | undefined;
const observingFetch: typeof fetch = (input, init) => { const observingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) expect(staged).toBeDefined() if (url.includes('/w/cb')) expect(staged).toBeDefined();
return fetch(input, init) return fetch(input, init);
} };
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: observingFetch, fetch: observingFetch,
allocateOutputSecrets: () => Promise.resolve([rotationSecret]), allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
stageRotation: (note) => { stageRotation: (note) => {
staged = note staged = note;
}, },
}) });
expect(received.stage).toBe('finalize') expect(received.stage).toBe('finalize');
expect(received.rotated).toBe(true) expect(received.rotated).toBe(true);
expect(noteK1(received.note.url)).toBe(rotationSecret) expect(noteK1(received.note.url)).toBe(rotationSecret);
expect(noteK1(requiredValue(staged).url)).toBe(rotationSecret) expect(noteK1(requiredValue(staged).url)).toBe(rotationSecret);
expect(requiredValue(staged).verified).toBe(false) expect(requiredValue(staged).verified).toBe(false);
expect(requiredValue(staged).pendingMint?.sourceRecoverySecret).toBe(senderK1) expect(requiredValue(staged).pendingMint?.sourceRecoverySecret).toBe(senderK1);
expect(instance.state.noteState(senderK1)).toBe('burned') expect(instance.state.noteState(senderK1)).toBe('burned');
expect(instance.state.noteState(rotationSecret)).toBe('outstanding') expect(instance.state.noteState(rotationSecret)).toBe('outstanding');
}) });
it('reports discard when the rotate provably never landed', async () => { it('reports discard when the rotate provably never landed', async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('96') const senderK1 = secret('96');
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000);
const rotationSecret = secret('97') const rotationSecret = secret('97');
let staged: NewBearer | undefined let staged: NewBearer | undefined;
const failingFetch: typeof fetch = (input, init) => { const failingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset')) if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset'));
return fetch(input, init) return fetch(input, init);
} };
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: failingFetch, fetch: failingFetch,
allocateOutputSecrets: () => Promise.resolve([rotationSecret]), allocateOutputSecrets: () => Promise.resolve([rotationSecret]),
stageRotation: (note) => { stageRotation: (note) => {
staged = note staged = note;
}, },
}) });
expect(received.stage).toBe('discard') expect(received.stage).toBe('discard');
expect(received.rotated).toBe(false) expect(received.rotated).toBe(false);
expect(received.note.verified).toBe(true) expect(received.note.verified).toBe(true);
expect(received.rotationError).toBeDefined() expect(received.rotationError).toBeDefined();
// nothing landed: both the sender's copy and the would-be output live on // nothing landed: both the sender's copy and the would-be output live on
expect(instance.state.noteState(senderK1)).toBe('outstanding') expect(instance.state.noteState(senderK1)).toBe('outstanding');
expect(instance.state.noteState(rotationSecret)).toBeNull() expect(instance.state.noteState(rotationSecret)).toBeNull();
expect(staged).toBeDefined() expect(staged).toBeDefined();
}) });
it("verifies an incoming note and rotates it, burning the sender's copy", async () => { it("verifies an incoming note and rotates it, burning the sender's copy", async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('20') const senderK1 = secret('20');
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
allocateOutputSecrets, allocateOutputSecrets,
stageRotation, stageRotation,
}) });
expect(received.rotated).toBe(true) expect(received.rotated).toBe(true);
expect(received.stage).toBe('finalize') expect(received.stage).toBe('finalize');
expect(received.note.amount).toBe(21_000) expect(received.note.amount).toBe(21_000);
expect(received.note.verified).toBe(true) expect(received.note.verified).toBe(true);
const newK1 = requiredValue(noteK1(received.note.url)) const newK1 = requiredValue(noteK1(received.note.url));
expect(newK1).not.toBe(senderK1) expect(newK1).not.toBe(senderK1);
expect(instance.state.noteState(senderK1)).toBe('burned') expect(instance.state.noteState(senderK1)).toBe('burned');
expect(instance.state.noteState(newK1)).toBe('outstanding') expect(instance.state.noteState(newK1)).toBe('outstanding');
}) });
it('refuses a note the wallet already holds', async () => { it('refuses a note the wallet already holds', async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('21') const senderK1 = secret('21');
const existing = await makeBearer(instance, senderK1, 21_000) const existing = await makeBearer(instance, senderK1, 21_000);
await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow( await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow(
/already/, /already/,
) );
}) });
it('surfaces a spent note as definitively spent', async () => { it('surfaces a spent note as definitively spent', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('22') const k1 = secret('22');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const info = await fetchNoteInfo(bearer.url) const info = await fetchNoteInfo(bearer.url);
await rotateNote(info.callback, k1) await rotateNote(info.callback, k1);
await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf(
NoteSpentError, NoteSpentError,
) );
}) });
it('surfaces a note locked mid-melt as pending, not as unverified', async () => { it('surfaces a note locked mid-melt as pending, not as unverified', async () => {
const instance = await mint({meltNeverSettles: true}) const instance = await mint({ meltNeverSettles: true });
const k1 = secret('23') const k1 = secret('23');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz') await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz');
await expect( await expect(
receiveBearer(noteUrl(instance, k1, 21_000), [], { receiveBearer(noteUrl(instance, k1, 21_000), [], {
allocateOutputSecrets, allocateOutputSecrets,
stageRotation, stageRotation,
}), }),
).rejects.toBeInstanceOf(PendingNoteError) ).rejects.toBeInstanceOf(PendingNoteError);
}) });
it('refuses to rotate without a durable staging checkpoint', async () => { it('refuses to rotate without a durable staging checkpoint', async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('9a') const senderK1 = secret('9a');
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000);
await expect( await expect(
receiveBearer(noteUrl(instance, senderK1, 21_000), [], { allocateOutputSecrets }), receiveBearer(noteUrl(instance, senderK1, 21_000), [], { allocateOutputSecrets }),
).rejects.toBeInstanceOf(ReceiveRotationStagingRequiredError) ).rejects.toBeInstanceOf(ReceiveRotationStagingRequiredError);
// the sender's copy is untouched - no rotation was attempted // the sender's copy is untouched - no rotation was attempted
expect(instance.state.noteState(senderK1)).toBe('outstanding') expect(instance.state.noteState(senderK1)).toBe('outstanding');
}) });
it('refuses to rotate without an allocation path', async () => { it('refuses to rotate without an allocation path', async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('9b') const senderK1 = secret('9b');
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000);
await expect( await expect(
receiveBearer(noteUrl(instance, senderK1, 21_000), [], { stageRotation }), receiveBearer(noteUrl(instance, senderK1, 21_000), [], { stageRotation }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(instance.state.noteState(senderK1)).toBe('outstanding') expect(instance.state.noteState(senderK1)).toBe('outstanding');
}) });
}) });
+111 -111
View File
@@ -1,30 +1,30 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {noteK1} from 'lnurlcash-kit' import { noteK1 } from 'lnurlcash-kit';
import { import {
OutputSecretAllocationRequiredError, OutputSecretAllocationRequiredError,
PayReturnCheckpointRequiredError, PayReturnCheckpointRequiredError,
payWithBearers, payWithBearers,
} from './ops' } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import {allocateOutputSecrets, makeBearer, mint, secret} from './ops.testHarness' import { allocateOutputSecrets, makeBearer, mint, secret } from './ops.testHarness';
describe('payWithBearers', () => { describe('payWithBearers', () => {
it('pays a bolt11 invoice by melting an exact note (settled)', async () => { it('pays a bolt11 invoice by melting an exact note (settled)', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('30'), 21_000) const bearer = await makeBearer(instance, secret('30'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 }, poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
}) });
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(instance.state.noteState(secret('30'))).toBe('burned') expect(instance.state.noteState(secret('30'))).toBe('burned');
}) });
it('pays a Lightning Address by requesting an invoice first', async () => { it('pays a Lightning Address by requesting an invoice first', async () => {
const payer = await mint() const payer = await mint();
const bearer = await makeBearer(payer, secret('31'), 21_000) const bearer = await makeBearer(payer, secret('31'), 21_000);
const payeeFetch: typeof fetch = (input, init) => { const payeeFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) { if (url.startsWith('https://payee.example/.well-known/lnurlp/alice')) {
return Promise.resolve( return Promise.resolve(
Response.json({ Response.json({
@@ -34,184 +34,184 @@ describe('payWithBearers', () => {
maxSendable: 100_000_000, maxSendable: 100_000_000,
metadata: '[]', metadata: '[]',
}), }),
) );
} }
if (url.startsWith('https://payee.example/invoice')) { if (url.startsWith('https://payee.example/invoice')) {
return Promise.resolve(Response.json({pr: 'lnbc210n1pjqrstuvwxyz'})) return Promise.resolve(Response.json({ pr: 'lnbc210n1pjqrstuvwxyz' }));
}
return fetch(input, init)
} }
return fetch(input, init);
};
const result = await payWithBearers([bearer], 'alice@payee.example', { const result = await payWithBearers([bearer], 'alice@payee.example', {
amountMsat: 21_000, amountMsat: 21_000,
kit: { fetch: payeeFetch }, kit: { fetch: payeeFetch },
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 }, poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
}) });
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(result.invoice).toMatch(/^lnbc/) expect(result.invoice).toMatch(/^lnbc/);
expect(payer.state.noteState(secret('31'))).toBe('burned') expect(payer.state.noteState(secret('31'))).toBe('burned');
}) });
it('carves the exact amount out of a larger note before melting', async () => { it('carves the exact amount out of a larger note before melting', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('32'), 50_000) const bearer = await makeBearer(instance, secret('32'), 50_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 }, poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
allocateOutputSecrets, allocateOutputSecrets,
onCarve: () => undefined, onCarve: () => undefined,
}) });
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(instance.state.noteState(secret('32'))).toBe('burned') expect(instance.state.noteState(secret('32'))).toBe('burned');
expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]) expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(result.carve.change?.amount).toBe(29_000) expect(result.carve.change?.amount).toBe(29_000);
const change = requiredValue(result.carve.change) const change = requiredValue(result.carve.change);
expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding') expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding');
}) });
it('classifies a failed melt as funds-returned once the note is spendable again', async () => { it('classifies a failed melt as funds-returned once the note is spendable again', async () => {
const instance = await mint({meltAlwaysFails: true}) const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('33'), 21_000) const bearer = await makeBearer(instance, secret('33'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets, allocateOutputSecrets,
onReturnReady: () => undefined, onReturnReady: () => undefined,
}) });
expect(result.outcome).toBe('failed-funds-returned') expect(result.outcome).toBe('failed-funds-returned');
expect(instance.state.noteState(secret('33'))).toBe('burned') expect(instance.state.noteState(secret('33'))).toBe('burned');
const rotatedNote = requiredValue(result.rotatedNote) const rotatedNote = requiredValue(result.rotatedNote);
expect(rotatedNote.verified).toBe(true) expect(rotatedNote.verified).toBe(true);
expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding') expect(instance.state.noteState(requiredValue(noteK1(rotatedNote.url)))).toBe('outstanding');
}) });
it('waits for durable recovery-secret staging before rotating returned funds', async () => { it('waits for durable recovery-secret staging before rotating returned funds', async () => {
const instance = await mint({meltAlwaysFails: true}) const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('39'), 21_000) const bearer = await makeBearer(instance, secret('39'), 21_000);
let releaseStage: (() => void) | undefined let releaseStage: (() => void) | undefined;
const stageGate = new Promise<void>((resolve) => { const stageGate = new Promise<void>((resolve) => {
releaseStage = resolve releaseStage = resolve;
}) });
let stageStarted: (() => void) | undefined let stageStarted: (() => void) | undefined;
const stageEntered = new Promise<void>((resolve) => { const stageEntered = new Promise<void>((resolve) => {
stageStarted = resolve stageStarted = resolve;
}) });
const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const payment = payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets, allocateOutputSecrets,
onReturnReady: async () => { onReturnReady: async () => {
stageStarted?.() stageStarted?.();
await stageGate await stageGate;
}, },
}) });
await stageEntered await stageEntered;
expect(instance.state.noteState(secret('39'))).toBe('outstanding') expect(instance.state.noteState(secret('39'))).toBe('outstanding');
releaseStage?.() releaseStage?.();
expect((await payment).outcome).toBe('failed-funds-returned') expect((await payment).outcome).toBe('failed-funds-returned');
expect(instance.state.noteState(secret('39'))).toBe('burned') expect(instance.state.noteState(secret('39'))).toBe('burned');
}) });
it('classifies a never-settling melt as unknown-still-pending', async () => { it('classifies a never-settling melt as unknown-still-pending', async () => {
const instance = await mint({meltNeverSettles: true}) const instance = await mint({ meltNeverSettles: true });
const bearer = await makeBearer(instance, secret('34'), 21_000) const bearer = await makeBearer(instance, secret('34'), 21_000);
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets, allocateOutputSecrets,
onReturnReady: () => undefined, onReturnReady: () => undefined,
}) });
expect(result.outcome).toBe('unknown-still-pending') expect(result.outcome).toBe('unknown-still-pending');
expect(instance.state.noteState(secret('34'))).toBe('pending') expect(instance.state.noteState(secret('34'))).toBe('pending');
}) });
it('rejects an amountless or unreadable invoice instead of guessing', async () => { it('rejects an amountless or unreadable invoice instead of guessing', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('35'), 21_000) const bearer = await makeBearer(instance, secret('35'), 21_000);
await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/) await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/);
await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i) await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i);
}) });
it('aborts before the melt when the carve commit hook fails', async () => { it('aborts before the melt when the carve commit hook fails', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('36'), 21_000) const bearer = await makeBearer(instance, secret('36'), 21_000);
// paying 10_500 off a 21_000 note forces a split, which must commit // paying 10_500 off a 21_000 note forces a split, which must commit
// through the hook before the melt may start // through the hook before the melt may start
await expect( await expect(
payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', { payWithBearers([bearer], 'lnbc105n1pjqrstuvwxyz', {
allocateOutputSecrets, allocateOutputSecrets,
onCarve: () => { onCarve: () => {
throw new Error('commit failed') throw new Error('commit failed');
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/commit failed/);
// The checkpoint failed before either split or melt reached the mint. // The checkpoint failed before either split or melt reached the mint.
expect(instance.state.noteState(secret('36'))).toBe('outstanding') expect(instance.state.noteState(secret('36'))).toBe('outstanding');
expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) expect([...instance.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
}) });
it('draws the return recovery secret from the caller allocation path', async () => { it('draws the return recovery secret from the caller allocation path', async () => {
const instance = await mint({meltAlwaysFails: true}) const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8a'), 21_000) const bearer = await makeBearer(instance, secret('8a'), 21_000);
const allocations: Array<{server: string; count: number}> = [] const allocations: Array<{ server: string; count: number }> = [];
const allocated = [secret('8b')] const allocated = [secret('8b')];
const stagedSecrets: string[] = [] const stagedSecrets: string[] = [];
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets: (server, count) => { allocateOutputSecrets: (server, count) => {
allocations.push({server, count}) allocations.push({ server, count });
return Promise.resolve(allocated.slice(0, count)) return Promise.resolve(allocated.slice(0, count));
}, },
onReturnReady: (_carve, recoverySecret) => { onReturnReady: (_carve, recoverySecret) => {
stagedSecrets.push(recoverySecret) stagedSecrets.push(recoverySecret);
}, },
}) });
expect(result.outcome).toBe('failed-funds-returned') expect(result.outcome).toBe('failed-funds-returned');
expect(allocations).toEqual([{server: `127.0.0.1:${instance.port}`, count: 1}]) expect(allocations).toEqual([{ server: `127.0.0.1:${instance.port}`, count: 1 }]);
expect(stagedSecrets).toEqual(allocated) expect(stagedSecrets).toEqual(allocated);
expect(noteK1(requiredValue(result.rotatedNote).url)).toBe(allocated[0]) expect(noteK1(requiredValue(result.rotatedNote).url)).toBe(allocated[0]);
}) });
it('allocates nothing while the melt settles cleanly', async () => { it('allocates nothing while the melt settles cleanly', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('8c'), 21_000) const bearer = await makeBearer(instance, secret('8c'), 21_000);
let allocations = 0 let allocations = 0;
const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 }, poll: { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 },
allocateOutputSecrets: () => { allocateOutputSecrets: () => {
allocations += 1 allocations += 1;
return Promise.resolve([]) return Promise.resolve([]);
}, },
}) });
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(allocations).toBe(0) expect(allocations).toBe(0);
}) });
it('refuses the return classification without its durable checkpoint', async () => { it('refuses the return classification without its durable checkpoint', async () => {
const instance = await mint({meltAlwaysFails: true}) const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8d'), 21_000) const bearer = await makeBearer(instance, secret('8d'), 21_000);
await expect( await expect(
payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
allocateOutputSecrets, allocateOutputSecrets,
}), }),
).rejects.toBeInstanceOf(PayReturnCheckpointRequiredError) ).rejects.toBeInstanceOf(PayReturnCheckpointRequiredError);
// the payment failed and the mint restored the note; the refusal came // the payment failed and the mint restored the note; the refusal came
// before the classification rotate, so nothing else moved // before the classification rotate, so nothing else moved
expect(instance.state.noteState(secret('8d'))).toBe('outstanding') expect(instance.state.noteState(secret('8d'))).toBe('outstanding');
}) });
it('refuses the return classification without an allocation path', async () => { it('refuses the return classification without an allocation path', async () => {
const instance = await mint({meltAlwaysFails: true}) const instance = await mint({ meltAlwaysFails: true });
const bearer = await makeBearer(instance, secret('8e'), 21_000) const bearer = await makeBearer(instance, secret('8e'), 21_000);
await expect( await expect(
payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
onReturnReady: () => undefined, onReturnReady: () => undefined,
}), }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(instance.state.noteState(secret('8e'))).toBe('outstanding') expect(instance.state.noteState(secret('8e'))).toBe('outstanding');
}) });
}) });
+52 -52
View File
@@ -1,9 +1,9 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {noteK1} from 'lnurlcash-kit' import { noteK1 } from 'lnurlcash-kit';
import type {CarveResult} from './ops' import type { CarveResult } from './ops';
import {ensureExactAmount, receiveBearer} from './ops' import { ensureExactAmount, receiveBearer } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import { import {
allocateOutputSecrets, allocateOutputSecrets,
makeBearer, makeBearer,
@@ -12,7 +12,7 @@ import {
retryingCbFetch, retryingCbFetch,
secret, secret,
stageRotation, stageRotation,
} from './ops.testHarness' } from './ops.testHarness';
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint // The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
// can execute the first attempt of a mutation and refuse the byte-identical // can execute the first attempt of a mutation and refuse the byte-identical
@@ -22,76 +22,76 @@ import {
// carried secrets are the only money left. // carried secrets are the only money left.
describe('mutation retry refusals', () => { describe('mutation retry refusals', () => {
it('rescues the outputs when the split answer is a retry refusal', async () => { it('rescues the outputs when the split answer is a retry refusal', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('60') const k1 = secret('60');
const bearer = await makeBearer(instance, k1, 50_000) const bearer = await makeBearer(instance, k1, 50_000);
const result = await ensureExactAmount([bearer], 25_000, { const result = await ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
allocateOutputSecrets, allocateOutputSecrets,
onCarve: () => undefined, onCarve: () => undefined,
}) });
// the split landed on the first (unseen) attempt: the input is burned // the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried // and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned') expect(instance.state.noteState(k1)).toBe('burned');
expect(result.consumed).toHaveLength(1) expect(result.consumed).toHaveLength(1);
const partK1 = requiredValue(noteK1(result.note.url)) const partK1 = requiredValue(noteK1(result.note.url));
expect(partK1).not.toBe(k1) expect(partK1).not.toBe(k1);
expect(result.note.amount).toBe(25_000) expect(result.note.amount).toBe(25_000);
expect(instance.state.noteState(partK1)).toBe('outstanding') expect(instance.state.noteState(partK1)).toBe('outstanding');
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) const changeK1 = requiredValue(noteK1(requiredValue(result.change).url));
expect(instance.state.noteState(changeK1)).toBe('outstanding') expect(instance.state.noteState(changeK1)).toBe('outstanding');
}) });
it('rescues the combined note when the merge answer is a retry refusal', async () => { it('rescues the combined note when the merge answer is a retry refusal', async () => {
const instance = await mint() const instance = await mint();
const a = await makeBearer(instance, secret('61'), 20_000) const a = await makeBearer(instance, secret('61'), 20_000);
const b = await makeBearer(instance, secret('62'), 5_000) const b = await makeBearer(instance, secret('62'), 5_000);
const result = await ensureExactAmount([a, b], 25_000, { const result = await ensureExactAmount([a, b], 25_000, {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
allocateOutputSecrets, allocateOutputSecrets,
onCarve: () => undefined, onCarve: () => undefined,
}) });
expect(result.consumed).toHaveLength(2) expect(result.consumed).toHaveLength(2);
expect(instance.state.noteState(secret('61'))).toBe('burned') expect(instance.state.noteState(secret('61'))).toBe('burned');
expect(instance.state.noteState(secret('62'))).toBe('burned') expect(instance.state.noteState(secret('62'))).toBe('burned');
const mergedK1 = requiredValue(noteK1(result.note.url)) const mergedK1 = requiredValue(noteK1(result.note.url));
expect(result.note.amount).toBe(25_000) expect(result.note.amount).toBe(25_000);
expect(instance.state.noteState(mergedK1)).toBe('outstanding') expect(instance.state.noteState(mergedK1)).toBe('outstanding');
}) });
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => { it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint() const instance = await mint();
const senderK1 = secret('63') const senderK1 = secret('63');
instance.state.creditNote(senderK1, 21_000) instance.state.creditNote(senderK1, 21_000);
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
allocateOutputSecrets, allocateOutputSecrets,
stageRotation, stageRotation,
}) });
expect(received.rotated).toBe(true) expect(received.rotated).toBe(true);
expect(received.note.amount).toBe(21_000) expect(received.note.amount).toBe(21_000);
expect(instance.state.noteState(senderK1)).toBe('burned') expect(instance.state.noteState(senderK1)).toBe('burned');
const k1 = requiredValue(noteK1(received.note.url)) const k1 = requiredValue(noteK1(received.note.url));
expect(k1).not.toBe(senderK1) expect(k1).not.toBe(senderK1);
expect(instance.state.noteState(k1)).toBe('outstanding') expect(instance.state.noteState(k1)).toBe('outstanding');
}) });
it('still fails a genuine refusal - a note spent before the split', async () => { it('still fails a genuine refusal - a note spent before the split', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('64') const k1 = secret('64');
const bearer = await makeBearer(instance, k1, 50_000) const bearer = await makeBearer(instance, k1, 50_000);
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked instance.state.settleMelt(k1); // burned elsewhere, before we ever asked
const checkpoints: CarveResult[] = [] const checkpoints: CarveResult[] = [];
await expect( await expect(
ensureExactAmount([bearer], 25_000, { ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(), fetch: retryingCbFetch(),
allocateOutputSecrets, allocateOutputSecrets,
onCarve: (carve) => { onCarve: (carve) => {
checkpoints.push(carve) checkpoints.push(carve);
}, },
}), }),
).rejects.toThrow(/spent/i) ).rejects.toThrow(/spent/i);
expect(checkpoints).toHaveLength(1) expect(checkpoints).toHaveLength(1);
expect(checkpoints[0]?.consumed).toEqual([]) expect(checkpoints[0]?.consumed).toEqual([]);
}) });
}) });
+67 -71
View File
@@ -1,8 +1,8 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {noteK1, noteSignature} from 'lnurlcash-kit' import { noteK1, noteSignature } from 'lnurlcash-kit';
import {ensureExactAmount} from './ops' import { ensureExactAmount } from './ops';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
import { import {
allocateOutputSecrets, allocateOutputSecrets,
makeBearer, makeBearer,
@@ -10,8 +10,8 @@ import {
noteUrl, noteUrl,
secret, secret,
signatureKeysFor, signatureKeysFor,
} from './ops.testHarness' } from './ops.testHarness';
import {fetchNoteInfo} from 'lnurlcash-kit' import { fetchNoteInfo } from 'lnurlcash-kit';
// Landed split/merge outputs carry the mint's signature in their URL (sig // Landed split/merge outputs carry the mint's signature in their URL (sig
// param), and earn verified:true only when that signature checks against a // param), and earn verified:true only when that signature checks against a
@@ -21,38 +21,38 @@ import {fetchNoteInfo} from 'lnurlcash-kit'
// exist at the mint), only the offline-verifiable badge is withheld. // exist at the mint), only the offline-verifiable badge is withheld.
describe('landed mutation signature verification', () => { describe('landed mutation signature verification', () => {
it('verifies a landed split against the current key and preserves its signature', async () => { it('verifies a landed split against the current key and preserves its signature', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('a0'), 21_000) const bearer = await makeBearer(instance, secret('a0'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets, allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) });
expect(result.note.verified).toBe(true) expect(result.note.verified).toBe(true);
const signature = noteSignature(result.note.url) const signature = noteSignature(result.note.url);
expect(signature).not.toBeNull() expect(signature).not.toBeNull();
expect(noteSignature(requiredValue(result.change).url)).not.toBeNull() expect(noteSignature(requiredValue(result.change).url)).not.toBeNull();
// change stays unverified (its true post-fee value is unknown until a // change stays unverified (its true post-fee value is unknown until a
// refresh) even though its signature is preserved // refresh) even though its signature is preserved
expect(result.change?.verified).toBe(false) expect(result.change?.verified).toBe(false);
}) });
it('verifies a landed merge against the current key and preserves its signature', async () => { it('verifies a landed merge against the current key and preserves its signature', async () => {
const instance = await mint() const instance = await mint();
const first = await makeBearer(instance, secret('a1'), 3_000) const first = await makeBearer(instance, secret('a1'), 3_000);
const second = await makeBearer(instance, secret('a2'), 4_000) const second = await makeBearer(instance, secret('a2'), 4_000);
const result = await ensureExactAmount([first, second], 7_000, { const result = await ensureExactAmount([first, second], 7_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets, allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) });
expect(result.note.verified).toBe(true) expect(result.note.verified).toBe(true);
expect(noteSignature(result.note.url)).not.toBeNull() expect(noteSignature(result.note.url)).not.toBeNull();
}) });
it('verifies against the previous key after the mint rotated its signing key', async () => { it('verifies against the previous key after the mint rotated its signing key', async () => {
// the mint signs every output under its RETIRED key while advertising // the mint signs every output under its RETIRED key while advertising
@@ -60,26 +60,26 @@ describe('landed mutation signature verification', () => {
const instance = await mint({ const instance = await mint({
previousPrivateKey: '22'.repeat(32), previousPrivateKey: '22'.repeat(32),
signWithPreviousKey: true, signWithPreviousKey: true,
}) });
const bearer = await makeBearer(instance, secret('a3'), 21_000) const bearer = await makeBearer(instance, secret('a3'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets, allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) });
expect(instance.state.previousPubkeys).toHaveLength(1) expect(instance.state.previousPubkeys).toHaveLength(1);
expect(result.note.verified).toBe(true) expect(result.note.verified).toBe(true);
expect(noteSignature(result.note.url)).not.toBeNull() expect(noteSignature(result.note.url)).not.toBeNull();
}) });
it('rejects verification when the previous key is not trusted', async () => { it('rejects verification when the previous key is not trusted', async () => {
const instance = await mint({ const instance = await mint({
previousPrivateKey: '22'.repeat(32), previousPrivateKey: '22'.repeat(32),
signWithPreviousKey: true, signWithPreviousKey: true,
}) });
const bearer = await makeBearer(instance, secret('a4'), 21_000) const bearer = await makeBearer(instance, secret('a4'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
@@ -87,73 +87,69 @@ describe('landed mutation signature verification', () => {
// only the current key is trusted - the signature under the retired // only the current key is trusted - the signature under the retired
// key must not verify // key must not verify
mintSignatureKeys: () => [instance.state.pubkey], mintSignatureKeys: () => [instance.state.pubkey],
}) });
expect(result.note.verified).toBe(false) expect(result.note.verified).toBe(false);
// the note and its signature are still tracked, recoverable by refresh // the note and its signature are still tracked, recoverable by refresh
expect(noteSignature(result.note.url)).not.toBeNull() expect(noteSignature(result.note.url)).not.toBeNull();
const partK1 = requiredValue(noteK1(result.note.url)) const partK1 = requiredValue(noteK1(result.note.url));
expect(instance.state.noteState(partK1)).toBe('outstanding') expect(instance.state.noteState(partK1)).toBe('outstanding');
expect(instance.state.noteState(secret('a4'))).toBe('burned') expect(instance.state.noteState(secret('a4'))).toBe('burned');
}) });
it('keeps a wrong-key signature staged unverified', async () => { it('keeps a wrong-key signature staged unverified', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('a5'), 21_000) const bearer = await makeBearer(instance, secret('a5'), 21_000);
const stranger = await mint({privateKey: '33'.repeat(32)}) const stranger = await mint({ privateKey: '33'.repeat(32) });
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets, allocateOutputSecrets,
// the only trusted key on file belongs to a DIFFERENT mint // the only trusted key on file belongs to a DIFFERENT mint
mintSignatureKeys: () => [stranger.state.pubkey], mintSignatureKeys: () => [stranger.state.pubkey],
}) });
expect(result.note.verified).toBe(false) expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).not.toBeNull() expect(noteSignature(result.note.url)).not.toBeNull();
expect(instance.state.noteState(secret('a5'))).toBe('burned') expect(instance.state.noteState(secret('a5'))).toBe('burned');
expect( expect(instance.state.noteState(requiredValue(noteK1(result.note.url)))).toBe('outstanding');
instance.state.noteState(requiredValue(noteK1(result.note.url))), });
).toBe('outstanding')
})
it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => { it('keeps a landed split unsigned by the mint staged unverified and consumed', async () => {
const instance = await mint({signatures: false}) const instance = await mint({ signatures: false });
const k1 = secret('a6') const k1 = secret('a6');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets, allocateOutputSecrets,
mintSignatureKeys: signatureKeysFor(instance), mintSignatureKeys: signatureKeysFor(instance),
}) });
// the mutation landed (the kit refused the missing signature, the // the mutation landed (the kit refused the missing signature, the
// rescue probe proved the burn): the inputs are retired, the staged // rescue probe proved the burn): the inputs are retired, the staged
// outputs are tracked at exactly their staged secrets, unverified // outputs are tracked at exactly their staged secrets, unverified
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]) expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
expect(result.note.verified).toBe(false) expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).toBeNull() expect(noteSignature(result.note.url)).toBeNull();
expect(instance.state.noteState(k1)).toBe('burned') expect(instance.state.noteState(k1)).toBe('burned');
const partK1 = requiredValue(noteK1(result.note.url)) const partK1 = requiredValue(noteK1(result.note.url));
expect(instance.state.noteState(partK1)).toBe('outstanding') expect(instance.state.noteState(partK1)).toBe('outstanding');
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000) expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000);
}) });
it('never verifies a landed mutation without any trusted keys on file', async () => { it('never verifies a landed mutation without any trusted keys on file', async () => {
const instance = await mint() const instance = await mint();
const bearer = await makeBearer(instance, secret('a7'), 21_000) const bearer = await makeBearer(instance, secret('a7'), 21_000);
const result = await ensureExactAmount([bearer], 5_000, { const result = await ensureExactAmount([bearer], 5_000, {
onCarve: () => undefined, onCarve: () => undefined,
allocateOutputSecrets, allocateOutputSecrets,
// no key source at all: nothing may be trusted blindly // no key source at all: nothing may be trusted blindly
}) });
expect(result.note.verified).toBe(false) expect(result.note.verified).toBe(false);
expect(noteSignature(result.note.url)).not.toBeNull() expect(noteSignature(result.note.url)).not.toBeNull();
expect( expect(instance.state.noteState(requiredValue(noteK1(result.note.url)))).toBe('outstanding');
instance.state.noteState(requiredValue(noteK1(result.note.url))), });
).toBe('outstanding') });
})
})
+8 -8
View File
@@ -1,8 +1,8 @@
import './ops.carve.cases' import './ops.carve.cases';
import './ops.carve-crash.cases' import './ops.carve-crash.cases';
import './ops.signature.cases' import './ops.signature.cases';
import './ops.mint-named.cases' import './ops.mint-named.cases';
import './ops.mint-receive.cases' import './ops.mint-receive.cases';
import './ops.pay.cases' import './ops.pay.cases';
import './ops.retry-refusal.cases' import './ops.retry-refusal.cases';
import './ops.transfer.cases' import './ops.transfer.cases';
+55 -55
View File
@@ -1,69 +1,69 @@
import {afterEach, expect} from 'vitest' import { afterEach, expect } from 'vitest';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {buildNoteUrl, fetchNoteInfo} from 'lnurlcash-kit' import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit';
import type {Bearer} from './types' import type { Bearer } from './types';
import {requiredValue} from './test-utils' import { requiredValue } from './test-utils';
export type Mint = Awaited<ReturnType<typeof createMockMint>> export type Mint = Awaited<ReturnType<typeof createMockMint>>;
const mints: Mint[] = [] const mints: Mint[] = [];
export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => { export const mint = async (options: Parameters<typeof createMockMint>[0] = {}): Promise<Mint> => {
const instance = await createMockMint(options) const instance = await createMockMint(options);
mints.push(instance) mints.push(instance);
return instance return instance;
} };
afterEach(async () => { afterEach(async () => {
await Promise.all(mints.splice(0).map((instance) => instance.close())) await Promise.all(mints.splice(0).map((instance) => instance.close()));
}) });
export const secret = (seed: string): string => export const secret = (seed: string): string =>
bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed))) bytesToHex(sha256(hexToBytes('00'.repeat(31) + seed)));
export const persistOutput = async (): Promise<void> => undefined export const persistOutput = async (): Promise<void> => undefined;
// deterministic stand-in for the wallet's BIP-32 allocation path: answers // deterministic stand-in for the wallet's BIP-32 allocation path: answers
// every reservation with fresh unique secrets, never reusing one // every reservation with fresh unique secrets, never reusing one
let allocatedSecretCounter = 0 let allocatedSecretCounter = 0;
export const allocateOutputSecrets = ( export const allocateOutputSecrets = (
_server: string, _server: string,
count: number, count: number,
): Promise<readonly string[]> => { ): Promise<readonly string[]> => {
const secrets = Array.from({ length: count }, () => { const secrets = Array.from({ length: count }, () => {
allocatedSecretCounter += 1 allocatedSecretCounter += 1;
return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`))) return bytesToHex(sha256(utf8ToBytes(`allocated-${allocatedSecretCounter}`)));
}) });
return Promise.resolve(secrets) return Promise.resolve(secrets);
} };
// the receive-rotation staging checkpoint - engine tests keep no wallet, // the receive-rotation staging checkpoint - engine tests keep no wallet,
// so staging is a recorded no-op for them // so staging is a recorded no-op for them
export const stageRotation = async (): Promise<void> => undefined export const stageRotation = async (): Promise<void> => undefined;
// the trusted signing keys a production caller would serve from the // the trusted signing keys a production caller would serve from the
// trusted-mint registry: the mock mint's current key plus any it retired // trusted-mint registry: the mock mint's current key plus any it retired
export const signatureKeysFor = export const signatureKeysFor = (instance: Mint) => (): readonly string[] => [
(instance: Mint) => instance.state.pubkey,
(): readonly string[] => ...instance.state.previousPubkeys,
[instance.state.pubkey, ...instance.state.previousPubkeys] ];
export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string => export const noteUrl = (instance: Mint, k1: string, amountMsat?: number): string =>
buildNoteUrl(`${instance.url}/w`, k1, amountMsat) buildNoteUrl(`${instance.url}/w`, k1, amountMsat);
let fixtureCounter = 0 let fixtureCounter = 0;
export const makeBearer = async ( export const makeBearer = async (
instance: Mint, instance: Mint,
k1: string, k1: string,
amountMsat: number, amountMsat: number,
): Promise<Bearer> => { ): Promise<Bearer> => {
instance.state.creditNote(k1, amountMsat) instance.state.creditNote(k1, amountMsat);
const url = noteUrl(instance, k1, amountMsat) const url = noteUrl(instance, k1, amountMsat);
const info = await fetchNoteInfo(url) const info = await fetchNoteInfo(url);
fixtureCounter += 1 fixtureCounter += 1;
return { return {
id: `fixture-${fixtureCounter}`, id: `fixture-${fixtureCounter}`,
url, url,
@@ -73,29 +73,29 @@ export const makeBearer = async (
mintPubkey: instance.state.pubkey, mintPubkey: instance.state.pubkey,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
} };
} };
export const settleLastInvoice = async (instance: Mint): Promise<string> => { export const settleLastInvoice = async (instance: Mint): Promise<string> => {
const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1)) const paymentHash = requiredValue([...instance.state.invoices.keys()].at(-1));
const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`) const response = await fetch(`${instance.url}/_test/settle?payment_hash=${paymentHash}`);
if (!response.ok) throw new Error(`settle hook failed: ${response.status}`) if (!response.ok) throw new Error(`settle hook failed: ${response.status}`);
return requiredValue(instance.state.invoices.get(paymentHash)).preimage return requiredValue(instance.state.invoices.get(paymentHash)).preimage;
} };
export const settleWhenRequested = async (instance: Mint): Promise<string> => { export const settleWhenRequested = async (instance: Mint): Promise<string> => {
for (let attempt = 0; attempt < 200 && instance.state.invoices.size === 0; attempt++) { for (let attempt = 0; attempt < 200 && instance.state.invoices.size === 0; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10)) await new Promise((resolve) => setTimeout(resolve, 10));
}
return settleLastInvoice(instance)
} }
return settleLastInvoice(instance);
};
export const expectBurned = async (instance: Mint, k1: string): Promise<void> => { export const expectBurned = async (instance: Mint, k1: string): Promise<void> => {
for (let attempt = 0; attempt < 200 && instance.state.noteState(k1) !== 'burned'; attempt++) { for (let attempt = 0; attempt < 200 && instance.state.noteState(k1) !== 'burned'; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10)) await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(instance.state.noteState(k1)).toBe('burned')
} }
expect(instance.state.noteState(k1)).toBe('burned');
};
// a fetch wrapper that answers every /w/cb request with the response to // a fetch wrapper that answers every /w/cb request with the response to
// its SECOND, byte-identical send - the HTTP-stack GET retry a real client // its SECOND, byte-identical send - the HTTP-stack GET retry a real client
@@ -105,13 +105,13 @@ export const expectBurned = async (instance: Mint, k1: string): Promise<void> =>
// ever sees // ever sees
export const retryingCbFetch = (): typeof fetch => { export const retryingCbFetch = (): typeof fetch => {
const impl: typeof fetch = async (input, init) => { const impl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/w/cb')) { if (url.includes('/w/cb')) {
const first = await fetch(input, init) const first = await fetch(input, init);
await first.arrayBuffer() await first.arrayBuffer();
return fetch(input, init) return fetch(input, init);
}
return fetch(input, init)
}
return impl
} }
return fetch(input, init);
};
return impl;
};
+195 -202
View File
@@ -1,15 +1,12 @@
import {describe, expect, it} from 'vitest' import { describe, expect, it } from 'vitest';
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import {hashK1, noteK1} from 'lnurlcash-kit' import { hashK1, noteK1 } from 'lnurlcash-kit';
import {transferBetweenMints as transferBetweenMintsEngine} from './ops' import { transferBetweenMints as transferBetweenMintsEngine } from './ops';
import { import { OutputSecretAllocationRequiredError, TransferMeltCheckpointRequiredError } from './ops';
OutputSecretAllocationRequiredError, import type { CarveResult, TransferOptions } from './ops';
TransferMeltCheckpointRequiredError, import type { Bearer } from './types';
} from './ops' import { requiredValue } from './test-utils';
import type {CarveResult, TransferOptions} from './ops'
import type {Bearer} from './types'
import {requiredValue} from './test-utils'
import { import {
allocateOutputSecrets, allocateOutputSecrets,
expectBurned, expectBurned,
@@ -18,7 +15,7 @@ import {
persistOutput, persistOutput,
secret, secret,
settleWhenRequested, settleWhenRequested,
} from './ops.testHarness' } from './ops.testHarness';
const transferBetweenMints = ( const transferBetweenMints = (
bearers: Bearer[], bearers: Bearer[],
@@ -31,313 +28,309 @@ const transferBetweenMints = (
onMeltReady: () => undefined, onMeltReady: () => undefined,
...options, ...options,
persistOutput, persistOutput,
}) });
describe('transferBetweenMints', () => { describe('transferBetweenMints', () => {
const fastPoll = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} const fastPoll = { intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000 };
it('draws the target and source secrets from the caller allocation path', async () => { it('draws the target and source secrets from the caller allocation path', async () => {
const source = await mint() const source = await mint();
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('91'), 21_000) const bearer = await makeBearer(source, secret('91'), 21_000);
const targetSecret = secret('92') const targetSecret = secret('92');
const sourceRecoverySecret = secret('93') const sourceRecoverySecret = secret('93');
const allocations: Array<{server: string; count: number}> = [] const allocations: Array<{ server: string; count: number }> = [];
const meltReadiness: string[] = [] const meltReadiness: string[] = [];
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
allocateOutputSecrets: (server, count) => { allocateOutputSecrets: (server, count) => {
allocations.push({server, count}) allocations.push({ server, count });
const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret] const secrets = allocations.length === 1 ? [targetSecret] : [sourceRecoverySecret];
return Promise.resolve(secrets.slice(0, count)) return Promise.resolve(secrets.slice(0, count));
}, },
onMeltReady: (_carve, recovery) => { onMeltReady: (_carve, recovery) => {
meltReadiness.push(recovery) meltReadiness.push(recovery);
}, },
}) });
await settleWhenRequested(target) await settleWhenRequested(target);
const result = await pending const result = await pending;
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
// the staged target note first, then the source recovery secret - an // the staged target note first, then the source recovery secret - an
// exact source carve allocates nothing // exact source carve allocates nothing
expect(allocations).toEqual([ expect(allocations).toEqual([
{ server: `127.0.0.1:${target.port}`, count: 1 }, { server: `127.0.0.1:${target.port}`, count: 1 },
{ server: `127.0.0.1:${source.port}`, count: 1 }, { server: `127.0.0.1:${source.port}`, count: 1 },
]) ]);
expect(meltReadiness).toEqual([sourceRecoverySecret]) expect(meltReadiness).toEqual([sourceRecoverySecret]);
expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret) expect(noteK1(requiredValue(result.mintedAtTarget).note.url)).toBe(targetSecret);
}) });
it('refuses the source melt without its durable checkpoint', async () => { it('refuses the source melt without its durable checkpoint', async () => {
const source = await mint() const source = await mint();
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('98'), 21_000) const bearer = await makeBearer(source, secret('98'), 21_000);
await expect( await expect(
transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
allocateOutputSecrets, allocateOutputSecrets,
persistOutput, persistOutput,
}), }),
).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError) ).rejects.toBeInstanceOf(TransferMeltCheckpointRequiredError);
// the target stage happened (invoice exists) but the source note is // the target stage happened (invoice exists) but the source note is
// untouched and nothing melted // untouched and nothing melted
expect(source.state.noteState(secret('98'))).toBe('outstanding') expect(source.state.noteState(secret('98'))).toBe('outstanding');
}) });
it('refuses at target staging without an allocation path', async () => { it('refuses at target staging without an allocation path', async () => {
const source = await mint() const source = await mint();
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('99'), 21_000) const bearer = await makeBearer(source, secret('99'), 21_000);
await expect( await expect(
transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { transferBetweenMintsEngine([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => undefined, onMeltReady: () => undefined,
persistOutput, persistOutput,
}), }),
).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError) ).rejects.toBeInstanceOf(OutputSecretAllocationRequiredError);
expect(source.state.noteState(secret('99'))).toBe('outstanding') expect(source.state.noteState(secret('99'))).toBe('outstanding');
}) });
it('moves value to another mint at a wallet-chosen target secret', async () => { it('moves value to another mint at a wallet-chosen target secret', async () => {
const source = await mint() const source = await mint();
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('40') const k1 = secret('40');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
}) });
const preimage = await settleWhenRequested(target) const preimage = await settleWhenRequested(target);
const result = await pending const result = await pending;
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(result.invoice).toMatch(/^lnbc/) expect(result.invoice).toMatch(/^lnbc/);
expect(result.quote).toEqual({ expect(result.quote).toEqual({
requestedMsat: 21_000, requestedMsat: 21_000,
grossMsat: 21_000, grossMsat: 21_000,
targetMintFeeMsat: 0, targetMintFeeMsat: 0,
sourceMeltFeeReserveMsat: 0, sourceMeltFeeReserveMsat: 0,
}) });
expect(result.sourceServer).not.toBe(result.targetServer) expect(result.sourceServer).not.toBe(result.targetServer);
await expectBurned(source, k1) await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget) const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.verified).toBe(true) expect(claimed.note.verified).toBe(true);
expect(target.state.noteState(preimage)).toBeNull() expect(target.state.noteState(preimage)).toBeNull();
const newK1 = requiredValue(noteK1(claimed.note.url)) const newK1 = requiredValue(noteK1(claimed.note.url));
expect(newK1).not.toBe(preimage) expect(newK1).not.toBe(preimage);
expect(target.state.noteState(newK1)).toBe('outstanding') expect(target.state.noteState(newK1)).toBe('outstanding');
}) });
it('refuses an amount no source mint can cover', async () => { it('refuses an amount no source mint can cover', async () => {
const source = await mint() const source = await mint();
const target = await mint() const target = await mint();
const k1 = secret('41') const k1 = secret('41');
const bearer = await makeBearer(source, k1, 5_000) const bearer = await makeBearer(source, k1, 5_000);
let persisted = false let persisted = false;
await expect( await expect(
transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, { transferBetweenMintsEngine([bearer], 50_000, `mint@127.0.0.1:${target.port}`, {
persistOutput: () => { persistOutput: () => {
persisted = true persisted = true;
}, },
}), }),
).rejects.toThrow(/enough/) ).rejects.toThrow(/enough/);
expect(persisted).toBe(false) expect(persisted).toBe(false);
expect(source.state.noteState(k1)).toBe('outstanding') expect(source.state.noteState(k1)).toBe('outstanding');
}) });
it('rejects a transfer onto the mint the notes are already on', async () => { it('rejects a transfer onto the mint the notes are already on', async () => {
const instance = await mint() const instance = await mint();
const k1 = secret('42') const k1 = secret('42');
const bearer = await makeBearer(instance, k1, 21_000) const bearer = await makeBearer(instance, k1, 21_000);
await expect( await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`), transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${instance.port}`),
).rejects.toThrow(/different target/) ).rejects.toThrow(/different target/);
expect(instance.state.noteState(k1)).toBe('outstanding') expect(instance.state.noteState(k1)).toBe('outstanding');
}) });
it('moves nothing when the target mint is unreachable', async () => { it('moves nothing when the target mint is unreachable', async () => {
const source = await mint() const source = await mint();
const dead = await createMockMint() const dead = await createMockMint();
const deadAddress = `mint@127.0.0.1:${dead.port}` const deadAddress = `mint@127.0.0.1:${dead.port}`;
await dead.close() await dead.close();
const k1 = secret('43') const k1 = secret('43');
const bearer = await makeBearer(source, k1, 50_000) const bearer = await makeBearer(source, k1, 50_000);
await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow() await expect(transferBetweenMints([bearer], 21_000, deadAddress)).rejects.toThrow();
expect(source.state.noteState(k1)).toBe('outstanding') expect(source.state.noteState(k1)).toBe('outstanding');
}) });
it('reports a definitively spent source before waiting on the target', async () => { it('reports a definitively spent source before waiting on the target', async () => {
const source = await mint() const source = await mint();
const target = await mint() const target = await mint();
const k1 = secret('54') const k1 = secret('54');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
source.state.settleMelt(k1) source.state.settleMelt(k1);
const result = await transferBetweenMints( const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`);
[bearer],
21_000,
`mint@127.0.0.1:${target.port}`,
)
expect(result.outcome).toBe('note-already-spent') expect(result.outcome).toBe('note-already-spent');
}) });
it('recovers from a melt whose answer was lost once the target invoice settles', async () => { it('recovers from a melt whose answer was lost once the target invoice settles', async () => {
const source = await mint({unconfirmedMutation: true}) const source = await mint({ unconfirmedMutation: true });
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('44') const k1 = secret('44');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
}) });
await settleWhenRequested(target) await settleWhenRequested(target);
const result = await pending const result = await pending;
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
await expectBurned(source, k1) await expectBurned(source, k1);
expect(result.mintedAtTarget?.note.amount).toBe(21_000) expect(result.mintedAtTarget?.note.amount).toBe(21_000);
}) });
it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => { it('grosses the carve up for the target mint fee, refusing when only the net is covered', async () => {
const source = await mint() const source = await mint();
const target = await mint({baseFeeMsat: 1_000, feePpm: 2_000}) const target = await mint({ baseFeeMsat: 1_000, feePpm: 2_000 });
const k1 = secret('46') const k1 = secret('46');
const bearer = await makeBearer(source, k1, 100_000) const bearer = await makeBearer(source, k1, 100_000);
await expect( await expect(
transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`), transferBetweenMints([bearer], 100_000, `mint@127.0.0.1:${target.port}`),
).rejects.toThrow(/enough/) ).rejects.toThrow(/enough/);
expect(source.state.noteState(k1)).toBe('outstanding') expect(source.state.noteState(k1)).toBe('outstanding');
}) });
it('restores the source note, re-secured, when the melt fails', async () => { it('restores the source note, re-secured, when the melt fails', async () => {
const source = await mint({meltAlwaysFails: true}) const source = await mint({ meltAlwaysFails: true });
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('47') const k1 = secret('47');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const result = await transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) });
expect(result.outcome).toBe('failed-funds-returned') expect(result.outcome).toBe('failed-funds-returned');
expect(result.mintedAtTarget).toBeUndefined() expect(result.mintedAtTarget).toBeUndefined();
expect(source.state.noteState(k1)).toBe('burned') expect(source.state.noteState(k1)).toBe('burned');
// the returned funds come back re-secured at the source-probe rotate's // the returned funds come back re-secured at the source-probe rotate's
// fresh secret - carried as rotatedNote, NOT folded into the carve // fresh secret - carried as rotatedNote, NOT folded into the carve
// (which an onCarve checkpoint may already have committed) // (which an onCarve checkpoint may already have committed)
const rotatedNote = requiredValue(result.rotatedNote) const rotatedNote = requiredValue(result.rotatedNote);
expect(rotatedNote.verified).toBe(true) expect(rotatedNote.verified).toBe(true);
const returnedK1 = requiredValue(noteK1(rotatedNote.url)) const returnedK1 = requiredValue(noteK1(rotatedNote.url));
expect(returnedK1).not.toBe(k1) expect(returnedK1).not.toBe(k1);
expect(source.state.noteState(returnedK1)).toBe('outstanding') expect(source.state.noteState(returnedK1)).toBe('outstanding');
expect(rotatedNote.amount).toBe(21_000) expect(rotatedNote.amount).toBe(21_000);
}) });
it('lands the target note at the staged wallet secret without rotating', async () => { it('lands the target note at the staged wallet secret without rotating', async () => {
const source = await mint() const source = await mint();
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('48') const k1 = secret('48');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
}) });
const preimage = await settleWhenRequested(target) const preimage = await settleWhenRequested(target);
const result = await pending const result = await pending;
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
await expectBurned(source, k1) await expectBurned(source, k1);
const claimed = requiredValue(result.mintedAtTarget) const claimed = requiredValue(result.mintedAtTarget);
expect(claimed.note.amount).toBe(21_000) expect(claimed.note.amount).toBe(21_000);
expect(claimed.note.verified).toBe(true) expect(claimed.note.verified).toBe(true);
const newK1 = requiredValue(noteK1(claimed.note.url)) const newK1 = requiredValue(noteK1(claimed.note.url));
// the note stands at the wallet's own secret - the quote was bound to // the note stands at the wallet's own secret - the quote was bound to
// its hash, and the payment preimage keys nothing at the target // its hash, and the payment preimage keys nothing at the target
expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1)) expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1));
expect(target.state.noteState(newK1)).toBe('outstanding') expect(target.state.noteState(newK1)).toBe('outstanding');
expect(target.state.noteState(preimage)).toBeNull() expect(target.state.noteState(preimage)).toBeNull();
}) });
it('keeps the named claim material when the target never credits the note', async () => { it('keeps the named claim material when the target never credits the note', async () => {
const source = await mint({meltNeverSettles: true}) const source = await mint({ meltNeverSettles: true });
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('49') const k1 = secret('49');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 }, poll: { intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300 },
}) });
// the target invoice exists (the quote is bound) but the source melt // the target invoice exists (the quote is bound) but the source melt
// never lands, so nothing is ever credited at the wallet's secret // never lands, so nothing is ever credited at the wallet's secret
while (target.state.invoices.size === 0) { while (target.state.invoices.size === 0) {
await new Promise((resolve) => setTimeout(resolve, 10)) await new Promise((resolve) => setTimeout(resolve, 10));
} }
const result = await pending const result = await pending;
expect(result.outcome).toBe('unknown-still-pending') expect(result.outcome).toBe('unknown-still-pending');
expect(result.mintedAtTarget).toBeUndefined() expect(result.mintedAtTarget).toBeUndefined();
// the wallet's secret is the way back to the money if the melt's // the wallet's secret is the way back to the money if the melt's
// payment arrives later - and the note at that secret is tracked // payment arrives later - and the note at that secret is tracked
// unverified, so a late settlement is never a lost note // unverified, so a late settlement is never a lost note
const noteSecret = requiredValue(result.claimMaterial?.noteSecret) const noteSecret = requiredValue(result.claimMaterial?.noteSecret);
expect(noteSecret).toMatch(/^[0-9a-f]{64}$/) expect(noteSecret).toMatch(/^[0-9a-f]{64}$/);
const material = requiredValue(result.claimMaterial?.note) const material = requiredValue(result.claimMaterial?.note);
expect(material.verified).toBe(false) expect(material.verified).toBe(false);
expect(noteK1(material.url)).toBe(noteSecret) expect(noteK1(material.url)).toBe(noteSecret);
}) });
it('fires onCarve before the carve or melt reaches the mint', async () => { it('fires onCarve before the carve or melt reaches the mint', async () => {
const source = await mint() const source = await mint();
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const bearer = await makeBearer(source, secret('51'), 21_000) const bearer = await makeBearer(source, secret('51'), 21_000);
const seen: CarveResult[] = [] const seen: CarveResult[] = [];
const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, { const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
poll: fastPoll, poll: fastPoll,
onCarve: (carve) => { onCarve: (carve) => {
seen.push(carve) seen.push(carve);
if (carve.consumed.length === 0) { if (carve.consumed.length === 0) {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull() expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBeNull();
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding') expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('outstanding');
} else { } else {
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding') expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding');
expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned') expect(source.state.noteState(requiredValue(noteK1(bearer.url)))).toBe('burned');
} }
}, },
}) });
await settleWhenRequested(target) await settleWhenRequested(target);
const result = await pending const result = await pending;
expect(result.outcome).toBe('settled') expect(result.outcome).toBe('settled');
expect(seen).toHaveLength(2) expect(seen).toHaveLength(2);
expect(seen[0]?.consumed).toEqual([]) expect(seen[0]?.consumed).toEqual([]);
expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]) expect(seen[1]?.consumed.map((entry) => entry.id)).toEqual([bearer.id]);
}) });
it('aborts before the melt when the carve commit hook fails', async () => { it('aborts before the melt when the carve commit hook fails', async () => {
const source = await mint() const source = await mint();
const target = await mint({testHooks: true}) const target = await mint({ testHooks: true });
const k1 = secret('52') const k1 = secret('52');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
await expect( await expect(
transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, { transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
onCarve: () => { onCarve: () => {
throw new Error('commit failed') throw new Error('commit failed');
}, },
}), }),
).rejects.toThrow(/commit failed/) ).rejects.toThrow(/commit failed/);
// The checkpoint failed before either split or melt reached the source. // The checkpoint failed before either split or melt reached the source.
expect(source.state.noteState(k1)).toBe('outstanding') expect(source.state.noteState(k1)).toBe('outstanding');
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true) expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true);
}) });
it('aborts before an exact-note melt when the final checkpoint fails', async () => { it('aborts before an exact-note melt when the final checkpoint fails', async () => {
const source = await mint() const source = await mint();
const target = await mint() const target = await mint();
const k1 = secret('53') const k1 = secret('53');
const bearer = await makeBearer(source, k1, 21_000) const bearer = await makeBearer(source, k1, 21_000);
await expect( await expect(
transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, {
onMeltReady: () => { onMeltReady: () => {
throw new Error('source link commit failed') throw new Error('source link commit failed');
}, },
}), }),
).rejects.toThrow(/source link commit failed/) ).rejects.toThrow(/source link commit failed/);
expect(source.state.noteState(k1)).toBe('outstanding') expect(source.state.noteState(k1)).toBe('outstanding');
}) });
}) });
+14 -19
View File
@@ -25,41 +25,36 @@
// source, mint + claim at target) // source, mint + claim at target)
// ops/shared.ts - bounded polling, UncertainOutcomeError // ops/shared.ts - bounded polling, UncertainOutcomeError
export {UncertainOutcomeError} from './ops/shared' export { UncertainOutcomeError } from './ops/shared';
export type {FundOperationOptions, PollOptions} from './ops/shared' export type { FundOperationOptions, PollOptions } from './ops/shared';
export {OutputSecretAllocationRequiredError} from './ops/allocation' export { OutputSecretAllocationRequiredError } from './ops/allocation';
export type {OutputSecretAllocator} from './ops/allocation' export type { OutputSecretAllocator } from './ops/allocation';
export { export {
CarveCheckpointRequiredError, CarveCheckpointRequiredError,
ensureExactAmount, ensureExactAmount,
UnsupportedMultiBatchMergeError, UnsupportedMultiBatchMergeError,
} from './ops/carve' } from './ops/carve';
export type {CarveOptions, CarveResult} from './ops/carve' export type { CarveOptions, CarveResult } from './ops/carve';
export { export {
MintedNoteSpentError, MintedNoteSpentError,
prepareMint, prepareMint,
claimMintedNote, claimMintedNote,
recoverStagedMintOutput, recoverStagedMintOutput,
} from './ops/mint' } from './ops/mint';
export type { export type { PreparedMint, PrepareMintOptions, ClaimedNote, StagedMintRecovery } from './ops/mint';
PreparedMint, export { receiveBearer, ReceiveRotationStagingRequiredError } from './ops/receiveBearer';
PrepareMintOptions, export type { ReceiveBearerOptions, ReceivedNote } from './ops/receiveBearer';
ClaimedNote, export { payWithBearers, PayReturnCheckpointRequiredError } from './ops/pay';
StagedMintRecovery, export type { PayOutcome, PayResult, PayOptions } from './ops/pay';
} from './ops/mint'
export {receiveBearer, ReceiveRotationStagingRequiredError} from './ops/receiveBearer'
export type {ReceiveBearerOptions, ReceivedNote} from './ops/receiveBearer'
export {payWithBearers, PayReturnCheckpointRequiredError} from './ops/pay'
export type {PayOutcome, PayResult, PayOptions} from './ops/pay'
export { export {
recoverPendingTransferSource, recoverPendingTransferSource,
transferBetweenMints, transferBetweenMints,
TransferMeltCheckpointRequiredError, TransferMeltCheckpointRequiredError,
} from './ops/transfer' } from './ops/transfer';
export type { export type {
TransferClaimMaterial, TransferClaimMaterial,
TransferOptions, TransferOptions,
TransferOutcome, TransferOutcome,
TransferQuote, TransferQuote,
TransferResult, TransferResult,
} from './ops/transfer' } from './ops/transfer';
+8 -8
View File
@@ -12,13 +12,13 @@
// point-of-use requirement. Tests inject deterministic allocators; // point-of-use requirement. Tests inject deterministic allocators;
// production callers serve reserveCashIndices-backed secrets. // production callers serve reserveCashIndices-backed secrets.
export type OutputSecretAllocator = (server: string, count: number) => Promise<readonly string[]> export type OutputSecretAllocator = (server: string, count: number) => Promise<readonly string[]>;
export class OutputSecretAllocationRequiredError extends Error { export class OutputSecretAllocationRequiredError extends Error {
override readonly name = 'OutputSecretAllocationRequiredError' override readonly name = 'OutputSecretAllocationRequiredError';
constructor() { constructor() {
super('This operation requires durably allocated output secrets.') super('This operation requires durably allocated output secrets.');
} }
} }
@@ -31,13 +31,13 @@ export const requireOutputSecrets = async (
server: string, server: string,
count: number, count: number,
): Promise<readonly string[]> => { ): Promise<readonly string[]> => {
if (!allocate) throw new OutputSecretAllocationRequiredError() if (!allocate) throw new OutputSecretAllocationRequiredError();
const secrets = await allocate(server, count) const secrets = await allocate(server, count);
if ( if (
secrets.length !== count || secrets.length !== count ||
secrets.some((secret) => typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret)) secrets.some((secret) => typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret))
) { ) {
throw new Error('The output secret allocation did not match the request.') throw new Error('The output secret allocation did not match the request.');
}
return secrets
} }
return secrets;
};
+98 -99
View File
@@ -17,46 +17,46 @@ import {
serverOf, serverOf,
splitNote, splitNote,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {OutputSecretAllocator} from './allocation' import type { OutputSecretAllocator } from './allocation';
import {requireOutputSecrets} from './allocation' import { requireOutputSecrets } from './allocation';
import {mergeAmbiguitySafe} from './carveRecovery' import { mergeAmbiguitySafe } from './carveRecovery';
import type {FundOperationOptions} from './shared' import type { FundOperationOptions } from './shared';
import { import {
assertFundOwner, assertFundOwner,
landedNoteVerifies, landedNoteVerifies,
probeMutationOutput, probeMutationOutput,
UncertainOutcomeError, UncertainOutcomeError,
withMutationSafety, withMutationSafety,
} from './shared' } from './shared';
// The same shape serves both checkpoint phases: the pre-wire phase carries // The same shape serves both checkpoint phases: the pre-wire phase carries
// note/change with an empty consumed list, and the landed phase carries the // note/change with an empty consumed list, and the landed phase carries the
// already-staged note plus the inputs the caller can now retire. // already-staged note plus the inputs the caller can now retire.
export type CarveResult = { export type CarveResult = {
// the exact-amount note, ready to hand over or melt // the exact-amount note, ready to hand over or melt
note: NewBearer note: NewBearer;
// the remainder note, when the carve split a larger input // the remainder note, when the carve split a larger input
change?: NewBearer change?: NewBearer;
// the input notes burned server-side by the carve (empty when a single // the input notes burned server-side by the carve (empty when a single
// note already held exactly the target amount) // note already held exactly the target amount)
consumed: Bearer[] consumed: Bearer[];
} };
export class UnsupportedMultiBatchMergeError extends Error { export class UnsupportedMultiBatchMergeError extends Error {
override readonly name = 'UnsupportedMultiBatchMergeError' override readonly name = 'UnsupportedMultiBatchMergeError';
constructor() { constructor() {
super('This carve would require multiple merge requests and cannot be performed safely.') super('This carve would require multiple merge requests and cannot be performed safely.');
} }
} }
export class CarveCheckpointRequiredError extends Error { export class CarveCheckpointRequiredError extends Error {
override readonly name = 'CarveCheckpointRequiredError' override readonly name = 'CarveCheckpointRequiredError';
constructor() { constructor() {
super('A mutating carve requires a durable onCarve checkpoint.') super('A mutating carve requires a durable onCarve checkpoint.');
} }
} }
@@ -65,8 +65,8 @@ export type CarveOptions = FundOperationOptions & {
// (split: target + change; merge: the combined note). Required for any // (split: target + change; merge: the combined note). Required for any
// mutating carve: the secrets come from the wallet's reserved BIP-32 // mutating carve: the secrets come from the wallet's reserved BIP-32
// indices so a restart can never reuse or lose them. // indices so a restart can never reuse or lose them.
readonly allocateOutputSecrets?: OutputSecretAllocator readonly allocateOutputSecrets?: OutputSecretAllocator;
} };
// Selection: only notes that can actually take part - verified (callback // Selection: only notes that can actually take part - verified (callback
// known), not locally spent, holding a real k1 (device-backed mirrors are // known), not locally spent, holding a real k1 (device-backed mirrors are
@@ -89,48 +89,47 @@ export const ensureExactAmount = async (
options: CarveOptions = {}, options: CarveOptions = {},
): Promise<CarveResult> => { ): Promise<CarveResult> => {
if (!Number.isInteger(amountMsat) || amountMsat <= 0) { if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.') throw new Error('Amount must be a positive whole number of msat.');
} }
// the forced mutation policy (signatures required, one byte-identical // the forced mutation policy (signatures required, one byte-identical
// replay) applies to every mint call below regardless of caller options // replay) applies to every mint call below regardless of caller options
const mutationOptions = withMutationSafety(options) const mutationOptions = withMutationSafety(options);
const eligible = bearers.filter( const eligible = bearers.filter(
(b) => b.verified && !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url), (b) => b.verified && !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
) );
// per-server greedy pick: smallest notes first until the target is // per-server greedy pick: smallest notes first until the target is
// covered (an exact single-note match short-circuits - no mutation at // covered (an exact single-note match short-circuits - no mutation at
// all is always better than carving) // all is always better than carving)
const byServer = new Map<string, Bearer[]>() const byServer = new Map<string, Bearer[]>();
for (const b of eligible) { for (const b of eligible) {
const server = serverOf(b.url) const server = serverOf(b.url);
byServer.set(server, [...(byServer.get(server) ?? []), b]) byServer.set(server, [...(byServer.get(server) ?? []), b]);
} }
let pick: Bearer[] | null = null let pick: Bearer[] | null = null;
for (const group of byServer.values()) { for (const group of byServer.values()) {
const sorted = [...group].sort((a, b) => a.amount - b.amount) const sorted = [...group].sort((a, b) => a.amount - b.amount);
const exact = sorted.find((b) => b.amount === amountMsat) const exact = sorted.find((b) => b.amount === amountMsat);
const candidate = exact ? [exact] : accumulate(sorted, amountMsat) const candidate = exact ? [exact] : accumulate(sorted, amountMsat);
if (!candidate) continue if (!candidate) continue;
if (!pick || better(candidate, pick, amountMsat)) pick = candidate if (!pick || better(candidate, pick, amountMsat)) pick = candidate;
} }
if (!pick) { if (!pick) {
throw new Error('No mint holds enough verified, unspent balance to cover that amount.') throw new Error('No mint holds enough verified, unspent balance to cover that amount.');
} }
const base = pick[0] const base = pick[0];
const total = pick.reduce((sum, b) => sum + b.amount, 0) const total = pick.reduce((sum, b) => sum + b.amount, 0);
const k1s = pick.map((b) => requireNoteK1(b.url)) const k1s = pick.map((b) => requireNoteK1(b.url));
const checkpoint = async (result: CarveResult): Promise<void> => { const checkpoint = async (result: CarveResult): Promise<void> => {
if (!options.onCarve) { if (!options.onCarve) {
throw new CarveCheckpointRequiredError() throw new CarveCheckpointRequiredError();
} }
assertFundOwner(options) assertFundOwner(options);
await options.onCarve(result) await options.onCarve(result);
assertFundOwner(options) assertFundOwner(options);
} };
const stage = (result: CarveResult): Promise<void> => const stage = (result: CarveResult): Promise<void> => checkpoint({ ...result, consumed: [] });
checkpoint({...result, consumed: []}) const retire = (note: NewBearer): Promise<void> => checkpoint({ note, consumed: pick });
const retire = (note: NewBearer): Promise<void> => checkpoint({note, consumed: pick})
if (pick.length === 1 && total === amountMsat) { if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched // already exact - hand over the note itself, untouched
@@ -143,39 +142,39 @@ export const ensureExactAmount = async (
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
}, },
consumed: [], consumed: [],
} };
} }
if (total === amountMsat) { if (total === amountMsat) {
if (mergeBatches(base.callback, k1s).length !== 1) { if (mergeBatches(base.callback, k1s).length !== 1) {
throw new UnsupportedMultiBatchMergeError() throw new UnsupportedMultiBatchMergeError();
} }
if (!options.onCarve) { if (!options.onCarve) {
throw new CarveCheckpointRequiredError() throw new CarveCheckpointRequiredError();
} }
const [mergeSecret] = await requireOutputSecrets( const [mergeSecret] = await requireOutputSecrets(
options.allocateOutputSecrets, options.allocateOutputSecrets,
serverOf(base.url), serverOf(base.url),
1, 1,
) );
const staged: NewBearer = { const staged: NewBearer = {
url: withNewK1(base.url, mergeSecret, total), url: withNewK1(base.url, mergeSecret, total),
callback: base.callback, callback: base.callback,
amount: total, amount: total,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} };
await stage({note: staged, consumed: pick}) await stage({ note: staged, consumed: pick });
assertFundOwner(options) assertFundOwner(options);
const merged = await mergeAmbiguitySafe(base, k1s, { const merged = await mergeAmbiguitySafe(base, k1s, {
...mutationOptions, ...mutationOptions,
randomSecret: () => mergeSecret, randomSecret: () => mergeSecret,
}) });
// a landed merge reports its signature (carried in the URL's sig param) // a landed merge reports its signature (carried in the URL's sig param)
// and earns verified only when that signature checks against a trusted // and earns verified only when that signature checks against a trusted
// current or previous key; a rescued merge saw no answer, so its note // current or previous key; a rescued merge saw no answer, so its note
// stays exactly as staged - unverified, same URL // stays exactly as staged - unverified, same URL
const landedUrl = withNewK1(base.url, mergeSecret, total, merged.signature) const landedUrl = withNewK1(base.url, mergeSecret, total, merged.signature);
const result: CarveResult = merged.rescued const result: CarveResult = merged.rescued
? { note: staged, consumed: pick } ? { note: staged, consumed: pick }
: { : {
@@ -185,66 +184,66 @@ export const ensureExactAmount = async (
verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys), verified: landedNoteVerifies(landedUrl, options.mintSignatureKeys),
}, },
consumed: pick, consumed: pick,
} };
await retire(result.note) await retire(result.note);
return result return result;
} }
// split path: total above target - one split request across all picked // split path: total above target - one split request across all picked
// k1s, carving the target off and leaving the change as a fresh note // k1s, carving the target off and leaving the change as a fresh note
if (!options.onCarve) { if (!options.onCarve) {
throw new CarveCheckpointRequiredError() throw new CarveCheckpointRequiredError();
} }
const [partK1, changeK1] = await requireOutputSecrets( const [partK1, changeK1] = await requireOutputSecrets(
options.allocateOutputSecrets, options.allocateOutputSecrets,
serverOf(base.url), serverOf(base.url),
2, 2,
) );
let partSignature: string | undefined let partSignature: string | undefined;
let changeSignature: string | undefined let changeSignature: string | undefined;
const stagedNote: NewBearer = { const stagedNote: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat), url: withNewK1(base.url, partK1, amountMsat),
callback: base.callback, callback: base.callback,
amount: amountMsat, amount: amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} };
const stagedChange: NewBearer = { const stagedChange: NewBearer = {
url: withNewK1(base.url, changeK1, total - amountMsat), url: withNewK1(base.url, changeK1, total - amountMsat),
callback: base.callback, callback: base.callback,
amount: total - amountMsat, amount: total - amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} };
await stage({note: stagedNote, change: stagedChange, consumed: pick}) await stage({ note: stagedNote, change: stagedChange, consumed: pick });
let secretIndex = 0 let secretIndex = 0;
const preparedSecret = (): string => { const preparedSecret = (): string => {
secretIndex += 1 secretIndex += 1;
if (secretIndex === 1) return partK1 if (secretIndex === 1) return partK1;
if (secretIndex === 2) return changeK1 if (secretIndex === 2) return changeK1;
throw new Error('The split requested more output secrets than were staged.') throw new Error('The split requested more output secrets than were staged.');
} };
assertFundOwner(options) assertFundOwner(options);
try { try {
const parts = await splitNote(base.callback, k1s, amountMsat, { const parts = await splitNote(base.callback, k1s, amountMsat, {
...mutationOptions, ...mutationOptions,
randomSecret: preparedSecret, randomSecret: preparedSecret,
}) });
partSignature = parts.signature partSignature = parts.signature;
changeSignature = parts.changeSignature changeSignature = parts.changeSignature;
} catch (err) { } catch (err) {
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one // the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth // input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, mutationOptions) const outcome = await probeBurnedNote(base.url, mutationOptions);
if (outcome === 'live') throw err // nothing burned - a plain failure if (outcome === 'live') throw err; // nothing burned - a plain failure
if (outcome === 'unknown') { if (outcome === 'unknown') {
// can't tell: surface both possible outputs unverified WITHOUT // can't tell: surface both possible outputs unverified WITHOUT
// consuming the inputs, and stop here rather than spend from limbo // consuming the inputs, and stop here rather than spend from limbo
throw new UncertainOutcomeError( throw new UncertainOutcomeError(
'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.', 'The split may have gone through but could not be confirmed - its possible outputs were already staged unverified alongside the originals.',
[], [],
) );
} }
// 'gone': the burn landed - the carried secrets are the only money // 'gone': the burn landed - the carried secrets are the only money
} else { } else {
@@ -253,10 +252,10 @@ export const ensureExactAmount = async (
// the first attempt and refused this one as an already-spent input. // the first attempt and refused this one as an already-spent input.
// The kit attaches the fresh output secrets to every refusal - probe // The kit attaches the fresh output secrets to every refusal - probe
// one output before deciding they are worthless. // one output before deciding they are worthless.
const carried = newSecretsOf(err) const carried = newSecretsOf(err);
if (carried.length !== 2) throw err if (carried.length !== 2) throw err;
const outcome = await probeMutationOutput(base.url, carried[0], mutationOptions) const outcome = await probeMutationOutput(base.url, carried[0], mutationOptions);
if (outcome === 'absent') throw err // never landed - a plain refusal if (outcome === 'absent') throw err; // never landed - a plain refusal
if (outcome === 'unknown') { if (outcome === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as // can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume // the ambiguous case above: track the possible outputs, consume
@@ -264,7 +263,7 @@ export const ensureExactAmount = async (
throw new UncertainOutcomeError( throw new UncertainOutcomeError(
'The split was refused, but the refusal may have named a retry that already landed - its possible outputs were already staged unverified alongside the originals.', 'The split was refused, but the refusal may have named a retry that already landed - its possible outputs were already staged unverified alongside the originals.',
[], [],
) );
} }
// 'live': the split landed and this answer was its retried twin - // 'live': the split landed and this answer was its retried twin -
// the carried secrets are the only money left // the carried secrets are the only money left
@@ -277,51 +276,51 @@ export const ensureExactAmount = async (
const noteUrl = const noteUrl =
partSignature === undefined partSignature === undefined
? stagedNote.url ? stagedNote.url
: withNewK1(base.url, partK1, amountMsat, partSignature) : withNewK1(base.url, partK1, amountMsat, partSignature);
const note: NewBearer = { const note: NewBearer = {
url: noteUrl, url: noteUrl,
callback: base.callback, callback: base.callback,
amount: amountMsat, amount: amountMsat,
verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys), verified: landedNoteVerifies(noteUrl, options.mintSignatureKeys),
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} };
// The change may be worth less than total - amount when the mint charges // The change may be worth less than total - amount when the mint charges
// split fees. It remains unverified at that upper bound until refresh; // split fees. It remains unverified at that upper bound until refresh;
// settling it here would rotate to an unstaged secret in a second request. // settling it here would rotate to an unstaged secret in a second request.
const changeUrl = const changeUrl =
changeSignature === undefined changeSignature === undefined
? stagedChange.url ? stagedChange.url
: withNewK1(base.url, changeK1, total - amountMsat, changeSignature) : withNewK1(base.url, changeK1, total - amountMsat, changeSignature);
const change: NewBearer = { const change: NewBearer = {
url: changeUrl, url: changeUrl,
callback: base.callback, callback: base.callback,
amount: total - amountMsat, amount: total - amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey, mintPubkey: base.mintPubkey,
} };
const result = {note, change, consumed: pick} const result = { note, change, consumed: pick };
await retire(note) await retire(note);
return result return result;
} };
// smallest-first accumulation until the target is covered; null when the // smallest-first accumulation until the target is covered; null when the
// whole group can't reach it // whole group can't reach it
const accumulate = (sorted: Bearer[], amountMsat: number): Bearer[] | null => { const accumulate = (sorted: Bearer[], amountMsat: number): Bearer[] | null => {
const picked: Bearer[] = [] const picked: Bearer[] = [];
let total = 0 let total = 0;
for (const b of sorted) { for (const b of sorted) {
picked.push(b) picked.push(b);
total += b.amount total += b.amount;
if (total >= amountMsat) return picked if (total >= amountMsat) return picked;
}
return null
} }
return null;
};
// the better carve plan: less waste first, then fewer notes burned // the better carve plan: less waste first, then fewer notes burned
const better = (a: Bearer[], b: Bearer[], target: number): boolean => { const better = (a: Bearer[], b: Bearer[], target: number): boolean => {
const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0) const sum = (notes: Bearer[]) => notes.reduce((s, n) => s + n.amount, 0);
const wasteA = sum(a) - target const wasteA = sum(a) - target;
const wasteB = sum(b) - target const wasteB = sum(b) - target;
if (wasteA !== wasteB) return wasteA < wasteB if (wasteA !== wasteB) return wasteA < wasteB;
return a.length < b.length return a.length < b.length;
} };
+20 -20
View File
@@ -2,18 +2,18 @@
// retried below the engine. This module owns the probes that distinguish a // retried below the engine. This module owns the probes that distinguish a
// plain refusal from a live or still-uncertain wallet-chosen output. // plain refusal from a live or still-uncertain wallet-chosen output.
import {AmbiguousMutationError, mergeNotes, newSecretsOf, probeBurnedNote} from 'lnurlcash-kit' import { AmbiguousMutationError, mergeNotes, newSecretsOf, probeBurnedNote } from 'lnurlcash-kit';
import type {Bearer} from '../types' import type { Bearer } from '../types';
import type {FundOperationOptions} from './shared' import type { FundOperationOptions } from './shared';
import {probeMutationOutput, UncertainOutcomeError} from './shared' import { probeMutationOutput, UncertainOutcomeError } from './shared';
export type MergeOutcome = { export type MergeOutcome = {
readonly rescued: boolean readonly rescued: boolean;
// the mint's signature over the combined note - present only when the // the mint's signature over the combined note - present only when the
// merge's own answer was seen; a rescued merge (lost answer, retried // merge's own answer was seen; a rescued merge (lost answer, retried
// twin) has none to offer // twin) has none to offer
readonly signature?: string readonly signature?: string;
} };
export const mergeAmbiguitySafe = async ( export const mergeAmbiguitySafe = async (
base: Bearer, base: Bearer,
@@ -21,30 +21,30 @@ export const mergeAmbiguitySafe = async (
options: FundOperationOptions, options: FundOperationOptions,
): Promise<MergeOutcome> => { ): Promise<MergeOutcome> => {
try { try {
const merged = await mergeNotes(base.callback, k1s, options) const merged = await mergeNotes(base.callback, k1s, options);
return {rescued: false, signature: merged.signature} return { rescued: false, signature: merged.signature };
} catch (error) { } catch (error) {
if (error instanceof AmbiguousMutationError) { if (error instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options) const outcome = await probeBurnedNote(base.url, options);
if (outcome === 'live') throw error if (outcome === 'live') throw error;
if (outcome === 'unknown') { if (outcome === 'unknown') {
throw new UncertainOutcomeError( throw new UncertainOutcomeError(
'The merge may have gone through but could not be confirmed - its possible combined note was already staged unverified alongside the originals.', 'The merge may have gone through but could not be confirmed - its possible combined note was already staged unverified alongside the originals.',
[], [],
) );
} }
return {rescued: true} return { rescued: true };
} }
const carried = newSecretsOf(error) const carried = newSecretsOf(error);
if (carried.length !== 1) throw error if (carried.length !== 1) throw error;
const outcome = await probeMutationOutput(base.url, carried[0], options) const outcome = await probeMutationOutput(base.url, carried[0], options);
if (outcome === 'absent') throw error if (outcome === 'absent') throw error;
if (outcome === 'unknown') { if (outcome === 'unknown') {
throw new UncertainOutcomeError( throw new UncertainOutcomeError(
'The merge was refused, but the refusal may have named a retry that already landed - its possible combined note was already staged unverified alongside the originals.', 'The merge was refused, but the refusal may have named a retry that already landed - its possible combined note was already staged unverified alongside the originals.',
[], [],
) );
}
return {rescued: true}
} }
return { rescued: true };
} }
};
+98 -98
View File
@@ -18,48 +18,48 @@ import {
noteK1, noteK1,
resolveMintInput, resolveMintInput,
serverOf, serverOf,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit' import type { MintAddressInfo, MintClaim } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import {ceilMsatToSat} from '../units' import { ceilMsatToSat } from '../units';
import type {OutputSecretAllocator} from './allocation' import type { OutputSecretAllocator } from './allocation';
import {requireOutputSecrets} from './allocation' import { requireOutputSecrets } from './allocation';
import type {PollOptions} from './shared' import type { PollOptions } from './shared';
import type {FundOperationOptions} from './shared' import type { FundOperationOptions } from './shared';
import {assertFundOwner, pollUntil, withMutationSafety} from './shared' import { assertFundOwner, pollUntil, withMutationSafety } from './shared';
export type PreparedMint = { export type PreparedMint = {
invoice: string invoice: string;
verifyUrl: string | null verifyUrl: string | null;
// The wallet-chosen secret the minted note lands at. Its unverified note // The wallet-chosen secret the minted note lands at. Its unverified note
// is persisted before the invoice request can put this secret's hash on // is persisted before the invoice request can put this secret's hash on
// the wire. // the wire.
noteSecret: string noteSecret: string;
// the net note value asked for - the claim cross-checks the service's // the net note value asked for - the claim cross-checks the service's
// authoritative maxWithdrawable against it // authoritative maxWithdrawable against it
expectedNoteValueMsat: number expectedNoteValueMsat: number;
// the gross amount actually invoiced (net + mint fee, rounded up to a // the gross amount actually invoiced (net + mint fee, rounded up to a
// whole sat - sub-sat invoices aren't reliably payable) // whole sat - sub-sat invoices aren't reliably payable)
grossMsat: number grossMsat: number;
mintUrl: string mintUrl: string;
withdrawLink: string withdrawLink: string;
mintPubkey?: string mintPubkey?: string;
server: string server: string;
username: string | null username: string | null;
nodeInfo: MintAddressInfo | null nodeInfo: MintAddressInfo | null;
} };
export type PrepareMintOptions = FundOperationOptions & { export type PrepareMintOptions = FundOperationOptions & {
readonly persistOutput: (note: NewBearer) => void | Promise<void> readonly persistOutput: (note: NewBearer) => void | Promise<void>;
readonly beforePersist?: (quote: { readonly beforePersist?: (quote: {
readonly grossMsat: number readonly grossMsat: number;
readonly server: string readonly server: string;
}) => void | Promise<void> }) => void | Promise<void>;
// the caller's durable allocation path for the staged note's secret - // the caller's durable allocation path for the staged note's secret -
// required: the wallet's reserved BIP-32 indices are the only source a // required: the wallet's reserved BIP-32 indices are the only source a
// restart can never reuse or lose // restart can never reuse or lose
readonly allocateOutputSecrets?: OutputSecretAllocator readonly allocateOutputSecrets?: OutputSecretAllocator;
} };
// Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover // Resolve a mint (Lightning Address, bare domain, bech32 LNURL), discover
// its mint address (best-effort LUD-25 experimental endpoint), read its // its mint address (best-effort LUD-25 experimental endpoint), read its
@@ -71,43 +71,43 @@ export const prepareMint = async (
amountMsat: number, amountMsat: number,
options: PrepareMintOptions, options: PrepareMintOptions,
): Promise<PreparedMint> => { ): Promise<PreparedMint> => {
const {beforePersist, persistOutput, ...rest} = options const { beforePersist, persistOutput, ...rest } = options;
// the forced mutation policy (see shared.ts) covers the invoice request // the forced mutation policy (see shared.ts) covers the invoice request
const kitOptions = withMutationSafety(rest) const kitOptions = withMutationSafety(rest);
if (!Number.isInteger(amountMsat) || amountMsat <= 0) { if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.') throw new Error('Amount must be a positive whole number of msat.');
} }
const url = resolveMintInput(mintInput) const url = resolveMintInput(mintInput);
if (!url) throw new Error('Enter a mint LNURL or Lightning Address.') if (!url) throw new Error('Enter a mint LNURL or Lightning Address.');
// best-effort mint-address discovery - derived from `url`'s own // best-effort mint-address discovery - derived from `url`'s own
// .well-known/lnurlp/{name} path; when it succeeds, its payLink is the // .well-known/lnurlp/{name} path; when it succeeds, its payLink is the
// authoritative place to fetch the payRequest from // authoritative place to fetch the payRequest from
const addressUrl = mintAddressUrl(url) const addressUrl = mintAddressUrl(url);
let nodeInfo: MintAddressInfo | null = null let nodeInfo: MintAddressInfo | null = null;
let payUrl = url let payUrl = url;
if (addressUrl) { if (addressUrl) {
try { try {
nodeInfo = await fetchMintAddress(addressUrl, kitOptions) nodeInfo = await fetchMintAddress(addressUrl, kitOptions);
payUrl = nodeInfo.payLink payUrl = nodeInfo.payLink;
} catch (error) { } catch (error) {
// no mint-address support here - proceed with just the guess // no mint-address support here - proceed with just the guess
if (!(error instanceof Error)) throw error if (!(error instanceof Error)) throw error;
} }
} }
const info = await fetchPayRequest(payUrl, kitOptions) const info = await fetchPayRequest(payUrl, kitOptions);
if (!info.withdrawLink) { if (!info.withdrawLink) {
throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).') throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).');
} }
const grossMsat = ceilMsatToSat( const grossMsat = ceilMsatToSat(
info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat, info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat,
) );
if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { if (grossMsat < info.minSendable || grossMsat > info.maxSendable) {
throw new Error("Amount is outside this mint's sendable range.") throw new Error("Amount is outside this mint's sendable range.");
} }
const server = serverOf(payUrl) const server = serverOf(payUrl);
await beforePersist?.({grossMsat, server}) await beforePersist?.({ grossMsat, server });
const [noteSecret] = await requireOutputSecrets(options.allocateOutputSecrets, server, 1) const [noteSecret] = await requireOutputSecrets(options.allocateOutputSecrets, server, 1);
const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey const mintPubkey = nodeInfo ? nodeInfo.mintPubkey : info.mintPubkey;
const staged: NewBearer = { const staged: NewBearer = {
url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat), url: buildNoteUrl(info.withdrawLink, noteSecret, amountMsat),
callback: '', callback: '',
@@ -116,14 +116,14 @@ export const prepareMint = async (
amount: 0, amount: 0,
verified: false, verified: false,
pendingMint: mintPubkey ? { mintPubkey } : {}, pendingMint: mintPubkey ? { mintPubkey } : {},
} };
assertFundOwner(options) assertFundOwner(options);
await persistOutput(staged) await persistOutput(staged);
assertFundOwner(options) assertFundOwner(options);
const invoice = await requestInvoice(info.callback, grossMsat, { const invoice = await requestInvoice(info.callback, grossMsat, {
...kitOptions, ...kitOptions,
h: hashK1(noteSecret), h: hashK1(noteSecret),
}) });
const prepared: PreparedMint = { const prepared: PreparedMint = {
invoice: invoice.pr, invoice: invoice.pr,
verifyUrl: invoice.verify ?? null, verifyUrl: invoice.verify ?? null,
@@ -135,23 +135,23 @@ export const prepareMint = async (
server, server,
username: lightningAddressUsername(payUrl), username: lightningAddressUsername(payUrl),
nodeInfo, nodeInfo,
} };
if (mintPubkey) prepared.mintPubkey = mintPubkey if (mintPubkey) prepared.mintPubkey = mintPubkey;
return prepared return prepared;
} };
export type ClaimedNote = { export type ClaimedNote = {
note: NewBearer note: NewBearer;
rotated: boolean rotated: boolean;
possibleCopy?: NewBearer possibleCopy?: NewBearer;
rotationError?: string rotationError?: string;
} };
export class MintedNoteSpentError extends Error { export class MintedNoteSpentError extends Error {
override readonly name = 'MintedNoteSpentError' override readonly name = 'MintedNoteSpentError';
constructor() { constructor() {
super('The mint reports the freshly minted note as already spent.') super('The mint reports the freshly minted note as already spent.');
} }
} }
@@ -168,22 +168,22 @@ export const pollMintClaim = async (
): Promise<MintClaim> => ): Promise<MintClaim> =>
pollUntil( pollUntil(
async (fetchOptions) => { async (fetchOptions) => {
const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions) const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions);
return claim.state === 'minted' || claim.state === 'spent' ? claim : null return claim.state === 'minted' || claim.state === 'spent' ? claim : null;
}, },
'Minted note not confirmed', 'Minted note not confirmed',
poll, poll,
options, options,
) );
export type ClaimTarget = { export type ClaimTarget = {
withdrawLink: string withdrawLink: string;
noteSecret: string noteSecret: string;
// the net note value asked for - a claim, cross-checked against the // the net note value asked for - a claim, cross-checked against the
// service's authoritative maxWithdrawable // service's authoritative maxWithdrawable
expectedNoteValueMsat: number expectedNoteValueMsat: number;
mintPubkey?: string mintPubkey?: string;
} };
// The note was credited to the wallet's own secret, which rode to the mint // The note was credited to the wallet's own secret, which rode to the mint
// only as a hash inside the invoice request. The payment preimage opens // only as a hash inside the invoice request. The payment preimage opens
@@ -193,37 +193,37 @@ export const claimFromSecret = async (
poll: PollOptions = {}, poll: PollOptions = {},
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => { ): Promise<ClaimedNote> => {
assertFundOwner(options) assertFundOwner(options);
const claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options) const claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options);
if (claim.state === 'spent') { if (claim.state === 'spent') {
throw new MintedNoteSpentError() throw new MintedNoteSpentError();
} }
if (claim.k1 !== target.noteSecret) { if (claim.k1 !== target.noteSecret) {
// belt and suspenders: the kit's claimMintedNote returns the queried // belt and suspenders: the kit's claimMintedNote returns the queried
// secret by construction and fetchNoteInfo rejects a mismatched echo, // secret by construction and fetchNoteInfo rejects a mismatched echo,
// so this cannot trip through the kit - if it ever does, building a // so this cannot trip through the kit - if it ever does, building a
// note from the answer would track the wrong secret // note from the answer would track the wrong secret
throw new Error('The mint answered the claim for a different note secret.') throw new Error('The mint answered the claim for a different note secret.');
} }
if (claim.amountMsat === null || !claim.callback) { if (claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the minted note details.') throw new Error('The mint did not return the minted note details.');
} }
const note: NewBearer = { const note: NewBearer = {
url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat), url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat),
callback: claim.callback, callback: claim.callback,
amount: claim.amountMsat, amount: claim.amountMsat,
verified: true, verified: true,
} };
if (target.mintPubkey) note.mintPubkey = target.mintPubkey if (target.mintPubkey) note.mintPubkey = target.mintPubkey;
return {note, rotated: true} return { note, rotated: true };
} };
// Polls until the minted note is claimable at the wallet's staged secret. // Polls until the minted note is claimable at the wallet's staged secret.
export const claimMintedNote = async ( export const claimMintedNote = async (
prepared: PreparedMint, prepared: PreparedMint,
poll: PollOptions = {}, poll: PollOptions = {},
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<ClaimedNote> => claimFromSecret(prepared, poll, options) ): Promise<ClaimedNote> => claimFromSecret(prepared, poll, options);
// One bounded observation for durable recovery. An unpaid invoice is normal: // One bounded observation for durable recovery. An unpaid invoice is normal:
// keep the staged output and try again at the next wallet/service activation. // keep the staged output and try again at the next wallet/service activation.
@@ -231,35 +231,35 @@ export type StagedMintRecovery =
| { readonly state: 'unminted' } | { readonly state: 'unminted' }
| { readonly state: 'pending' } | { readonly state: 'pending' }
| { readonly state: 'spent' } | { readonly state: 'spent' }
| {readonly state: 'minted'; readonly note: NewBearer} | { readonly state: 'minted'; readonly note: NewBearer };
export const recoverStagedMintOutput = async ( export const recoverStagedMintOutput = async (
staged: Bearer, staged: Bearer,
options: FundOperationOptions = {}, options: FundOperationOptions = {},
): Promise<StagedMintRecovery> => { ): Promise<StagedMintRecovery> => {
const noteSecret = noteK1(staged.url) const noteSecret = noteK1(staged.url);
const expectedNoteValueMsat = noteDeclaredAmount(staged.url) const expectedNoteValueMsat = noteDeclaredAmount(staged.url);
if (!staged.pendingMint || !noteSecret || expectedNoteValueMsat === null) { if (!staged.pendingMint || !noteSecret || expectedNoteValueMsat === null) {
throw new Error('The pending mint output is malformed.') throw new Error('The pending mint output is malformed.');
} }
const withdrawLink = new URL(staged.url) const withdrawLink = new URL(staged.url);
withdrawLink.searchParams.delete('k1') withdrawLink.searchParams.delete('k1');
withdrawLink.searchParams.delete('amount') withdrawLink.searchParams.delete('amount');
withdrawLink.searchParams.delete('sig') withdrawLink.searchParams.delete('sig');
assertFundOwner(options) assertFundOwner(options);
const claim = await fetchMintClaim(withdrawLink.href, noteSecret, withMutationSafety(options)) const claim = await fetchMintClaim(withdrawLink.href, noteSecret, withMutationSafety(options));
if (claim.state === 'unminted') return {state: 'unminted'} if (claim.state === 'unminted') return { state: 'unminted' };
if (claim.state === 'pending') return {state: 'pending'} if (claim.state === 'pending') return { state: 'pending' };
if (claim.state === 'spent') return {state: 'spent'} if (claim.state === 'spent') return { state: 'spent' };
if (claim.k1 !== noteSecret || claim.amountMsat === null || !claim.callback) { if (claim.k1 !== noteSecret || claim.amountMsat === null || !claim.callback) {
throw new Error('The mint did not return the pending output details.') throw new Error('The mint did not return the pending output details.');
} }
const note: NewBearer = { const note: NewBearer = {
url: buildNoteUrl(withdrawLink.href, noteSecret, claim.amountMsat), url: buildNoteUrl(withdrawLink.href, noteSecret, claim.amountMsat),
callback: claim.callback, callback: claim.callback,
amount: claim.amountMsat, amount: claim.amountMsat,
verified: true, verified: true,
} };
if (staged.pendingMint.mintPubkey) note.mintPubkey = staged.pendingMint.mintPubkey if (staged.pendingMint.mintPubkey) note.mintPubkey = staged.pendingMint.mintPubkey;
return {state: 'minted', note} return { state: 'minted', note };
} };
+88 -73
View File
@@ -18,16 +18,16 @@ import {
sameInvoice, sameInvoice,
serverOf, serverOf,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit' import type { LnurlcashOptions, MeltResult } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {OutputSecretAllocator} from './allocation' import type { OutputSecretAllocator } from './allocation';
import {requireOutputSecrets} from './allocation' import { requireOutputSecrets } from './allocation';
import type {CarveResult} from './carve' import type { CarveResult } from './carve';
import {ensureExactAmount} from './carve' import { ensureExactAmount } from './carve';
import type {PollOptions} from './shared' import type { PollOptions } from './shared';
import type {FundOperationOptions, MintSignatureKeys} from './shared' import type { FundOperationOptions, MintSignatureKeys } from './shared';
import {assertFundOwner, pollVerifyUntilSettled, withMutationSafety} from './shared' import { assertFundOwner, pollVerifyUntilSettled, withMutationSafety } from './shared';
export type PayOutcome = export type PayOutcome =
| 'settled' | 'settled'
@@ -35,51 +35,51 @@ export type PayOutcome =
| 'unknown-still-pending' | 'unknown-still-pending'
// the service reports the carved note as already spent before the melt // the service reports the carved note as already spent before the melt
// even started - nothing was paid, and the note is definitively gone // even started - nothing was paid, and the note is definitively gone
| 'note-already-spent' | 'note-already-spent';
export type PayResult = { export type PayResult = {
outcome: PayOutcome outcome: PayOutcome;
carve: CarveResult carve: CarveResult;
// the invoice that was (attempted to be) paid // the invoice that was (attempted to be) paid
invoice: string invoice: string;
amountMsat: number amountMsat: number;
verifyUrl: string | null verifyUrl: string | null;
rotatedNote?: NewBearer rotatedNote?: NewBearer;
rescuedNote?: NewBearer rescuedNote?: NewBearer;
} };
export class PayReturnCheckpointRequiredError extends Error { export class PayReturnCheckpointRequiredError extends Error {
override readonly name = 'PayReturnCheckpointRequiredError' override readonly name = 'PayReturnCheckpointRequiredError';
constructor() { constructor() {
super('A pay-return classification requires a durable onReturnReady checkpoint.') super('A pay-return classification requires a durable onReturnReady checkpoint.');
} }
} }
export type PayOptions = { export type PayOptions = {
// required when `input` is a Lightning Address / LNURL-pay (a bolt11 // required when `input` is a Lightning Address / LNURL-pay (a bolt11
// carries its own amount) // carries its own amount)
amountMsat?: number amountMsat?: number;
// verify-poll budget - tests shrink this // verify-poll budget - tests shrink this
poll?: PollOptions poll?: PollOptions;
// kit transport overrides (fetch injection, timeouts) // kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions kit?: LnurlcashOptions;
assertOwner?: () => void assertOwner?: () => void;
// carve checkpoint - committed before the melt and the settlement wait, // carve checkpoint - committed before the melt and the settlement wait,
// so an abort mid-wait never strands the carve (see // so an abort mid-wait never strands the carve (see
// FundOperationOptions.onCarve) // FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve'] onCarve?: FundOperationOptions['onCarve'];
// the caller's durable allocation path: the carve's output secrets and - // the caller's durable allocation path: the carve's output secrets and -
// only when the return classification actually needs one - the recovery // only when the return classification actually needs one - the recovery
// secret. Required for any mutating carve or return classification. // secret. Required for any mutating carve or return classification.
allocateOutputSecrets?: OutputSecretAllocator allocateOutputSecrets?: OutputSecretAllocator;
// trusted signing keys the carve's landed outputs verify against (see // trusted signing keys the carve's landed outputs verify against (see
// FundOperationOptions.mintSignatureKeys) // FundOperationOptions.mintSignatureKeys)
mintSignatureKeys?: MintSignatureKeys mintSignatureKeys?: MintSignatureKeys;
// Called before the classification rotate puts its chosen output secret // Called before the classification rotate puts its chosen output secret
// on the wire. Production callers durably link it to the carved source. // on the wire. Production callers durably link it to the carved source.
onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise<void> onReturnReady?: (carve: CarveResult, recoverySecret: string) => void | Promise<void>;
} };
// A melt's resolved promise only means the payment is in flight; the // A melt's resolved promise only means the payment is in flight; the
// outcome is classified by polling the melt's LUD-25 verify URL, then - if // outcome is classified by polling the melt's LUD-25 verify URL, then - if
@@ -94,7 +94,16 @@ export type PayOptions = {
export const payWithBearers = async ( export const payWithBearers = async (
bearers: Bearer[], bearers: Bearer[],
input: string, input: string,
{amountMsat, poll = {}, kit = {}, assertOwner, onCarve, allocateOutputSecrets, mintSignatureKeys, onReturnReady}: PayOptions = {}, {
amountMsat,
poll = {},
kit = {},
assertOwner,
onCarve,
allocateOutputSecrets,
mintSignatureKeys,
onReturnReady,
}: PayOptions = {},
): Promise<PayResult> => { ): Promise<PayResult> => {
// the forced mutation policy (see shared.ts) covers the carve, the melt, // the forced mutation policy (see shared.ts) covers the carve, the melt,
// and the classification rotate regardless of caller kit options // and the classification rotate regardless of caller kit options
@@ -103,66 +112,72 @@ export const payWithBearers = async (
...(assertOwner ? { assertOwner } : {}), ...(assertOwner ? { assertOwner } : {}),
...(onCarve ? { onCarve } : {}), ...(onCarve ? { onCarve } : {}),
...(mintSignatureKeys ? { mintSignatureKeys } : {}), ...(mintSignatureKeys ? { mintSignatureKeys } : {}),
}) });
let invoice: string let invoice: string;
let amount: number let amount: number;
const trimmed = input.trim() const trimmed = input.trim();
if (isBolt11Invoice(trimmed)) { if (isBolt11Invoice(trimmed)) {
const decoded = decodeBolt11AmountMsat(trimmed) const decoded = decodeBolt11AmountMsat(trimmed);
if (decoded === null || decoded <= 0) { if (decoded === null || decoded <= 0) {
throw new Error( throw new Error(
"Could not read this invoice's amount - amount-less invoices are not supported.", "Could not read this invoice's amount - amount-less invoices are not supported.",
) );
} }
invoice = trimmed invoice = trimmed;
amount = decoded amount = decoded;
} else { } else {
// a Lightning Address (or LNURL-pay) has no invoice of its own yet - // a Lightning Address (or LNURL-pay) has no invoice of its own yet -
// resolving it gets a payRequest, and an amount is needed before an // resolving it gets a payRequest, and an amount is needed before an
// actual invoice exists // actual invoice exists
const url = resolveLnurlInput(trimmed) const url = resolveLnurlInput(trimmed);
if (!url) { if (!url) {
throw new Error('Not a valid bolt11 invoice or Lightning Address.') throw new Error('Not a valid bolt11 invoice or Lightning Address.');
} }
if (amountMsat === undefined || !Number.isInteger(amountMsat) || amountMsat <= 0) { if (amountMsat === undefined || !Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Enter an amount to pay to this address.') throw new Error('Enter an amount to pay to this address.');
} }
const info = await fetchPayRequest(url, options) const info = await fetchPayRequest(url, options);
if (amountMsat < info.minSendable || amountMsat > info.maxSendable) { if (amountMsat < info.minSendable || amountMsat > info.maxSendable) {
throw new Error("Amount is outside the payee's sendable range.") throw new Error("Amount is outside the payee's sendable range.");
} }
const result = await requestInvoice(info.callback, amountMsat, options) const result = await requestInvoice(info.callback, amountMsat, options);
invoice = result.pr invoice = result.pr;
amount = amountMsat amount = amountMsat;
} }
const carve = await ensureExactAmount(bearers, amount, { const carve = await ensureExactAmount(bearers, amount, {
...options, ...options,
...(allocateOutputSecrets ? { allocateOutputSecrets } : {}), ...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
}) });
const k1 = requireNoteK1(carve.note.url) const k1 = requireNoteK1(carve.note.url);
if (carve.consumed.length === 0) assertFundOwner(options) if (carve.consumed.length === 0) assertFundOwner(options);
let melt: MeltResult let melt: MeltResult;
try { try {
melt = await meltNote(carve.note.callback, k1, invoice, options) melt = await meltNote(carve.note.callback, k1, invoice, options);
} catch (err) { } catch (err) {
// this melt names a single note, so a NoteSpentError here is // this melt names a single note, so a NoteSpentError here is
// unambiguous - it's already gone, and gets locked spent the same way // unambiguous - it's already gone, and gets locked spent the same way
// a successful melt would have locked it // a successful melt would have locked it
if (err instanceof NoteSpentError) { if (err instanceof NoteSpentError) {
return {outcome: 'note-already-spent', carve, invoice, amountMsat: amount, verifyUrl: null} return { outcome: 'note-already-spent', carve, invoice, amountMsat: amount, verifyUrl: null };
} }
throw err throw err;
} }
if (!melt.verify) { if (!melt.verify) {
// no melt proof to poll - the note locking as spent locally is all the // no melt proof to poll - the note locking as spent locally is all the
// confirmation there is // confirmation there is
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl: null} return {
outcome: 'unknown-still-pending',
carve,
invoice,
amountMsat: amount,
verifyUrl: null,
};
} }
const verifyUrl = melt.verify const verifyUrl = melt.verify;
try { try {
const proof = await pollVerifyUntilSettled(verifyUrl, poll, options) const proof = await pollVerifyUntilSettled(verifyUrl, poll, options);
// a settled report is only this payment's proof when it's for the // a settled report is only this payment's proof when it's for the
// invoice this melt actually paid - a mint that mixes up proofs must // invoice this melt actually paid - a mint that mixes up proofs must
// not confirm the wrong payment. The verify URL is already scoped to // not confirm the wrong payment. The verify URL is already scoped to
@@ -171,27 +186,27 @@ export const payWithBearers = async (
// belongs to another payment, while an undecodable one says nothing // belongs to another payment, while an undecodable one says nothing
// either way (a service regenerating synthetic prs in proofs) and is // either way (a service regenerating synthetic prs in proofs) and is
// tolerated. // tolerated.
const proofAmount = decodeBolt11AmountMsat(proof.pr) const proofAmount = decodeBolt11AmountMsat(proof.pr);
if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) { if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) {
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
} }
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} return { outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl };
} catch { } catch {
// The verify budget ran out. Journal the exact output secret before the // The verify budget ran out. Journal the exact output secret before the
// classification rotate reaches the mint, so a crash can recover either // classification rotate reaches the mint, so a crash can recover either
// the returned source or the rotated replacement. // the returned source or the rotated replacement.
if (!onReturnReady) throw new PayReturnCheckpointRequiredError() if (!onReturnReady) throw new PayReturnCheckpointRequiredError();
const [recoverySecret] = await requireOutputSecrets( const [recoverySecret] = await requireOutputSecrets(
allocateOutputSecrets, allocateOutputSecrets,
serverOf(carve.note.url), serverOf(carve.note.url),
1, 1,
) );
await onReturnReady(carve, recoverySecret) await onReturnReady(carve, recoverySecret);
try { try {
const rotated = await rotateNote(carve.note.callback, k1, { const rotated = await rotateNote(carve.note.callback, k1, {
...options, ...options,
randomSecret: () => recoverySecret, randomSecret: () => recoverySecret,
}) });
return { return {
outcome: 'failed-funds-returned', outcome: 'failed-funds-returned',
carve, carve,
@@ -205,13 +220,13 @@ export const payWithBearers = async (
invoice, invoice,
amountMsat: amount, amountMsat: amount,
verifyUrl, verifyUrl,
} };
} catch (err) { } catch (err) {
if (err instanceof PendingNoteError) { if (err instanceof PendingNoteError) {
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
} }
if (err instanceof NoteSpentError) { if (err instanceof NoteSpentError) {
return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} return { outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl };
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
const rescuedNote: NewBearer = { const rescuedNote: NewBearer = {
@@ -219,8 +234,8 @@ export const payWithBearers = async (
callback: carve.note.callback, callback: carve.note.callback,
amount, amount,
verified: false, verified: false,
} };
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey;
return { return {
outcome: 'failed-funds-returned', outcome: 'failed-funds-returned',
carve, carve,
@@ -228,9 +243,9 @@ export const payWithBearers = async (
amountMsat: amount, amountMsat: amount,
verifyUrl, verifyUrl,
rescuedNote, rescuedNote,
};
}
return { outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl };
} }
} }
return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} };
}
}
}
+42 -42
View File
@@ -22,37 +22,37 @@ import {
requireNoteK1, requireNoteK1,
serverOf, serverOf,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import {receiveNote, secureReceivedNote} from '../receive' import { receiveNote, secureReceivedNote } from '../receive';
import type {OutputSecretAllocator} from './allocation' import type { OutputSecretAllocator } from './allocation';
import {requireOutputSecrets} from './allocation' import { requireOutputSecrets } from './allocation';
import type {FundOperationOptions} from './shared' import type { FundOperationOptions } from './shared';
import {assertFundOwner, probeMutationOutput, withMutationSafety} from './shared' import { assertFundOwner, probeMutationOutput, withMutationSafety } from './shared';
export class ReceiveRotationStagingRequiredError extends Error { export class ReceiveRotationStagingRequiredError extends Error {
override readonly name = 'ReceiveRotationStagingRequiredError' override readonly name = 'ReceiveRotationStagingRequiredError';
constructor() { constructor() {
super('A receive rotation requires a durable stageRotation checkpoint.') super('A receive rotation requires a durable stageRotation checkpoint.');
} }
} }
export type ReceiveBearerOptions = FundOperationOptions & { export type ReceiveBearerOptions = FundOperationOptions & {
// the caller's durable allocation path for the rotation's fresh secret - // the caller's durable allocation path for the rotation's fresh secret -
// required when stageRotation is present // required when stageRotation is present
readonly allocateOutputSecrets?: OutputSecretAllocator readonly allocateOutputSecrets?: OutputSecretAllocator;
// durably persist the future rotated note (unverified, pendingMint // durably persist the future rotated note (unverified, pendingMint
// carrying the original k1 as sourceRecoverySecret) before the rotate // carrying the original k1 as sourceRecoverySecret) before the rotate
// reaches the mint. The record's fate is decided by the result's // reaches the mint. The record's fate is decided by the result's
// `stage` disposition. // `stage` disposition.
readonly stageRotation?: (staged: NewBearer) => void | Promise<void> readonly stageRotation?: (staged: NewBearer) => void | Promise<void>;
} };
export type ReceivedNote = { export type ReceivedNote = {
readonly note: NewBearer readonly note: NewBearer;
readonly rotated: boolean readonly rotated: boolean;
readonly rotationError?: string readonly rotationError?: string;
// how the caller disposes the staged rotation output: // how the caller disposes the staged rotation output:
// - 'finalize': the rotate landed - replace the staged record with `note` // - 'finalize': the rotate landed - replace the staged record with `note`
// - 'keep': the outcome is genuinely ambiguous - the staged record IS // - 'keep': the outcome is genuinely ambiguous - the staged record IS
@@ -62,8 +62,8 @@ export type ReceivedNote = {
// record; `note` is the original, still verified as received // record; `note` is the original, still verified as received
// - 'none': no rotate was attempted (the note could not be verified) - // - 'none': no rotate was attempted (the note could not be verified) -
// nothing was staged // nothing was staged
readonly stage: 'finalize' | 'keep' | 'discard' | 'none' readonly stage: 'finalize' | 'keep' | 'discard' | 'none';
} };
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are // NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note, // definitive and propagate; an unreachable service still yields the note,
@@ -74,20 +74,20 @@ export const receiveBearer = async (
options: ReceiveBearerOptions = {}, options: ReceiveBearerOptions = {},
): Promise<ReceivedNote> => { ): Promise<ReceivedNote> => {
// the forced mutation policy (see shared.ts) covers the rotate below // the forced mutation policy (see shared.ts) covers the rotate below
const mutationOptions = withMutationSafety(options) const mutationOptions = withMutationSafety(options);
const note = await receiveNote(input, existing) const note = await receiveNote(input, existing);
if (!note.verified || !note.callback) { if (!note.verified || !note.callback) {
return {note, rotated: false, stage: 'none'} return { note, rotated: false, stage: 'none' };
} }
// the rotate burns the sender's secret at a fresh one: stage that future // the rotate burns the sender's secret at a fresh one: stage that future
// note durably before the mutation can land, or a crash strands the money // note durably before the mutation can land, or a crash strands the money
const staging = options.stageRotation const staging = options.stageRotation;
if (!staging) throw new ReceiveRotationStagingRequiredError() if (!staging) throw new ReceiveRotationStagingRequiredError();
const [rotationSecret] = await requireOutputSecrets( const [rotationSecret] = await requireOutputSecrets(
options.allocateOutputSecrets, options.allocateOutputSecrets,
serverOf(note.url), serverOf(note.url),
1, 1,
) );
const staged: NewBearer = { const staged: NewBearer = {
url: withNewK1(note.url, rotationSecret, note.amount), url: withNewK1(note.url, rotationSecret, note.amount),
callback: note.callback, callback: note.callback,
@@ -97,18 +97,18 @@ export const receiveBearer = async (
sourceRecoverySecret: requireNoteK1(note.url), sourceRecoverySecret: requireNoteK1(note.url),
...(note.mintPubkey ? { mintPubkey: note.mintPubkey } : {}), ...(note.mintPubkey ? { mintPubkey: note.mintPubkey } : {}),
}, },
} };
await staging(staged) await staging(staged);
const rotateOptions = {...mutationOptions, randomSecret: () => rotationSecret} const rotateOptions = { ...mutationOptions, randomSecret: () => rotationSecret };
// the disposition the caller applies to its staged record // the disposition the caller applies to its staged record
const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => { const disposition = (rotated: boolean, ambiguous: boolean): ReceivedNote['stage'] => {
if (rotated) return 'finalize' if (rotated) return 'finalize';
return ambiguous ? 'keep' : 'discard' return ambiguous ? 'keep' : 'discard';
} };
try { try {
assertFundOwner(options) assertFundOwner(options);
const rotatedUrl = await secureReceivedNote(note, rotateOptions) const rotatedUrl = await secureReceivedNote(note, rotateOptions);
return {note: {...note, url: rotatedUrl}, rotated: true, stage: disposition(true, false)} return { note: { ...note, url: rotatedUrl }, rotated: true, stage: disposition(true, false) };
} catch (err) { } catch (err) {
// A classified rotate refusal can still be a LANDED rotate: the // A classified rotate refusal can still be a LANDED rotate: the
// callback is a GET and HTTP stacks retry GETs, so the service may // callback is a GET and HTTP stacks retry GETs, so the service may
@@ -116,15 +116,15 @@ export const receiveBearer = async (
// already-spent input. The refusal then proves the sender's copy is // already-spent input. The refusal then proves the sender's copy is
// burned and the carried fresh secret is the only money left - probe // burned and the carried fresh secret is the only money left - probe
// it before believing the refusal. // it before believing the refusal.
const carried = newSecretsOf(err) const carried = newSecretsOf(err);
if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) { if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) {
const outcome = await probeMutationOutput(note.url, carried[0], mutationOptions) const outcome = await probeMutationOutput(note.url, carried[0], mutationOptions);
if (outcome === 'live') { if (outcome === 'live') {
return { return {
note: { ...note, url: withNewK1(note.url, carried[0], note.amount) }, note: { ...note, url: withNewK1(note.url, carried[0], note.amount) },
rotated: true, rotated: true,
stage: disposition(true, false), stage: disposition(true, false),
} };
} }
if (outcome === 'unknown') { if (outcome === 'unknown') {
return { return {
@@ -134,7 +134,7 @@ export const receiveBearer = async (
rotated: false, rotated: false,
stage: disposition(false, true), stage: disposition(false, true),
rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`, rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`,
} };
} }
// 'absent': the refusal is genuine - fall through to the distinct // 'absent': the refusal is genuine - fall through to the distinct
// handling below // handling below
@@ -147,10 +147,10 @@ export const receiveBearer = async (
err instanceof PendingNoteError || err instanceof PendingNoteError ||
err instanceof NoteUnknownError err instanceof NoteUnknownError
) { ) {
throw err throw err;
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(note.url, mutationOptions) const outcome = await probeBurnedNote(note.url, mutationOptions);
if (outcome === 'gone') { if (outcome === 'gone') {
return { return {
note: { note: {
@@ -159,7 +159,7 @@ export const receiveBearer = async (
}, },
rotated: true, rotated: true,
stage: disposition(true, false), stage: disposition(true, false),
} };
} }
if (outcome === 'unknown') { if (outcome === 'unknown') {
return { return {
@@ -167,7 +167,7 @@ export const receiveBearer = async (
rotated: false, rotated: false,
stage: disposition(false, true), stage: disposition(false, true),
rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`, rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`,
} };
} }
} }
// a failed rotate never fails the receive - the note is money as it // a failed rotate never fails the receive - the note is money as it
@@ -178,6 +178,6 @@ export const receiveBearer = async (
rotated: false, rotated: false,
rotationError: err instanceof Error ? err.message : String(err), rotationError: err instanceof Error ? err.message : String(err),
stage: disposition(false, false), stage: disposition(false, false),
};
} }
} };
}
+76 -74
View File
@@ -10,11 +10,11 @@ import {
serverOf, serverOf,
verifyNoteSignature, verifyNoteSignature,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' import type { LnurlcashOptions, VerifyResult } from 'lnurlcash-kit';
import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit' import { NoteSpentError, NoteUnknownError, noteDeclaredAmount } from 'lnurlcash-kit';
import type {NewBearer} from '../types' import type { NewBearer } from '../types';
import type {CarveResult} from './carve' import type { CarveResult } from './carve';
// a mutation's answer was lost AND the probe could not tell whether it // a mutation's answer was lost AND the probe could not tell whether it
// landed - the possible outputs the fresh secrets would control, for the // landed - the possible outputs the fresh secrets would control, for the
@@ -22,11 +22,11 @@ import type {CarveResult} from './carve'
// checkpoint may already have persisted them, in which case this list is // checkpoint may already have persisted them, in which case this list is
// empty so callers do not add duplicate records. // empty so callers do not add duplicate records.
export class UncertainOutcomeError extends Error { export class UncertainOutcomeError extends Error {
readonly possibleOutputs: NewBearer[] readonly possibleOutputs: NewBearer[];
constructor(message: string, possibleOutputs: NewBearer[]) { constructor(message: string, possibleOutputs: NewBearer[]) {
super(message) super(message);
this.name = 'UncertainOutcomeError' this.name = 'UncertainOutcomeError';
this.possibleOutputs = possibleOutputs this.possibleOutputs = possibleOutputs;
} }
} }
@@ -34,30 +34,30 @@ export class UncertainOutcomeError extends Error {
// from budget exhaustion so the caller can treat it as normal teardown // from budget exhaustion so the caller can treat it as normal teardown
export class PollAbortedError extends Error { export class PollAbortedError extends Error {
constructor() { constructor() {
super('The wait was interrupted by shutdown.') super('The wait was interrupted by shutdown.');
this.name = 'PollAbortedError' this.name = 'PollAbortedError';
} }
} }
export type FundOperationOptions = LnurlcashOptions & { export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void readonly assertOwner?: () => void;
// Two durable phases for a mutating carve. Before wire use, unverified // Two durable phases for a mutating carve. Before wire use, unverified
// outputs are reported with `consumed: []`. After the mint mutation is // outputs are reported with `consumed: []`. After the mint mutation is
// known to have landed, the already-staged note is reported again with // known to have landed, the already-staged note is reported again with
// the inputs that can now be retired. A rejection in either phase stops // the inputs that can now be retired. A rejection in either phase stops
// the flow; exact-note carves call neither phase. // the flow; exact-note carves call neither phase.
readonly onCarve?: (carve: CarveResult) => void | Promise<void> readonly onCarve?: (carve: CarveResult) => void | Promise<void>;
// the trusted signing keys a landed mutation output's signature is // the trusted signing keys a landed mutation output's signature is
// checked against before it may report verified: the mint's pinned // checked against before it may report verified: the mint's pinned
// current key AND its previous one (a just-rotated mint's last notes // current key AND its previous one (a just-rotated mint's last notes
// stay verifiable). Production supplies the trusted-mint registry's // stay verifiable). Production supplies the trusted-mint registry's
// keys; an absent source (or empty list) keeps every landed output // keys; an absent source (or empty list) keeps every landed output
// staged unverified rather than trusting it blindly. // staged unverified rather than trusting it blindly.
readonly mintSignatureKeys?: MintSignatureKeys readonly mintSignatureKeys?: MintSignatureKeys;
} };
// the trusted signing keys available for one mint server // the trusted signing keys available for one mint server
export type MintSignatureKeys = (server: string) => readonly string[] export type MintSignatureKeys = (server: string) => readonly string[];
// The mutation safety policy, forced where the engine talks to a mint: // The mutation safety policy, forced where the engine talks to a mint:
// every mutation must come back signed (the signature is the only offline // every mutation must come back signed (the signature is the only offline
@@ -66,11 +66,13 @@ export type MintSignatureKeys = (server: string) => readonly string[]
// safe). Both are lnurlcash-kit's own defaults - restating them AFTER the // safe). Both are lnurlcash-kit's own defaults - restating them AFTER the
// caller spread means no caller's options object can quietly strip either // caller spread means no caller's options object can quietly strip either
// guarantee. // guarantee.
export const withMutationSafety = <Options extends LnurlcashOptions>(options: Options): Options => ({ export const withMutationSafety = <Options extends LnurlcashOptions>(
options: Options,
): Options => ({
...options, ...options,
requireSignatures: true, requireSignatures: true,
mutationRetries: 1, mutationRetries: 1,
}) });
// A landed mutation output earns verified:true only when its signature // A landed mutation output earns verified:true only when its signature
// (carried as the URL's sig param) verifies against a trusted key for its // (carried as the URL's sig param) verifies against a trusted key for its
@@ -82,21 +84,21 @@ export const landedNoteVerifies = (
noteUrl: string, noteUrl: string,
keysFor: MintSignatureKeys | undefined, keysFor: MintSignatureKeys | undefined,
): boolean => { ): boolean => {
if (!keysFor) return false if (!keysFor) return false;
const k1 = noteK1(noteUrl) const k1 = noteK1(noteUrl);
const amountMsat = noteDeclaredAmount(noteUrl) const amountMsat = noteDeclaredAmount(noteUrl);
const signature = noteSignature(noteUrl) const signature = noteSignature(noteUrl);
if (!k1 || amountMsat === null || !signature) return false if (!k1 || amountMsat === null || !signature) return false;
const keys = keysFor(serverOf(noteUrl)) const keys = keysFor(serverOf(noteUrl));
return keys.length > 0 && verifyNoteSignature(k1, amountMsat, signature, [...keys]) return keys.length > 0 && verifyNoteSignature(k1, amountMsat, signature, [...keys]);
} };
export const assertFundOwner = (options: FundOperationOptions): void => { export const assertFundOwner = (options: FundOperationOptions): void => {
options.assertOwner?.() options.assertOwner?.();
} };
// what probing a would-be mutation output can tell // what probing a would-be mutation output can tell
export type OutputProbe = 'live' | 'absent' | 'unknown' export type OutputProbe = 'live' | 'absent' | 'unknown';
// A refused mutation can still have LANDED: the redeem callback is a GET // A refused mutation can still have LANDED: the redeem callback is a GET
// and HTTP stacks retry GETs, so the service may have executed the first // and HTTP stacks retry GETs, so the service may have executed the first
@@ -113,53 +115,53 @@ export const probeMutationOutput = async (
options: LnurlcashOptions = {}, options: LnurlcashOptions = {},
): Promise<OutputProbe> => { ): Promise<OutputProbe> => {
try { try {
await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options) await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options);
return 'live' return 'live';
} catch (err) { } catch (err) {
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent' if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent';
return 'unknown' return 'unknown';
}
} }
};
export type PollOptions = { export type PollOptions = {
// first delay between checks (doubles each round up to intervalCapMs) // first delay between checks (doubles each round up to intervalCapMs)
intervalMs?: number intervalMs?: number;
intervalCapMs?: number intervalCapMs?: number;
// total budget before giving up // total budget before giving up
maxWaitMs?: number maxWaitMs?: number;
// aborts the wait promptly (shutdown). Only the WAIT is interruptible: // aborts the wait promptly (shutdown). Only the WAIT is interruptible:
// callers pass this for work whose observation phase may outlive the // callers pass this for work whose observation phase may outlive the
// caller - once pollVerifyUntilSettled has returned, the signal no // caller - once pollVerifyUntilSettled has returned, the signal no
// longer reaches anything // longer reaches anything
signal?: AbortSignal signal?: AbortSignal;
} };
const DEFAULT_POLL: Required<Omit<PollOptions, 'signal'>> = { const DEFAULT_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 1000, intervalMs: 1000,
intervalCapMs: 5000, intervalCapMs: 5000,
maxWaitMs: 120_000, maxWaitMs: 120_000,
} };
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
// a sleep that ends immediately on abort instead of riding out its timer // a sleep that ends immediately on abort instead of riding out its timer
const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> => const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const onAbort = (): void => { const onAbort = (): void => {
clearTimeout(timer) clearTimeout(timer);
reject(new PollAbortedError()) reject(new PollAbortedError());
} };
const timer = setTimeout(() => { const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort) signal.removeEventListener('abort', onAbort);
resolve() resolve();
}, ms) }, ms);
if (signal.aborted) { if (signal.aborted) {
clearTimeout(timer) clearTimeout(timer);
reject(new PollAbortedError()) reject(new PollAbortedError());
return return;
} }
signal.addEventListener('abort', onAbort, {once: true}) signal.addEventListener('abort', onAbort, { once: true });
}) });
// polls `check` until it yields a value, with backoff, inside a total // polls `check` until it yields a value, with backoff, inside a total
// time budget. A single failed check isn't fatal - the next round tries // time budget. A single failed check isn't fatal - the next round tries
@@ -175,39 +177,39 @@ export const pollUntil = async <T>(
const { intervalMs, intervalCapMs, maxWaitMs, signal } = { const { intervalMs, intervalCapMs, maxWaitMs, signal } = {
...DEFAULT_POLL, ...DEFAULT_POLL,
...poll, ...poll,
} };
const fetchOptions: LnurlcashOptions = signal const fetchOptions: LnurlcashOptions = signal
? { ? {
...options, ...options,
fetch: (input, init) => { fetch: (input, init) => {
const base = options.fetch ?? globalThis.fetch const base = options.fetch ?? globalThis.fetch;
return base(input, {...init, signal}) return base(input, { ...init, signal });
}, },
} }
: options : options;
const deadline = Date.now() + maxWaitMs const deadline = Date.now() + maxWaitMs;
let delay = intervalMs let delay = intervalMs;
let lastError: unknown = null let lastError: unknown = null;
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError() if (signal?.aborted) throw new PollAbortedError();
try { try {
const result = await check(fetchOptions) const result = await check(fetchOptions);
if (result !== null) return result if (result !== null) return result;
lastError = null lastError = null;
} catch (err) { } catch (err) {
// the signal's own AbortError lands here on an interrupted fetch // the signal's own AbortError lands here on an interrupted fetch
if (signal?.aborted) throw new PollAbortedError() if (signal?.aborted) throw new PollAbortedError();
lastError = err lastError = err;
} }
if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal) if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal);
else await sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) else await sleep(Math.min(delay, Math.max(0, deadline - Date.now())));
delay = Math.min(delay * 2, intervalCapMs) delay = Math.min(delay * 2, intervalCapMs);
} }
if (lastError instanceof Error) { if (lastError instanceof Error) {
throw new Error(`${exhausted}: ${lastError.message}`) throw new Error(`${exhausted}: ${lastError.message}`);
}
throw new Error(`${exhausted} within the time budget.`)
} }
throw new Error(`${exhausted} within the time budget.`);
};
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with // polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
// backoff, inside a total time budget. A single failed check isn't fatal - // backoff, inside a total time budget. A single failed check isn't fatal -
@@ -221,10 +223,10 @@ export const pollVerifyUntilSettled = (
): Promise<VerifyResult> => ): Promise<VerifyResult> =>
pollUntil( pollUntil(
async (fetchOptions) => { async (fetchOptions) => {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions) const result = await fetchInvoiceVerification(verifyUrl, fetchOptions);
return result.settled ? result : null return result.settled ? result : null;
}, },
'Payment not confirmed', 'Payment not confirmed',
poll, poll,
options, options,
) );
+124 -115
View File
@@ -25,18 +25,18 @@ import {
rotateNote, rotateNote,
serverOf, serverOf,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {LnurlcashOptions} from 'lnurlcash-kit' import type { LnurlcashOptions } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from '../types' import type { Bearer, NewBearer } from '../types';
import type {OutputSecretAllocator} from './allocation' import type { OutputSecretAllocator } from './allocation';
import {requireOutputSecrets} from './allocation' import { requireOutputSecrets } from './allocation';
import type {CarveResult} from './carve' import type { CarveResult } from './carve';
import {ensureExactAmount} from './carve' import { ensureExactAmount } from './carve';
import type {ClaimedNote, PreparedMint, PrepareMintOptions} from './mint' import type { ClaimedNote, PreparedMint, PrepareMintOptions } from './mint';
import {MintedNoteSpentError, claimFromSecret, prepareMint} from './mint' import { MintedNoteSpentError, claimFromSecret, prepareMint } from './mint';
import type {PollOptions} from './shared' import type { PollOptions } from './shared';
import type {FundOperationOptions, MintSignatureKeys} from './shared' import type { FundOperationOptions, MintSignatureKeys } from './shared';
import {assertFundOwner, withMutationSafety} from './shared' import { assertFundOwner, withMutationSafety } from './shared';
export type TransferOutcome = export type TransferOutcome =
// the melt settled and the target note was claimed (and rotated) // the melt settled and the target note was claimed (and rotated)
@@ -51,95 +51,95 @@ export type TransferOutcome =
| 'note-already-spent' | 'note-already-spent'
// the target invoice settled (the money arrived) but the claim could // the target invoice settled (the money arrived) but the claim could
// not complete - claimMaterial carries everything needed to retry it // not complete - claimMaterial carries everything needed to retry it
| 'settled-claim-failed' | 'settled-claim-failed';
export type TransferQuote = { export type TransferQuote = {
// the net value the user wants to land on the target mint // the net value the user wants to land on the target mint
requestedMsat: number requestedMsat: number;
// what the source side must cover - the target invoice, grossed up for // what the source side must cover - the target invoice, grossed up for
// the target's advertised mint fee and rounded to a whole sat // the target's advertised mint fee and rounded to a whole sat
grossMsat: number grossMsat: number;
// the target mint's receive fee as estimated by the gross-up (the // the target mint's receive fee as estimated by the gross-up (the
// service's own fee math is authoritative - the claimed note's amount // service's own fee math is authoritative - the claimed note's amount
// is what it actually withheld) // is what it actually withheld)
targetMintFeeMsat: number targetMintFeeMsat: number;
// LUD-25 melt has no fee field - the melted note must equal the invoice // LUD-25 melt has no fee field - the melted note must equal the invoice
// exactly, so no source-side reserve is even expressible // exactly, so no source-side reserve is even expressible
sourceMeltFeeReserveMsat: number sourceMeltFeeReserveMsat: number;
} };
// everything a caller needs to retry (or log) the target claim when the // everything a caller needs to retry (or log) the target claim when the
// transfer could not complete it - once the melt has settled this // transfer could not complete it - once the melt has settled this
// material IS the money, so it is never dropped // material IS the money, so it is never dropped
export type TransferClaimMaterial = { export type TransferClaimMaterial = {
invoice: string invoice: string;
withdrawLink: string withdrawLink: string;
expectedNoteValueMsat: number expectedNoteValueMsat: number;
note: NewBearer note: NewBearer;
// the wallet-chosen secret the target note lands at. // the wallet-chosen secret the target note lands at.
// The note exists at this secret from the moment the invoice settles - // The note exists at this secret from the moment the invoice settles -
// the caller can rebuild the note URL from it and retry the claim // the caller can rebuild the note URL from it and retry the claim
noteSecret: string noteSecret: string;
} };
export type TransferResult = { export type TransferResult = {
outcome: TransferOutcome outcome: TransferOutcome;
// the source-side changeset: consumed inputs and any change note // the source-side changeset: consumed inputs and any change note
carve: CarveResult carve: CarveResult;
quote: TransferQuote quote: TransferQuote;
// the invoice the source note was melted to pay // the invoice the source note was melted to pay
invoice: string invoice: string;
// the target invoice's optional verify URL (used for a payment receipt; // the target invoice's optional verify URL (used for a payment receipt;
// the staged note itself is the settlement observation) // the staged note itself is the settlement observation)
verifyUrl: string | null verifyUrl: string | null;
sourceServer: string sourceServer: string;
targetServer: string targetServer: string;
// the fresh target note, on 'settled' // the fresh target note, on 'settled'
mintedAtTarget?: ClaimedNote mintedAtTarget?: ClaimedNote;
// on failed-funds-returned, when the source-probe rotate succeeded: the // on failed-funds-returned, when the source-probe rotate succeeded: the
// returned funds re-secured at a fresh secret. Kept OUT of `carve` // returned funds re-secured at a fresh secret. Kept OUT of `carve`
// (which always describes the carve as committed by the onCarve // (which always describes the carve as committed by the onCarve
// checkpoint) so an early-committed carve and a post-wait rotation can // checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked // both be persisted - the carved note the rotate burned must be marked
// spent, this note added // spent, this note added
rotatedNote?: NewBearer rotatedNote?: NewBearer;
// present whenever the claim could still complete later // present whenever the claim could still complete later
claimMaterial?: TransferClaimMaterial claimMaterial?: TransferClaimMaterial;
// a fresh secret rescued from an ambiguous rotate while classifying the // a fresh secret rescued from an ambiguous rotate while classifying the
// melt - the caller must track it unverified (same semantics as pay.ts) // melt - the caller must track it unverified (same semantics as pay.ts)
rescuedNote?: NewBearer rescuedNote?: NewBearer;
} };
export class TransferMeltCheckpointRequiredError extends Error { export class TransferMeltCheckpointRequiredError extends Error {
override readonly name = 'TransferMeltCheckpointRequiredError' override readonly name = 'TransferMeltCheckpointRequiredError';
constructor() { constructor() {
super('A source melt requires a durable onMeltReady checkpoint.') super('A source melt requires a durable onMeltReady checkpoint.');
} }
} }
export type TransferOptions = { export type TransferOptions = {
// verify-poll budget - tests shrink this // verify-poll budget - tests shrink this
poll?: PollOptions poll?: PollOptions;
// kit transport overrides (fetch injection, timeouts) // kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions kit?: LnurlcashOptions;
assertOwner?: () => void assertOwner?: () => void;
// carve checkpoint - committed before the melt and the target // carve checkpoint - committed before the melt and the target
// settlement wait, so an abort mid-wait can never strand the carve (see // settlement wait, so an abort mid-wait can never strand the carve (see
// FundOperationOptions.onCarve) // FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve'] onCarve?: FundOperationOptions['onCarve'];
// persists the target note before its hash reaches the invoice callback // persists the target note before its hash reaches the invoice callback
persistOutput: PrepareMintOptions['persistOutput'] persistOutput: PrepareMintOptions['persistOutput'];
// the caller's durable allocation path: the staged target note's secret, // the caller's durable allocation path: the staged target note's secret,
// the source carve's outputs, and the source recovery secret. Required: // the source carve's outputs, and the source recovery secret. Required:
// the wallet's reserved BIP-32 indices are the only source a restart can // the wallet's reserved BIP-32 indices are the only source a restart can
// never reuse or lose. // never reuse or lose.
allocateOutputSecrets?: OutputSecretAllocator allocateOutputSecrets?: OutputSecretAllocator;
// trusted signing keys the source carve's landed outputs verify against // trusted signing keys the source carve's landed outputs verify against
mintSignatureKeys?: MintSignatureKeys mintSignatureKeys?: MintSignatureKeys;
// Last durable checkpoint before the source melt can become irreversible. // Last durable checkpoint before the source melt can become irreversible.
onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise<void> onMeltReady?: (carve: CarveResult, sourceRecoverySecret: string) => void | Promise<void>;
} };
// the claim retry material for a named target: the note at the wallet's // the claim retry material for a named target: the note at the wallet's
// own secret, unverified until a claim succeeds // own secret, unverified until a claim succeeds
@@ -149,34 +149,34 @@ const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer =
callback: '', callback: '',
amount: prepared.expectedNoteValueMsat, amount: prepared.expectedNoteValueMsat,
verified: false, verified: false,
} };
if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey;
return note return note;
} };
const transferSources = (bearers: Bearer[], targetServer: string, grossMsat: number): Bearer[] => { const transferSources = (bearers: Bearer[], targetServer: string, grossMsat: number): Bearer[] => {
const eligible = bearers.filter( const eligible = bearers.filter(
(bearer) => !bearer.spent && bearer.callback !== '' && !bearer.deviceId && noteK1(bearer.url), (bearer) => !bearer.spent && bearer.callback !== '' && !bearer.deviceId && noteK1(bearer.url),
) );
const candidates = eligible.filter((bearer) => serverOf(bearer.url) !== targetServer) const candidates = eligible.filter((bearer) => serverOf(bearer.url) !== targetServer);
if (eligible.length > 0 && candidates.length === 0) { if (eligible.length > 0 && candidates.length === 0) {
throw new Error("That's the mint these notes are already on - pick a different target.") throw new Error("That's the mint these notes are already on - pick a different target.");
} }
const totals = new Map<string, number>() const totals = new Map<string, number>();
for (const bearer of candidates) { for (const bearer of candidates) {
const server = serverOf(bearer.url) const server = serverOf(bearer.url);
totals.set(server, (totals.get(server) ?? 0) + bearer.amount) totals.set(server, (totals.get(server) ?? 0) + bearer.amount);
} }
if (![...totals.values()].some((total) => total >= grossMsat)) { if (![...totals.values()].some((total) => total >= grossMsat)) {
throw new Error('No mint holds enough verified, unspent balance to cover that amount.') throw new Error('No mint holds enough verified, unspent balance to cover that amount.');
}
return candidates
} }
return candidates;
};
export type PendingTransferSourceRecovery = export type PendingTransferSourceRecovery =
| { readonly state: 'pending' } | { readonly state: 'pending' }
| { readonly state: 'spent' } | { readonly state: 'spent' }
| {readonly state: 'returned'; readonly note: NewBearer} | { readonly state: 'returned'; readonly note: NewBearer };
export const recoverPendingTransferSource = async ( export const recoverPendingTransferSource = async (
staged: Bearer, staged: Bearer,
@@ -185,39 +185,39 @@ export const recoverPendingTransferSource = async (
): Promise<PendingTransferSourceRecovery> => { ): Promise<PendingTransferSourceRecovery> => {
// a recovery only ever PROBES the melt outcome and rotates - it never // a recovery only ever PROBES the melt outcome and rotates - it never
// replays the melt itself; the forced policy pins signature/retry safety // replays the melt itself; the forced policy pins signature/retry safety
const mutationOptions = withMutationSafety(options) const mutationOptions = withMutationSafety(options);
const recoverySecret = staged.pendingMint?.sourceRecoverySecret const recoverySecret = staged.pendingMint?.sourceRecoverySecret;
if (!recoverySecret) throw new Error('The pending transfer source recovery secret is missing.') if (!recoverySecret) throw new Error('The pending transfer source recovery secret is missing.');
const recoveredUrl = withNewK1(source.url, recoverySecret, source.amount) const recoveredUrl = withNewK1(source.url, recoverySecret, source.amount);
assertFundOwner(options) assertFundOwner(options);
try { try {
const recovered = await fetchNoteInfo(recoveredUrl, mutationOptions) const recovered = await fetchNoteInfo(recoveredUrl, mutationOptions);
const note: NewBearer = { const note: NewBearer = {
url: withNewK1(source.url, recoverySecret, recovered.maxWithdrawable), url: withNewK1(source.url, recoverySecret, recovered.maxWithdrawable),
callback: recovered.callback, callback: recovered.callback,
amount: recovered.maxWithdrawable, amount: recovered.maxWithdrawable,
verified: true, verified: true,
} };
if (source.mintPubkey) note.mintPubkey = source.mintPubkey if (source.mintPubkey) note.mintPubkey = source.mintPubkey;
return {state: 'returned', note} return { state: 'returned', note };
} catch (error) { } catch (error) {
if (error instanceof NoteSpentError) return {state: 'spent'} if (error instanceof NoteSpentError) return { state: 'spent' };
if (!(error instanceof NoteUnknownError)) throw error if (!(error instanceof NoteUnknownError)) throw error;
} }
assertFundOwner(options) assertFundOwner(options);
try { try {
const rotated = await rotateNote(source.callback, requireNoteK1(source.url), { const rotated = await rotateNote(source.callback, requireNoteK1(source.url), {
...mutationOptions, ...mutationOptions,
randomSecret: () => recoverySecret, randomSecret: () => recoverySecret,
}) });
const note: NewBearer = { const note: NewBearer = {
url: withNewK1(source.url, recoverySecret, source.amount, rotated.signature), url: withNewK1(source.url, recoverySecret, source.amount, rotated.signature),
callback: source.callback, callback: source.callback,
amount: source.amount, amount: source.amount,
verified: true, verified: true,
} };
if (source.mintPubkey) note.mintPubkey = source.mintPubkey if (source.mintPubkey) note.mintPubkey = source.mintPubkey;
return {state: 'returned', note} return { state: 'returned', note };
} catch (error) { } catch (error) {
if ( if (
error instanceof PendingNoteError || error instanceof PendingNoteError ||
@@ -225,26 +225,35 @@ export const recoverPendingTransferSource = async (
error instanceof AmbiguousMutationError || error instanceof AmbiguousMutationError ||
newSecretsOf(error).includes(recoverySecret) newSecretsOf(error).includes(recoverySecret)
) { ) {
return {state: 'pending'} return { state: 'pending' };
}
throw error
} }
throw error;
} }
};
export const transferBetweenMints = async ( export const transferBetweenMints = async (
bearers: Bearer[], bearers: Bearer[],
amountMsat: number, amountMsat: number,
targetMint: string, targetMint: string,
{poll = {}, kit = {}, assertOwner, onCarve, persistOutput, allocateOutputSecrets, mintSignatureKeys, onMeltReady}: TransferOptions, {
poll = {},
kit = {},
assertOwner,
onCarve,
persistOutput,
allocateOutputSecrets,
mintSignatureKeys,
onMeltReady,
}: TransferOptions,
): Promise<TransferResult> => { ): Promise<TransferResult> => {
const options: FundOperationOptions = withMutationSafety({ const options: FundOperationOptions = withMutationSafety({
...kit, ...kit,
...(assertOwner ? { assertOwner } : {}), ...(assertOwner ? { assertOwner } : {}),
...(onCarve ? { onCarve } : {}), ...(onCarve ? { onCarve } : {}),
...(mintSignatureKeys ? { mintSignatureKeys } : {}), ...(mintSignatureKeys ? { mintSignatureKeys } : {}),
}) });
if (!Number.isInteger(amountMsat) || amountMsat <= 0) { if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.') throw new Error('Amount must be a positive whole number of msat.');
} }
// resolving the target and requesting its invoice touches only the // resolving the target and requesting its invoice touches only the
// TARGET mint - a failure here (unreachable, no minting support, amount // TARGET mint - a failure here (unreachable, no minting support, amount
@@ -254,55 +263,55 @@ export const transferBetweenMints = async (
persistOutput, persistOutput,
...(allocateOutputSecrets ? { allocateOutputSecrets } : {}), ...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
beforePersist: ({ grossMsat, server }) => { beforePersist: ({ grossMsat, server }) => {
transferSources(bearers, server, grossMsat) transferSources(bearers, server, grossMsat);
}, },
}) });
const verifyUrl = prepared.verifyUrl const verifyUrl = prepared.verifyUrl;
const targetServer = prepared.server const targetServer = prepared.server;
// the source must be a DIFFERENT mint - value "moved" within one mint // the source must be a DIFFERENT mint - value "moved" within one mint
// goes nowhere (melt pays an invoice; the same mint's invoice just // goes nowhere (melt pays an invoice; the same mint's invoice just
// re-mints into itself, paying fees for nothing) // re-mints into itself, paying fees for nothing)
const offTarget = transferSources(bearers, targetServer, prepared.grossMsat) const offTarget = transferSources(bearers, targetServer, prepared.grossMsat);
const quote: TransferQuote = { const quote: TransferQuote = {
requestedMsat: amountMsat, requestedMsat: amountMsat,
grossMsat: prepared.grossMsat, grossMsat: prepared.grossMsat,
targetMintFeeMsat: prepared.grossMsat - amountMsat, targetMintFeeMsat: prepared.grossMsat - amountMsat,
sourceMeltFeeReserveMsat: 0, sourceMeltFeeReserveMsat: 0,
} };
// carving burns its inputs server-side, so it happens only once the // carving burns its inputs server-side, so it happens only once the
// target is known good and the invoice exists // target is known good and the invoice exists
const carve = await ensureExactAmount(offTarget, prepared.grossMsat, { const carve = await ensureExactAmount(offTarget, prepared.grossMsat, {
...options, ...options,
...(allocateOutputSecrets ? { allocateOutputSecrets } : {}), ...(allocateOutputSecrets ? { allocateOutputSecrets } : {}),
}) });
const sourceServer = serverOf(carve.note.url) const sourceServer = serverOf(carve.note.url);
const invoice = prepared.invoice const invoice = prepared.invoice;
const claimMaterial: TransferClaimMaterial = { const claimMaterial: TransferClaimMaterial = {
invoice, invoice,
withdrawLink: prepared.withdrawLink, withdrawLink: prepared.withdrawLink,
expectedNoteValueMsat: prepared.expectedNoteValueMsat, expectedNoteValueMsat: prepared.expectedNoteValueMsat,
noteSecret: prepared.noteSecret, noteSecret: prepared.noteSecret,
note: namedClaimNote(prepared, prepared.noteSecret), note: namedClaimNote(prepared, prepared.noteSecret),
} };
// from here on the carve's fresh secrets exist only in this result - the // from here on the carve's fresh secrets exist only in this result - the
// flow never throws again; every outcome carries them // flow never throws again; every outcome carries them
const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} const base = { carve, quote, invoice, verifyUrl, sourceServer, targetServer };
const k1 = requireNoteK1(carve.note.url) const k1 = requireNoteK1(carve.note.url);
if (!onMeltReady) throw new TransferMeltCheckpointRequiredError() if (!onMeltReady) throw new TransferMeltCheckpointRequiredError();
const [sourceRecoverySecret] = await requireOutputSecrets( const [sourceRecoverySecret] = await requireOutputSecrets(
allocateOutputSecrets, allocateOutputSecrets,
serverOf(carve.note.url), serverOf(carve.note.url),
1, 1,
) );
if (carve.consumed.length === 0) assertFundOwner(options) if (carve.consumed.length === 0) assertFundOwner(options);
await onMeltReady(carve, sourceRecoverySecret) await onMeltReady(carve, sourceRecoverySecret);
assertFundOwner(options) assertFundOwner(options);
try { try {
await meltNote(carve.note.callback, k1, invoice, options) await meltNote(carve.note.callback, k1, invoice, options);
} catch (error) { } catch (error) {
if (!(error instanceof Error)) throw error if (!(error instanceof Error)) throw error;
if (error instanceof NoteSpentError) { if (error instanceof NoteSpentError) {
return {...base, outcome: 'note-already-spent'} return { ...base, outcome: 'note-already-spent' };
} }
// anything else - a clean refusal, a dropped response, a lost answer - // anything else - a clean refusal, a dropped response, a lost answer -
// is resolved below: the target invoice settles only if this melt's // is resolved below: the target invoice settles only if this melt's
@@ -310,15 +319,15 @@ export const transferBetweenMints = async (
} }
try { try {
try { try {
const claimed = await claimFromSecret(prepared, poll, options) const claimed = await claimFromSecret(prepared, poll, options);
return {...base, outcome: 'settled', mintedAtTarget: claimed} return { ...base, outcome: 'settled', mintedAtTarget: claimed };
} catch (error) { } catch (error) {
if (error instanceof MintedNoteSpentError) { if (error instanceof MintedNoteSpentError) {
return {...base, outcome: 'settled-claim-failed', claimMaterial} return { ...base, outcome: 'settled-claim-failed', claimMaterial };
} }
throw new Error('The target note could not be confirmed at its staged secret.', { throw new Error('The target note could not be confirmed at its staged secret.', {
cause: error, cause: error,
}) });
} }
} catch { } catch {
// the target invoice never settled within budget - the source note is // the target invoice never settled within budget - the source note is
@@ -331,7 +340,7 @@ export const transferBetweenMints = async (
const rotated = await rotateNote(carve.note.callback, k1, { const rotated = await rotateNote(carve.note.callback, k1, {
...options, ...options,
randomSecret: () => sourceRecoverySecret, randomSecret: () => sourceRecoverySecret,
}) });
// the rotate succeeded: the melt provably never landed, and the // the rotate succeeded: the melt provably never landed, and the
// funds are back - re-secured, since the melt attempt put k1 on the // funds are back - re-secured, since the melt attempt put k1 on the
// wire. Reported separately from `carve` (see rotatedNote): the // wire. Reported separately from `carve` (see rotatedNote): the
@@ -347,10 +356,10 @@ export const transferBetweenMints = async (
verified: true, verified: true,
...(carve.note.mintPubkey ? { mintPubkey: carve.note.mintPubkey } : {}), ...(carve.note.mintPubkey ? { mintPubkey: carve.note.mintPubkey } : {}),
}, },
} };
} catch (err) { } catch (err) {
if (err instanceof PendingNoteError || err instanceof NoteSpentError) { if (err instanceof PendingNoteError || err instanceof NoteSpentError) {
return {...base, outcome: 'unknown-still-pending', claimMaterial} return { ...base, outcome: 'unknown-still-pending', claimMaterial };
} }
if (err instanceof AmbiguousMutationError) { if (err instanceof AmbiguousMutationError) {
// The rotate's answer was lost. Keep the transfer journal until // The rotate's answer was lost. Keep the transfer journal until
@@ -360,11 +369,11 @@ export const transferBetweenMints = async (
callback: carve.note.callback, callback: carve.note.callback,
amount: carve.note.amount, amount: carve.note.amount,
verified: false, verified: false,
};
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey;
return { ...base, outcome: 'unknown-still-pending', claimMaterial, rescuedNote };
} }
if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey return { ...base, outcome: 'unknown-still-pending', claimMaterial };
return {...base, outcome: 'unknown-still-pending', claimMaterial, rescuedNote}
}
return {...base, outcome: 'unknown-still-pending', claimMaterial}
}
} }
} }
};
+8 -8
View File
@@ -4,15 +4,15 @@
// The proven marker lives on the v2 wallet-material record; adoption itself // The proven marker lives on the v2 wallet-material record; adoption itself
// additionally requires the slot to commit to the exact saved material. // additionally requires the slot to commit to the exact saved material.
import {linkingPubKeyHex} from './keys' import { linkingPubKeyHex } from './keys';
import {adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY} from './storage/passkeySlots' import { adoptLegacyPasskeySlots, PASSKEY_SLOTS_STORAGE_KEY } from './storage/passkeySlots';
import {withStorageLock} from './storageLock' import { withStorageLock } from './storageLock';
import {savedWalletMaterialOwnerId} from './walletMaterialStorage' import { savedWalletMaterialOwnerId } from './walletMaterialStorage';
export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => { export const migrateLegacyPasskeySlots = async (linkingKey: Uint8Array): Promise<number> => {
const ownerId = savedWalletMaterialOwnerId() const ownerId = savedWalletMaterialOwnerId();
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Legacy passkey migration requires a proven owner.') throw new Error('Legacy passkey migration requires a proven owner.');
}
return withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => adoptLegacyPasskeySlots(ownerId))
} }
return withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => adoptLegacyPasskeySlots(ownerId));
};
+29 -29
View File
@@ -2,65 +2,65 @@
// is therefore both authority for the old wrap and input for its fresh wrap; // is therefore both authority for the old wrap and input for its fresh wrap;
// one wrong credential aborts the complete in-memory batch before persistence. // one wrong credential aborts the complete in-memory batch before persistence.
import type {WalletMaterialV2} from './storage/storedSecret' import type { WalletMaterialV2 } from './storage/storedSecret';
import {serializeWalletMaterial, walletMaterialHash} from './storage/storedSecret' import { serializeWalletMaterial, walletMaterialHash } from './storage/storedSecret';
import type {PasskeySlot} from './storage/passkeySlots' import type { PasskeySlot } from './storage/passkeySlots';
import { import {
PASSKEY_SLOTS_STORAGE_KEY, PASSKEY_SLOTS_STORAGE_KEY,
passkeySlotsEqual, passkeySlotsEqual,
readPasskeySlots, readPasskeySlots,
requireCurrentPasskeyMaterialOwner, requireCurrentPasskeyMaterialOwner,
writePasskeySlots, writePasskeySlots,
} from './storage/passkeySlots' } from './storage/passkeySlots';
import {withStorageLock} from './storageLock' import { withStorageLock } from './storageLock';
import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap' import { unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf } from './passkeyWrap';
type ProvenSlot = { type ProvenSlot = {
readonly slot: PasskeySlot readonly slot: PasskeySlot;
readonly prfOutput: Uint8Array readonly prfOutput: Uint8Array;
} };
export const rewrapAllSlots = async ( export const rewrapAllSlots = async (
material: WalletMaterialV2, material: WalletMaterialV2,
prfOutputs: ReadonlyMap<string, Uint8Array>, prfOutputs: ReadonlyMap<string, Uint8Array>,
): Promise<void> => { ): Promise<void> => {
const ownerId = requireCurrentPasskeyMaterialOwner(material) const ownerId = requireCurrentPasskeyMaterialOwner(material);
const materialHash = walletMaterialHash(material) const materialHash = walletMaterialHash(material);
const serialized = serializeWalletMaterial(material) const serialized = serializeWalletMaterial(material);
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => {
const provenSlots: ProvenSlot[] = readPasskeySlots().map((slot) => { const provenSlots: ProvenSlot[] = readPasskeySlots().map((slot) => {
const prfOutput = prfOutputs.get(slot.credentialId) const prfOutput = prfOutputs.get(slot.credentialId);
if (!prfOutput) { if (!prfOutput) {
throw new Error('Missing old PRF output for a passkey slot - refusing a partial re-wrap.') throw new Error('Missing old PRF output for a passkey slot - refusing a partial re-wrap.');
} }
return {slot, prfOutput} return { slot, prfOutput };
}) });
for (const { slot, prfOutput } of provenSlots) { for (const { slot, prfOutput } of provenSlots) {
if (slot.materialHash !== materialHash) { if (slot.materialHash !== materialHash) {
throw new Error('Passkey slot does not match the saved wallet material.') throw new Error('Passkey slot does not match the saved wallet material.');
} }
const current = await unwrapWalletMaterialWithPrf(prfOutput, slot) const current = await unwrapWalletMaterialWithPrf(prfOutput, slot);
if (serializeWalletMaterial(current) !== serialized) { if (serializeWalletMaterial(current) !== serialized) {
throw new Error('Passkey slot contains different wallet material.') throw new Error('Passkey slot contains different wallet material.');
} }
} }
const rewrapped: PasskeySlot[] = [] const rewrapped: PasskeySlot[] = [];
for (const { slot, prfOutput } of provenSlots) { for (const { slot, prfOutput } of provenSlots) {
rewrapped.push({...slot, ...(await wrapWalletMaterialWithPrf(prfOutput, material))}) rewrapped.push({ ...slot, ...(await wrapWalletMaterialWithPrf(prfOutput, material)) });
} }
if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) { if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) {
throw new Error('Saved wallet material changed during passkey re-wrap.') throw new Error('Saved wallet material changed during passkey re-wrap.');
} }
const currentSlots = readPasskeySlots() const currentSlots = readPasskeySlots();
if ( if (
currentSlots.length !== provenSlots.length || currentSlots.length !== provenSlots.length ||
currentSlots.some((current, index) => { currentSlots.some((current, index) => {
const original = provenSlots[index]?.slot const original = provenSlots[index]?.slot;
return original === undefined || !passkeySlotsEqual(original, current) return original === undefined || !passkeySlotsEqual(original, current);
}) })
) { ) {
throw new Error('Passkey slots changed during re-wrap.') throw new Error('Passkey slots changed during re-wrap.');
}
writePasskeySlots(ownerId, rewrapped)
})
} }
writePasskeySlots(ownerId, rewrapped);
});
};
+27 -27
View File
@@ -8,20 +8,20 @@
// info string domain-separates these keys from any other key ever derived // info string domain-separates these keys from any other key ever derived
// from the same PRF output. // from the same PRF output.
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import type {PasskeyWrap} from './storage/passkeySlots' import type { PasskeyWrap } from './storage/passkeySlots';
import {parseWalletMaterial, serializeWalletMaterial} from './storage/storedSecret' import { parseWalletMaterial, serializeWalletMaterial } from './storage/storedSecret';
import type {WalletMaterialV2} from './storage/storedSecret' import type { WalletMaterialV2 } from './storage/storedSecret';
import {serializedWalletMaterialHash, walletMaterialHash} from './storage/walletMaterial' import { serializedWalletMaterialHash, walletMaterialHash } from './storage/walletMaterial';
const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2' const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2';
export class InvalidPasskeyMaterialError extends Error { export class InvalidPasskeyMaterialError extends Error {
override readonly name = 'InvalidPasskeyMaterialError' override readonly name = 'InvalidPasskeyMaterialError';
constructor() { constructor() {
super('Passkey payload is not canonical v2 wallet material.') super('Passkey payload is not canonical v2 wallet material.');
} }
} }
@@ -31,7 +31,7 @@ export const derivePasskeyWrapKey = async (
): Promise<CryptoKey> => { ): Promise<CryptoKey> => {
const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(prfOutput), 'HKDF', false, [ const baseKey = await crypto.subtle.importKey('raw', new Uint8Array(prfOutput), 'HKDF', false, [
'deriveKey', 'deriveKey',
]) ]);
return crypto.subtle.deriveKey( return crypto.subtle.deriveKey(
// the copies pin the TS type to Uint8Array<ArrayBuffer> - hexToBytes // the copies pin the TS type to Uint8Array<ArrayBuffer> - hexToBytes
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects // returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
@@ -45,28 +45,28 @@ export const derivePasskeyWrapKey = async (
{ name: 'AES-GCM', length: 256 }, { name: 'AES-GCM', length: 256 },
false, false,
['encrypt', 'decrypt'], ['encrypt', 'decrypt'],
) );
} };
export const wrapWalletMaterialWithPrf = async ( export const wrapWalletMaterialWithPrf = async (
prfOutput: Uint8Array, prfOutput: Uint8Array,
material: WalletMaterialV2, material: WalletMaterialV2,
): Promise<PasskeyWrap> => { ): Promise<PasskeyWrap> => {
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)) const hkdfSalt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt);
const serialized = serializeWalletMaterial(material) const serialized = serializeWalletMaterial(material);
if (parseWalletMaterial(serialized) === null) throw new InvalidPasskeyMaterialError() if (parseWalletMaterial(serialized) === null) throw new InvalidPasskeyMaterialError();
const ciphertext = new Uint8Array( const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, utf8ToBytes(serialized)), await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, utf8ToBytes(serialized)),
) );
return { return {
hkdfSalt: bytesToHex(hkdfSalt), hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv), iv: bytesToHex(iv),
materialHash: walletMaterialHash(material), materialHash: walletMaterialHash(material),
wrappedMaterial: bytesToHex(ciphertext), wrappedMaterial: bytesToHex(ciphertext),
} };
} };
// rejects (WebCrypto's own auth-tag check) if the PRF output is wrong - // rejects (WebCrypto's own auth-tag check) if the PRF output is wrong -
// i.e. a different passkey than the one that created the slot // i.e. a different passkey than the one that created the slot
@@ -74,17 +74,17 @@ export const unwrapWalletMaterialWithPrf = async (
prfOutput: Uint8Array, prfOutput: Uint8Array,
wrap: PasskeyWrap, wrap: PasskeyWrap,
): Promise<WalletMaterialV2> => { ): Promise<WalletMaterialV2> => {
const wrapKey = await derivePasskeyWrapKey(prfOutput, hexToBytes(wrap.hkdfSalt)) const wrapKey = await derivePasskeyWrapKey(prfOutput, hexToBytes(wrap.hkdfSalt));
const plaintext = await crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv)) }, { name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv)) },
wrapKey, wrapKey,
new Uint8Array(hexToBytes(wrap.wrappedMaterial)), new Uint8Array(hexToBytes(wrap.wrappedMaterial)),
) );
const serialized = new TextDecoder().decode(plaintext) const serialized = new TextDecoder().decode(plaintext);
if (serializedWalletMaterialHash(serialized) !== wrap.materialHash) { if (serializedWalletMaterialHash(serialized) !== wrap.materialHash) {
throw new InvalidPasskeyMaterialError() throw new InvalidPasskeyMaterialError();
}
const material = parseWalletMaterial(serialized)
if (material === null) throw new InvalidPasskeyMaterialError()
return material
} }
const material = parseWalletMaterial(serialized);
if (material === null) throw new InvalidPasskeyMaterialError();
return material;
};
+71 -66
View File
@@ -1,129 +1,134 @@
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {deriveWalletMaterial, saveWalletMaterial} from './keys' import { deriveWalletMaterial, saveWalletMaterial } from './keys';
import { import {
getPasskeyPrfOutput, getPasskeyPrfOutput,
registerPasskey, registerPasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWalletMaterialWithPasskey, unlockWalletMaterialWithPasskey,
} from './passkeys' } from './passkeys';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import {stubLocalStorage} from './test-utils' import { stubLocalStorage } from './test-utils';
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const MNEMONIC =
const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow' 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const MATERIAL = {...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32)} const OTHER_MNEMONIC =
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC) 'legal winner thank year wave sausage worth useful legal winner thank yellow';
const ALTERED_ROOT = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex} const MATERIAL = { ...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32) };
const SLOT_STORAGE_KEY = 'sattle_passkey_slots' const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC);
const ALTERED_ROOT = { ...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex };
const SLOT_STORAGE_KEY = 'sattle_passkey_slots';
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
private readonly credentialId = new Uint8Array(16).fill(12) private readonly credentialId = new Uint8Array(16).fill(12);
private readonly secret = new Uint8Array(32).fill(13) private readonly secret = new Uint8Array(32).fill(13);
onCreate?: () => Promise<void> onCreate?: () => Promise<void>;
onGet?: () => Promise<void> onGet?: () => Promise<void>;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential> => {
await this.onCreate?.() await this.onCreate?.();
return this.credential(options?.publicKey?.extensions?.prf?.eval?.first) return this.credential(options?.publicKey?.extensions?.prf?.eval?.first);
} };
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
await this.onGet?.() await this.onGet?.();
const allowed = options?.publicKey?.allowCredentials ?? [] const allowed = options?.publicKey?.allowCredentials ?? [];
return allowed.some( return allowed.some(
(descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId),
) )
? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) ? this.credential(options?.publicKey?.extensions?.prf?.eval?.first)
: null : null;
} };
private credential = (salt: BufferSource | undefined): CeremonyCredential => ({ private credential = (salt: BufferSource | undefined): CeremonyCredential => ({
type: 'public-key', type: 'public-key',
rawId: this.credentialId, rawId: this.credentialId,
getClientExtensionResults: () => ({ getClientExtensionResults: () => ({
prf: salt prf: salt
? {enabled: true, results: {first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt)))}} ? {
enabled: true,
results: { first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt))) },
}
: {}, : {},
}), }),
}) });
} }
const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? '' const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? '';
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('exact wallet material passkey commitment', () => { describe('exact wallet material passkey commitment', () => {
it('rejects a wrong old PRF before changing any slot bytes', async () => { it('rejects a wrong old PRF before changing any slot bytes', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const slot = await registerPasskey(MATERIAL, { credentials: authenticator });
const before = rawSlots() const before = rawSlots();
const wrongPrf = new Uint8Array(32).fill(99) const wrongPrf = new Uint8Array(32).fill(99);
await expect( await expect(
rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, wrongPrf]])), rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, wrongPrf]])),
).rejects.toThrow() ).rejects.toThrow();
expect(rawSlots()).toBe(before) expect(rawSlots()).toBe(before);
}) });
it('rejects same-owner altered-root registration against encrypted saved material', async () => { it('rejects same-owner altered-root registration against encrypted saved material', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await saveWalletMaterial(MATERIAL, 'password') await saveWalletMaterial(MATERIAL, 'password');
await expect(registerPasskey(ALTERED_ROOT, { credentials: authenticator })).rejects.toThrow( await expect(registerPasskey(ALTERED_ROOT, { credentials: authenticator })).rejects.toThrow(
'saved wallet material', 'saved wallet material',
) );
expect(rawSlots()).toBe('') expect(rawSlots()).toBe('');
}) });
it('rejects registration when same-owner saved material changes during the ceremony', async () => { it('rejects registration when same-owner saved material changes during the ceremony', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
authenticator.onCreate = () => saveWalletMaterial(ALTERED_ROOT) authenticator.onCreate = () => saveWalletMaterial(ALTERED_ROOT);
await expect(registerPasskey(MATERIAL, { credentials: authenticator })).rejects.toThrow( await expect(registerPasskey(MATERIAL, { credentials: authenticator })).rejects.toThrow(
'saved wallet material', 'saved wallet material',
) );
expect(rawSlots()).toBe('') expect(rawSlots()).toBe('');
}) });
it('rejects unlock after same-owner saved material was replaced', async () => { it('rejects unlock after same-owner saved material was replaced', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: authenticator}) await registerPasskey(MATERIAL, { credentials: authenticator });
await saveWalletMaterial(ALTERED_ROOT, 'password') await saveWalletMaterial(ALTERED_ROOT, 'password');
await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow() await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).rejects.toThrow();
}) });
it('rejects same-owner saved-material replacement during the unlock ceremony', async () => { it('rejects same-owner saved-material replacement during the unlock ceremony', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: authenticator}) await registerPasskey(MATERIAL, { credentials: authenticator });
authenticator.onGet = () => saveWalletMaterial(ALTERED_ROOT) authenticator.onGet = () => saveWalletMaterial(ALTERED_ROOT);
await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).rejects.toThrow( await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).rejects.toThrow(
'changed', 'changed',
) );
}) });
it('keeps a valid slot usable when its old PRF proves the existing wrap', async () => { it('keeps a valid slot usable when its old PRF proves the existing wrap', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const slot = await registerPasskey(MATERIAL, { credentials: authenticator });
const oldPrf = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) const oldPrf = await getPasskeyPrfOutput(slot.credentialId, { credentials: authenticator });
await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, oldPrf]])) await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, oldPrf]]));
await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).resolves.toEqual( await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).resolves.toEqual(
MATERIAL, MATERIAL,
) );
}) });
}) });
+76 -76
View File
@@ -4,12 +4,12 @@
// unguessable without the authenticator. Everything except a real // unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here. // authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -22,7 +22,7 @@ import {
rewrapAllSlots, rewrapAllSlots,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
@@ -34,43 +34,43 @@ import {
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
} from './keys' } from './keys';
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import { parseJsonObject, parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates // Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks // PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never. // found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource // id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>() private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true supportsPrf = true;
prfResultsOnCreate = true prfResultsOnCreate = true;
prfResultsOnGet = true prfResultsOnGet = true;
createCalls = 0 createCalls = 0;
getCalls = 0 getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1 this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16)) const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32)) const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), {id, secret}) this.held.set(bytesToHex(id), { id, secret });
return { return {
type: 'public-key', type: 'public-key',
rawId: id, rawId: id,
@@ -83,19 +83,19 @@ class FakeAuthenticator implements PasskeyCredentials {
} }
: {}, : {},
}), }),
} };
} };
// answers with the first allowed credential it holds, like a real // answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none // authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1 this.getCalls += 1;
const pk = options?.publicKey const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex)) const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined const held = match ? this.held.get(match) : undefined;
if (!held) return null if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first const salt = pk?.extensions?.prf?.eval?.first;
return { return {
type: 'public-key', type: 'public-key',
rawId: held.id, rawId: held.id,
@@ -105,77 +105,77 @@ class FakeAuthenticator implements PasskeyCredentials {
? { enabled: true, results: { first: this.prf(held.secret, salt) } } ? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {}, : {},
}), }),
} };
} };
// simulates the passkey's secret changing underneath a slot (credential // simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind) // re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => { rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId) const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
} };
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => { private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>, // set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects // which BufferSource rejects
const out = new Uint8Array(32) const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt))) out.set(hmac(sha256, secret, toBytes(salt)));
return out return out;
} };
} }
const readRawSlots = (): Array<Record<string, unknown>> => const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => { const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
} };
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}');
delete stored.ownerId delete stored.ownerId;
delete stored.version delete stored.version;
localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) localStorage.setItem('sattle_linking_key', JSON.stringify(stored));
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('pure wrap crypto', () => { describe('pure wrap crypto', () => {
it('round-trips a linking key through a PRF-derived wrap', async () => { it('round-trips a linking key through a PRF-derived wrap', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
const unwrapped = await unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap) const unwrapped = await unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap);
expect(unwrapped).toEqual(MATERIAL) expect(unwrapped).toEqual(MATERIAL);
}) });
it('rejects unwrap with a different PRF output', async () => { it('rejects unwrap with a different PRF output', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow();
}) });
it('rejects unwrap with a tampered HKDF salt', async () => { it('rejects unwrap with a tampered HKDF salt', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
await expect( await expect(
unwrapWalletMaterialWithPrf(PRF_OUTPUT, { ...wrap, hkdfSalt: 'ab'.repeat(16) }), unwrapWalletMaterialWithPrf(PRF_OUTPUT, { ...wrap, hkdfSalt: 'ab'.repeat(16) }),
).rejects.toThrow() ).rejects.toThrow();
}) });
it('rejects unwrap with a tampered ciphertext', async () => { it('rejects unwrap with a tampered ciphertext', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
const flipped = `${wrap.wrappedMaterial.slice(0, -2)}${wrap.wrappedMaterial.endsWith('00') ? '01' : '00'}` const flipped = `${wrap.wrappedMaterial.slice(0, -2)}${wrap.wrappedMaterial.endsWith('00') ? '01' : '00'}`;
await expect( await expect(
unwrapWalletMaterialWithPrf(PRF_OUTPUT, { ...wrap, wrappedMaterial: flipped }), unwrapWalletMaterialWithPrf(PRF_OUTPUT, { ...wrap, wrappedMaterial: flipped }),
).rejects.toThrow() ).rejects.toThrow();
}) });
it('derives wrap keys deterministically from the same PRF output and salt', async () => { it('derives wrap keys deterministically from the same PRF output and salt', async () => {
const salt = new Uint8Array(16).fill(1) const salt = new Uint8Array(16).fill(1);
const a = await derivePasskeyWrapKey(PRF_OUTPUT, salt) const a = await derivePasskeyWrapKey(PRF_OUTPUT, salt);
const b = await derivePasskeyWrapKey(PRF_OUTPUT, salt) const b = await derivePasskeyWrapKey(PRF_OUTPUT, salt);
const record = await encryptRecord(a, {v: 1}) const record = await encryptRecord(a, { v: 1 });
await expect(decryptRecord(b, record)).resolves.toEqual({v: 1}) await expect(decryptRecord(b, record)).resolves.toEqual({ v: 1 });
}) });
}) });
+151 -146
View File
@@ -2,15 +2,12 @@
// material in assertions or diagnostics. The fake authenticator models only // material in assertions or diagnostics. The fake authenticator models only
// WebAuthn credential selection and deterministic PRF evaluation. // WebAuthn credential selection and deterministic PRF evaluation.
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import { import { deriveWalletMaterial, saveWalletMaterial } from './keys';
deriveWalletMaterial,
saveWalletMaterial,
} from './keys'
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -20,109 +17,112 @@ import {
unlockWalletMaterialWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import type {CeremonyCredential, PasskeyCredentials, PasskeyWrap} from './passkeys' import type { CeremonyCredential, PasskeyCredentials, PasskeyWrap } from './passkeys';
import {stubLocalStorage} from './test-utils' import { stubLocalStorage } from './test-utils';
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const MNEMONIC =
const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow' 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const MATERIAL = {...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32)} const OTHER_MNEMONIC =
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC) 'legal winner thank year wave sausage worth useful legal winner thank yellow';
const PRF_OUTPUT = new Uint8Array(32).fill(3) const MATERIAL = { ...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32) };
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC);
const SLOT_STORAGE_KEY = 'sattle_passkey_slots' const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const SLOT_STORAGE_KEY = 'sattle_passkey_slots';
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
private readonly credentialId = new Uint8Array(16).fill(5) private readonly credentialId = new Uint8Array(16).fill(5);
private secret = new Uint8Array(32).fill(6) private secret = new Uint8Array(32).fill(6);
onGet?: () => Promise<void> onGet?: () => Promise<void>;
cancelCreate = false cancelCreate = false;
rotateSecret = (): void => { rotateSecret = (): void => {
this.secret = new Uint8Array(32).fill(7) this.secret = new Uint8Array(32).fill(7);
} };
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
if (this.cancelCreate) return null if (this.cancelCreate) return null;
return this.credential(options?.publicKey?.extensions?.prf?.eval?.first) return this.credential(options?.publicKey?.extensions?.prf?.eval?.first);
} };
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
await this.onGet?.() await this.onGet?.();
const allowed = options?.publicKey?.allowCredentials ?? [] const allowed = options?.publicKey?.allowCredentials ?? [];
const selected = allowed.some( const selected = allowed.some(
(descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId),
) );
return selected return selected ? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) : null;
? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) };
: null
}
private credential = (salt: BufferSource | undefined): CeremonyCredential => ({ private credential = (salt: BufferSource | undefined): CeremonyCredential => ({
type: 'public-key', type: 'public-key',
rawId: this.credentialId, rawId: this.credentialId,
getClientExtensionResults: () => ({ getClientExtensionResults: () => ({
prf: salt prf: salt
? {enabled: true, results: {first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt)))}} ? {
enabled: true,
results: { first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt))) },
}
: {}, : {},
}), }),
}) });
} }
const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? '' const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? '';
const alterLastByte = (hex: string): string => const alterLastByte = (hex: string): string =>
`${hex.slice(0, -2)}${hex.endsWith('00') ? '01' : '00'}` `${hex.slice(0, -2)}${hex.endsWith('00') ? '01' : '00'}`;
const encryptPayload = async ( const encryptPayload = async (
prfOutput: Uint8Array, prfOutput: Uint8Array,
payload: string, payload: string,
materialHash: string, materialHash: string,
): Promise<PasskeyWrap> => { ): Promise<PasskeyWrap> => {
const hkdfSalt = new Uint8Array(16).fill(8) const hkdfSalt = new Uint8Array(16).fill(8);
const iv = new Uint8Array(12).fill(9) const iv = new Uint8Array(12).fill(9);
const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt);
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, { name: 'AES-GCM', iv },
wrapKey, wrapKey,
utf8ToBytes(payload), utf8ToBytes(payload),
) );
return { return {
hkdfSalt: bytesToHex(hkdfSalt), hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv), iv: bytesToHex(iv),
materialHash, materialHash,
wrappedMaterial: bytesToHex(new Uint8Array(ciphertext)), wrappedMaterial: bytesToHex(new Uint8Array(ciphertext)),
} };
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('v2 passkey material', () => { describe('v2 passkey material', () => {
it('round-trips canonical material bytes through the PRF wrap', async () => { it('round-trips canonical material bytes through the PRF wrap', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap)).resolves.toEqual(MATERIAL) await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap)).resolves.toEqual(MATERIAL);
}) });
it('rejects wrong PRF and altered ciphertext without changing the wrap', async () => { it('rejects wrong PRF and altered ciphertext without changing the wrap', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL);
const before = JSON.stringify(wrap) const before = JSON.stringify(wrap);
const altered = { const altered = {
...wrap, ...wrap,
wrappedMaterial: alterLastByte(wrap.wrappedMaterial), wrappedMaterial: alterLastByte(wrap.wrappedMaterial),
} };
await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow();
await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, altered)).rejects.toThrow() await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, altered)).rejects.toThrow();
expect(JSON.stringify(wrap)).toBe(before) expect(JSON.stringify(wrap)).toBe(before);
}) });
it.each([ it.each([
['legacy key-only payload', MATERIAL.linkingKeyHex], ['legacy key-only payload', MATERIAL.linkingKeyHex],
@@ -131,141 +131,146 @@ describe('v2 passkey material', () => {
JSON.stringify({ ...MATERIAL, cashRootHex: `${MATERIAL.cashRootHex.slice(0, -2)}00` }), JSON.stringify({ ...MATERIAL, cashRootHex: `${MATERIAL.cashRootHex.slice(0, -2)}00` }),
], ],
])('rejects authenticated %s without changing slots or activation', async (_name, payload) => { ])('rejects authenticated %s without changing slots or activation', async (_name, payload) => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const slot = await registerPasskey(MATERIAL, { credentials: authenticator });
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { credentials: authenticator });
const materialHash = bytesToHex(sha256(utf8ToBytes(payload))) const materialHash = bytesToHex(sha256(utf8ToBytes(payload)));
const wrap = await encryptPayload(prfOutput, payload, materialHash) const wrap = await encryptPayload(prfOutput, payload, materialHash);
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...wrap}])) localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{ ...slot, ...wrap }]));
const before = rawSlots() const before = rawSlots();
let activated = false let activated = false;
await expect( await expect(
unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => { unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => {
activated = true activated = true;
}), }),
).rejects.toThrow() ).rejects.toThrow();
expect(activated).toBe(false) expect(activated).toBe(false);
expect(rawSlots()).toBe(before) expect(rawSlots()).toBe(before);
}) });
it('rejects a wrong runtime PRF without changing slots or activation', async () => { it('rejects a wrong runtime PRF without changing slots or activation', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: authenticator}) await registerPasskey(MATERIAL, { credentials: authenticator });
const before = rawSlots() const before = rawSlots();
let activated = false let activated = false;
authenticator.rotateSecret() authenticator.rotateSecret();
await expect( await expect(
unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => { unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => {
activated = true activated = true;
}), }),
).rejects.toThrow() ).rejects.toThrow();
expect(activated).toBe(false) expect(activated).toBe(false);
expect(rawSlots()).toBe(before) expect(rawSlots()).toBe(before);
}) });
it('rejects altered stored ciphertext without rewriting it or activating', async () => { it('rejects altered stored ciphertext without rewriting it or activating', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const slot = await registerPasskey(MATERIAL, { credentials: authenticator });
const altered = { const altered = {
...slot, ...slot,
wrappedMaterial: alterLastByte(slot.wrappedMaterial), wrappedMaterial: alterLastByte(slot.wrappedMaterial),
} };
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([altered])) localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([altered]));
const before = rawSlots() const before = rawSlots();
let activated = false let activated = false;
await expect( await expect(
unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => { unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => {
activated = true activated = true;
}), }),
).rejects.toThrow() ).rejects.toThrow();
expect(activated).toBe(false) expect(activated).toBe(false);
expect(rawSlots()).toBe(before) expect(rawSlots()).toBe(before);
}) });
it('rejects same-owner altered root at registration without a ceremony or slot write', async () => { it('rejects same-owner altered root at registration without a ceremony or slot write', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
const alteredRoot = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex} const alteredRoot = { ...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex };
await expect(registerPasskey(alteredRoot, { credentials: authenticator })).rejects.toThrow( await expect(registerPasskey(alteredRoot, { credentials: authenticator })).rejects.toThrow(
'saved wallet material', 'saved wallet material',
) );
expect(rawSlots()).toBe('') expect(rawSlots()).toBe('');
}) });
it('registers and unlocks complete material without plaintext slot leakage', async () => { it('registers and unlocks complete material without plaintext slot leakage', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const slot = await registerPasskey(MATERIAL, { credentials: authenticator });
const unlocked = await unlockWalletMaterialWithPasskey({credentials: authenticator}) const unlocked = await unlockWalletMaterialWithPasskey({ credentials: authenticator });
expect(unlocked).toEqual(MATERIAL) expect(unlocked).toEqual(MATERIAL);
expect(slot.ownerId).not.toContain(MATERIAL.linkingKeyHex) expect(slot.ownerId).not.toContain(MATERIAL.linkingKeyHex);
expect(rawSlots()).not.toContain(MATERIAL.linkingKeyHex) expect(rawSlots()).not.toContain(MATERIAL.linkingKeyHex);
expect(rawSlots()).not.toContain(MATERIAL.cashRootHex) expect(rawSlots()).not.toContain(MATERIAL.cashRootHex);
}) });
it('rejects authenticated foreign-owner material without changing slots or activation', async () => { it('rejects authenticated foreign-owner material without changing slots or activation', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const slot = await registerPasskey(MATERIAL, { credentials: authenticator });
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { credentials: authenticator });
const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL) const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL);
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...foreignWrap}])) localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{ ...slot, ...foreignWrap }]));
const before = rawSlots() const before = rawSlots();
let activated = false let activated = false;
await expect( await expect(
unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => { unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => {
activated = true activated = true;
}), }),
).rejects.toThrow('different wallet') ).rejects.toThrow('different wallet');
expect(activated).toBe(false) expect(activated).toBe(false);
expect(rawSlots()).toBe(before) expect(rawSlots()).toBe(before);
}) });
it('rejects a slot changed during its ceremony without changing storage or activation', async () => { it('rejects a slot changed during its ceremony without changing storage or activation', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: authenticator}) await registerPasskey(MATERIAL, { credentials: authenticator });
let changed = '' let changed = '';
authenticator.onGet = async () => { authenticator.onGet = async () => {
const slot = readPasskeySlots()[0] const slot = readPasskeySlots()[0];
if (slot === undefined) throw new Error('expected registered slot') if (slot === undefined) throw new Error('expected registered slot');
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, createdAt: slot.createdAt + 1}])) localStorage.setItem(
changed = rawSlots() SLOT_STORAGE_KEY,
} JSON.stringify([{ ...slot, createdAt: slot.createdAt + 1 }]),
let activated = false );
changed = rawSlots();
};
let activated = false;
await expect( await expect(
unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => { unlockWalletMaterialWithPasskey({ credentials: authenticator }).then(() => {
activated = true activated = true;
}), }),
).rejects.toThrow('changed') ).rejects.toThrow('changed');
expect(activated).toBe(false) expect(activated).toBe(false);
expect(rawSlots()).toBe(changed) expect(rawSlots()).toBe(changed);
}) });
it('rewraps every slot around byte-identical material or writes nothing', async () => { it('rewraps every slot around byte-identical material or writes nothing', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const slot = await registerPasskey(MATERIAL, { credentials: authenticator });
const before = rawSlots() const before = rawSlots();
await expect(rewrapAllSlots(MATERIAL, new Map())).rejects.toThrow('partial re-wrap') await expect(rewrapAllSlots(MATERIAL, new Map())).rejects.toThrow('partial re-wrap');
expect(rawSlots()).toBe(before) expect(rawSlots()).toBe(before);
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator}) const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { credentials: authenticator });
await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, prfOutput]])) await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, prfOutput]]));
await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).resolves.toEqual( await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).resolves.toEqual(
MATERIAL, MATERIAL,
) );
}) });
it('does not create a slot when registration is cancelled', async () => { it('does not create a slot when registration is cancelled', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
authenticator.cancelCreate = true authenticator.cancelCreate = true;
await expect(registerPasskey(MATERIAL, {credentials: authenticator})).rejects.toThrow('cancelled') await expect(registerPasskey(MATERIAL, { credentials: authenticator })).rejects.toThrow(
expect(rawSlots()).toBe('') 'cancelled',
}) );
}) expect(rawSlots()).toBe('');
});
});
+78 -76
View File
@@ -4,12 +4,12 @@
// unguessable without the authenticator. Everything except a real // unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here. // authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -23,7 +23,7 @@ import {
unlockWalletMaterialWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
@@ -35,43 +35,43 @@ import {
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
} from './keys' } from './keys';
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import { parseJsonObject, parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates // Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks // PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never. // found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource // id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>() private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true supportsPrf = true;
prfResultsOnCreate = true prfResultsOnCreate = true;
prfResultsOnGet = true prfResultsOnGet = true;
createCalls = 0 createCalls = 0;
getCalls = 0 getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1 this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16)) const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32)) const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), {id, secret}) this.held.set(bytesToHex(id), { id, secret });
return { return {
type: 'public-key', type: 'public-key',
rawId: id, rawId: id,
@@ -84,19 +84,19 @@ class FakeAuthenticator implements PasskeyCredentials {
} }
: {}, : {},
}), }),
} };
} };
// answers with the first allowed credential it holds, like a real // answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none // authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1 this.getCalls += 1;
const pk = options?.publicKey const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex)) const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined const held = match ? this.held.get(match) : undefined;
if (!held) return null if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first const salt = pk?.extensions?.prf?.eval?.first;
return { return {
type: 'public-key', type: 'public-key',
rawId: held.id, rawId: held.id,
@@ -106,82 +106,84 @@ class FakeAuthenticator implements PasskeyCredentials {
? { enabled: true, results: { first: this.prf(held.secret, salt) } } ? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {}, : {},
}), }),
} };
} };
// simulates the passkey's secret changing underneath a slot (credential // simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind) // re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => { rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId) const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
} };
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => { private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>, // set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects // which BufferSource rejects
const out = new Uint8Array(32) const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt))) out.set(hmac(sha256, secret, toBytes(salt)));
return out return out;
} };
} }
const readRawSlots = (): Array<Record<string, unknown>> => const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => { const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
} };
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}');
delete stored.ownerId delete stored.ownerId;
delete stored.version delete stored.version;
localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) localStorage.setItem('sattle_linking_key', JSON.stringify(stored));
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('multiple passkeys', () => { describe('multiple passkeys', () => {
it('keeps slots independent: each passkey unlocks the same key', async () => { it('keeps slots independent: each passkey unlocks the same key', async () => {
const laptop = new FakeAuthenticator() const laptop = new FakeAuthenticator();
const phone = new FakeAuthenticator() const phone = new FakeAuthenticator();
const laptopSlot = await registerPasskey(MATERIAL, { const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop, credentials: laptop,
name: 'laptop', name: 'laptop',
}) });
const phoneSlot = await registerPasskey(MATERIAL, { const phoneSlot = await registerPasskey(MATERIAL, {
credentials: phone, credentials: phone,
name: 'phone', name: 'phone',
}) });
expect(readPasskeySlots()).toHaveLength(2) expect(readPasskeySlots()).toHaveLength(2);
// independent wrap keys: same plaintext, different salts and ciphertexts // independent wrap keys: same plaintext, different salts and ciphertexts
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt) expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt);
expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial) expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial);
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL) expect(await unlockWalletMaterialWithPasskey({ credentials: laptop })).toEqual(MATERIAL);
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL) expect(await unlockWalletMaterialWithPasskey({ credentials: phone })).toEqual(MATERIAL);
}) });
it('removePasskey drops exactly one slot and leaves the rest working', async () => { it('removePasskey drops exactly one slot and leaves the rest working', async () => {
const laptop = new FakeAuthenticator() const laptop = new FakeAuthenticator();
const phone = new FakeAuthenticator() const phone = new FakeAuthenticator();
const laptopSlot = await registerPasskey(MATERIAL, { const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop, credentials: laptop,
}) });
await registerPasskey(MATERIAL, {credentials: phone}) await registerPasskey(MATERIAL, { credentials: phone });
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true) await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(true);
expect(readPasskeySlots()).toHaveLength(1) expect(readPasskeySlots()).toHaveLength(1);
// the removed passkey no longer matches any offered credential // the removed passkey no longer matches any offered credential
await expect(unlockWalletMaterialWithPasskey({credentials: laptop})).rejects.toThrow('cancelled') await expect(unlockWalletMaterialWithPasskey({ credentials: laptop })).rejects.toThrow(
'cancelled',
);
// the survivor is unaffected // the survivor is unaffected
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL) expect(await unlockWalletMaterialWithPasskey({ credentials: phone })).toEqual(MATERIAL);
// removing again is a no-op // removing again is a no-op
await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false) await expect(removePasskey(laptopSlot.credentialId)).resolves.toBe(false);
}) });
}) });
+112 -112
View File
@@ -4,12 +4,12 @@
// unguessable without the authenticator. Everything except a real // unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here. // authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -23,7 +23,7 @@ import {
unlockWalletMaterialWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import { import {
decryptRecord, decryptRecord,
decryptSavedWalletMaterial, decryptSavedWalletMaterial,
@@ -36,43 +36,43 @@ import {
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
walletMaterialLinkingKey, walletMaterialLinkingKey,
} from './keys' } from './keys';
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import { parseJsonObject, parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates // Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks // PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never. // found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource // id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>() private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true supportsPrf = true;
prfResultsOnCreate = true prfResultsOnCreate = true;
prfResultsOnGet = true prfResultsOnGet = true;
createCalls = 0 createCalls = 0;
getCalls = 0 getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1 this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16)) const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32)) const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), {id, secret}) this.held.set(bytesToHex(id), { id, secret });
return { return {
type: 'public-key', type: 'public-key',
rawId: id, rawId: id,
@@ -85,19 +85,19 @@ class FakeAuthenticator implements PasskeyCredentials {
} }
: {}, : {},
}), }),
} };
} };
// answers with the first allowed credential it holds, like a real // answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none // authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1 this.getCalls += 1;
const pk = options?.publicKey const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex)) const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined const held = match ? this.held.get(match) : undefined;
if (!held) return null if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first const salt = pk?.extensions?.prf?.eval?.first;
return { return {
type: 'public-key', type: 'public-key',
rawId: held.id, rawId: held.id,
@@ -107,72 +107,72 @@ class FakeAuthenticator implements PasskeyCredentials {
? { enabled: true, results: { first: this.prf(held.secret, salt) } } ? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {}, : {},
}), }),
} };
} };
// simulates the passkey's secret changing underneath a slot (credential // simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind) // re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => { rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId) const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
} };
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => { private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>, // set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects // which BufferSource rejects
const out = new Uint8Array(32) const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt))) out.set(hmac(sha256, secret, toBytes(salt)));
return out return out;
} };
} }
const readRawSlots = (): Array<Record<string, unknown>> => const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => { const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
} };
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_wallet_material_v2') ?? '{}') const stored = parseJsonObject(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
delete stored.ownerId delete stored.ownerId;
delete stored.version delete stored.version;
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(stored)) localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(stored));
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('slot ownership', () => { describe('slot ownership', () => {
it('binds a new slot to the proven saved wallet owner', async () => { it('binds a new slot to the proven saved wallet owner', async () => {
// Given the saved wallet has a canonical owner marker // Given the saved wallet has a canonical owner marker
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
// When its linking key registers a passkey // When its linking key registers a passkey
const slot = await registerPasskey(MATERIAL, {credentials: auth}) const slot = await registerPasskey(MATERIAL, { credentials: auth });
// Then the slot carries that same canonical owner // Then the slot carries that same canonical owner
expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) expect(slot.ownerId).toBe(linkingPubKeyHex(LINKING_KEY));
expect(readPasskeySlots()).toEqual([slot]) expect(readPasskeySlots()).toEqual([slot]);
}) });
it('filters foreign, malformed, and unowned slots from reads and availability', async () => { it('filters foreign, malformed, and unowned slots from reads and availability', async () => {
// Given one valid current-owner slot plus copies with untrusted owners // Given one valid current-owner slot plus copies with untrusted owners
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
const current = await registerPasskey(MATERIAL, {credentials: auth}) const current = await registerPasskey(MATERIAL, { credentials: auth });
const foreign = { const foreign = {
...current, ...current,
credentialId: '11'.repeat(16), credentialId: '11'.repeat(16),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
} };
const malformed = { const malformed = {
...current, ...current,
credentialId: '22'.repeat(16), credentialId: '22'.repeat(16),
ownerId: 'not-an-owner', ownerId: 'not-an-owner',
} };
const unowned = { const unowned = {
credentialId: '33'.repeat(16), credentialId: '33'.repeat(16),
hkdfSalt: current.hkdfSalt, hkdfSalt: current.hkdfSalt,
@@ -180,76 +180,76 @@ describe('slot ownership', () => {
materialHash: current.materialHash, materialHash: current.materialHash,
wrappedMaterial: current.wrappedMaterial, wrappedMaterial: current.wrappedMaterial,
createdAt: current.createdAt, createdAt: current.createdAt,
} };
writeRawSlots([foreign, malformed, unowned]) writeRawSlots([foreign, malformed, unowned]);
// When the current wallet asks for its slots // When the current wallet asks for its slots
const slots = readPasskeySlots() const slots = readPasskeySlots();
// Then no foreign or unproven slot is exposed // Then no foreign or unproven slot is exposed
expect(slots).toEqual([]) expect(slots).toEqual([]);
expect(hasPasskeySlots()).toBe(false) expect(hasPasskeySlots()).toBe(false);
}) });
it('does not offer markerless slots for passkey-first unlock', async () => { it('does not offer markerless slots for passkey-first unlock', async () => {
// Given a legacy slot and a saved wallet with no proven owner marker // Given a legacy slot and a saved wallet with no proven owner marker
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, { credentials: auth });
const legacy = readRawSlots() const legacy = readRawSlots();
delete legacy[0]?.ownerId delete legacy[0]?.ownerId;
delete legacy[0]?.version delete legacy[0]?.version;
writeRawSlots(legacy) writeRawSlots(legacy);
removeSavedOwnerMarker() removeSavedOwnerMarker();
// When passkey unlock is attempted before another proof path // When passkey unlock is attempted before another proof path
const attempt = unlockWalletMaterialWithPasskey({credentials: auth}) const attempt = unlockWalletMaterialWithPasskey({ credentials: auth });
// Then it fails before asking the authenticator // Then it fails before asking the authenticator
await expect(attempt).rejects.toThrow('No passkeys') await expect(attempt).rejects.toThrow('No passkeys');
expect(auth.getCalls).toBe(0) expect(auth.getCalls).toBe(0);
expect(hasPasskeySlots()).toBe(false) expect(hasPasskeySlots()).toBe(false);
}) });
it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => { it('does not auto-adopt legacy slots when a foreign wallet is saved', async () => {
// Given markerless residue from the old wallet // Given markerless residue from the old wallet
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, { credentials: auth });
const legacy = readRawSlots() const legacy = readRawSlots();
delete legacy[0]?.ownerId delete legacy[0]?.ownerId;
delete legacy[0]?.version delete legacy[0]?.version;
writeRawSlots(legacy) writeRawSlots(legacy);
// When a different wallet is installed with its canonical owner // When a different wallet is installed with its canonical owner
await saveLinkingKey(OTHER_LINKING_KEY) await saveLinkingKey(OTHER_LINKING_KEY);
await saveWalletMaterial({...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)}) await saveWalletMaterial({ ...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY) });
// Then the residue stays unowned and unavailable to the new wallet // Then the residue stays unowned and unavailable to the new wallet
expect(readPasskeySlots()).toEqual([]) expect(readPasskeySlots()).toEqual([]);
expect(hasPasskeySlots()).toBe(false) expect(hasPasskeySlots()).toBe(false);
expect(readRawSlots()).toEqual(legacy) expect(readRawSlots()).toEqual(legacy);
}) });
it('adopts legacy slots only after the saved wallet owner is proven', async () => { it('adopts legacy slots only after the saved wallet owner is proven', async () => {
// Given a legacy encrypted wallet and its markerless passkey slot // Given a legacy encrypted wallet and its markerless passkey slot
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, { credentials: auth });
const legacy = readRawSlots() const legacy = readRawSlots();
delete legacy[0]?.ownerId delete legacy[0]?.ownerId;
delete legacy[0]?.version delete legacy[0]?.version;
writeRawSlots(legacy) writeRawSlots(legacy);
await saveWalletMaterial(MATERIAL, 'correct horse') await saveWalletMaterial(MATERIAL, 'correct horse');
removeSavedOwnerMarker() removeSavedOwnerMarker();
// When migration is attempted before and then after password proof // When migration is attempted before and then after password proof
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner') await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner');
const provenMaterial = await decryptSavedWalletMaterial('correct horse') const provenMaterial = await decryptSavedWalletMaterial('correct horse');
ensureSavedWalletMaterialOwner(provenMaterial) ensureSavedWalletMaterialOwner(provenMaterial);
await migrateLegacyPasskeySlots(walletMaterialLinkingKey(provenMaterial)) await migrateLegacyPasskeySlots(walletMaterialLinkingKey(provenMaterial));
// Then the same slot is stamped once for that proven owner and unlocks // Then the same slot is stamped once for that proven owner and unlocks
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY)) expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY));
expect(readPasskeySlots()).toHaveLength(1) expect(readPasskeySlots()).toHaveLength(1);
expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY)) expect(readPasskeySlots()[0]?.ownerId).toBe(linkingPubKeyHex(LINKING_KEY));
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).resolves.toEqual(MATERIAL) await expect(unlockWalletMaterialWithPasskey({ credentials: auth })).resolves.toEqual(MATERIAL);
}) });
}) });
+99 -99
View File
@@ -4,12 +4,12 @@
// unguessable without the authenticator. Everything except a real // unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here. // authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -23,7 +23,7 @@ import {
unlockWalletMaterialWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
@@ -35,44 +35,44 @@ import {
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
} from './keys' } from './keys';
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import { parseJsonObject, parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const OTHER_MATERIAL = {...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)} const OTHER_MATERIAL = { ...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY) };
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates // Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks // PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never. // found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource // id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>() private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true supportsPrf = true;
prfResultsOnCreate = true prfResultsOnCreate = true;
prfResultsOnGet = true prfResultsOnGet = true;
createCalls = 0 createCalls = 0;
getCalls = 0 getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1 this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16)) const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32)) const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), {id, secret}) this.held.set(bytesToHex(id), { id, secret });
return { return {
type: 'public-key', type: 'public-key',
rawId: id, rawId: id,
@@ -85,19 +85,19 @@ class FakeAuthenticator implements PasskeyCredentials {
} }
: {}, : {},
}), }),
} };
} };
// answers with the first allowed credential it holds, like a real // answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none // authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1 this.getCalls += 1;
const pk = options?.publicKey const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex)) const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined const held = match ? this.held.get(match) : undefined;
if (!held) return null if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first const salt = pk?.extensions?.prf?.eval?.first;
return { return {
type: 'public-key', type: 'public-key',
rawId: held.id, rawId: held.id,
@@ -107,133 +107,133 @@ class FakeAuthenticator implements PasskeyCredentials {
? { enabled: true, results: { first: this.prf(held.secret, salt) } } ? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {}, : {},
}), }),
} };
} };
// simulates the passkey's secret changing underneath a slot (credential // simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind) // re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => { rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId) const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
} };
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => { private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>, // set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects // which BufferSource rejects
const out = new Uint8Array(32) const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt))) out.set(hmac(sha256, secret, toBytes(salt)));
return out return out;
} };
} }
const readRawSlots = (): Array<Record<string, unknown>> => const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => { const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
} };
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}');
delete stored.ownerId delete stored.ownerId;
delete stored.version delete stored.version;
localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) localStorage.setItem('sattle_linking_key', JSON.stringify(stored));
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('slot ownership (continued)', () => { describe('slot ownership (continued)', () => {
it('rejects an unwrapped key that does not match the saved proven owner', async () => { it('rejects an unwrapped key that does not match the saved proven owner', async () => {
// Given a current-owner slot whose authenticated wrap was replaced with // Given a current-owner slot whose authenticated wrap was replaced with
// a valid wrap of another wallet key // a valid wrap of another wallet key
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: auth}) const slot = await registerPasskey(MATERIAL, { credentials: auth });
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
credentials: auth, credentials: auth,
}) });
const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL) const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL);
writeRawSlots([{...slot, ...foreignWrap}]) writeRawSlots([{ ...slot, ...foreignWrap }]);
// When the authenticator successfully unwraps that foreign key // When the authenticator successfully unwraps that foreign key
const attempt = unlockWalletMaterialWithPasskey({credentials: auth}) const attempt = unlockWalletMaterialWithPasskey({ credentials: auth });
// Then owner validation rejects it before activation can receive it // Then owner validation rejects it before activation can receive it
await expect(attempt).rejects.toThrow('different wallet') await expect(attempt).rejects.toThrow('different wallet');
}) });
it('rejects a stale unlock when the saved owner changes during the ceremony', async () => { it('rejects a stale unlock when the saved owner changes during the ceremony', async () => {
// Given an authenticator that replaces the saved wallet before returning // Given an authenticator that replaces the saved wallet before returning
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, { credentials: auth });
const stale: PasskeyCredentials = { const stale: PasskeyCredentials = {
create: auth.create, create: auth.create,
get: async (options) => { get: async (options) => {
const credential = await auth.get(options) const credential = await auth.get(options);
await saveLinkingKey(OTHER_LINKING_KEY) await saveLinkingKey(OTHER_LINKING_KEY);
await saveWalletMaterial(OTHER_MATERIAL) await saveWalletMaterial(OTHER_MATERIAL);
return credential return credential;
}, },
} };
// When the old wallet's ceremony completes after replacement // When the old wallet's ceremony completes after replacement
const attempt = unlockWalletMaterialWithPasskey({credentials: stale}) const attempt = unlockWalletMaterialWithPasskey({ credentials: stale });
// Then the old linking key is never returned for activation // Then the old linking key is never returned for activation
await expect(attempt).rejects.toThrow('different wallet') await expect(attempt).rejects.toThrow('different wallet');
}) });
it('does not adopt a slot carrying a malformed owner marker', async () => { it('does not adopt a slot carrying a malformed owner marker', async () => {
// Given an otherwise valid slot whose owner claim is malformed // Given an otherwise valid slot whose owner claim is malformed
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: auth}) const slot = await registerPasskey(MATERIAL, { credentials: auth });
const malformed = {...slot, ownerId: 'not-an-owner'} const malformed = { ...slot, ownerId: 'not-an-owner' };
writeRawSlots([malformed]) writeRawSlots([malformed]);
// When the current owner performs the legacy migration // When the current owner performs the legacy migration
await migrateLegacyPasskeySlots(LINKING_KEY) await migrateLegacyPasskeySlots(LINKING_KEY);
// Then only truly markerless legacy slots are eligible // Then only truly markerless legacy slots are eligible
expect(readPasskeySlots()).toEqual([]) expect(readPasskeySlots()).toEqual([]);
expect(readRawSlots()).toEqual([malformed]) expect(readRawSlots()).toEqual([malformed]);
}) });
it('cannot remove a foreign-owner slot', async () => { it('cannot remove a foreign-owner slot', async () => {
// Given a slot owned by another wallet remains in shared storage // Given a slot owned by another wallet remains in shared storage
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: auth}) const slot = await registerPasskey(MATERIAL, { credentials: auth });
const foreign = {...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY)} const foreign = { ...slot, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY) };
writeRawSlots([foreign]) writeRawSlots([foreign]);
// When the current owner asks to remove that credential id // When the current owner asks to remove that credential id
const removed = await removePasskey(slot.credentialId) const removed = await removePasskey(slot.credentialId);
// Then the foreign record is untouched // Then the foreign record is untouched
expect(removed).toBe(false) expect(removed).toBe(false);
expect(readRawSlots()).toEqual([foreign]) expect(readRawSlots()).toEqual([foreign]);
}) });
it('rewraps only current-owner slots and preserves foreign slots', async () => { it('rewraps only current-owner slots and preserves foreign slots', async () => {
// Given current and foreign slots share storage // Given current and foreign slots share storage
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: auth}) const slot = await registerPasskey(MATERIAL, { credentials: auth });
const foreign = { const foreign = {
...slot, ...slot,
credentialId: '44'.repeat(16), credentialId: '44'.repeat(16),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
} };
writeRawSlots([slot, foreign]) writeRawSlots([slot, foreign]);
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
credentials: auth, credentials: auth,
}) });
// When the current wallet rewraps its slots // When the current wallet rewraps its slots
await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, prfOutput]])) await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, prfOutput]]));
// Then the foreign slot did not require output and remains byte-identical // Then the foreign slot did not require output and remains byte-identical
expect(readRawSlots()).toContainEqual(foreign) expect(readRawSlots()).toContainEqual(foreign);
}) });
}) });
+117 -117
View File
@@ -4,12 +4,12 @@
// unguessable without the authenticator. Everything except a real // unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here. // authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -23,7 +23,7 @@ import {
unlockWalletMaterialWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import { import {
decryptRecord, decryptRecord,
decryptSavedWalletMaterial, decryptSavedWalletMaterial,
@@ -36,43 +36,43 @@ import {
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
walletMaterialLinkingKey, walletMaterialLinkingKey,
} from './keys' } from './keys';
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import { parseJsonObject, parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates // Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks // PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never. // found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource // id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>() private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true supportsPrf = true;
prfResultsOnCreate = true prfResultsOnCreate = true;
prfResultsOnGet = true prfResultsOnGet = true;
createCalls = 0 createCalls = 0;
getCalls = 0 getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1 this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16)) const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32)) const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), {id, secret}) this.held.set(bytesToHex(id), { id, secret });
return { return {
type: 'public-key', type: 'public-key',
rawId: id, rawId: id,
@@ -85,19 +85,19 @@ class FakeAuthenticator implements PasskeyCredentials {
} }
: {}, : {},
}), }),
} };
} };
// answers with the first allowed credential it holds, like a real // answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none // authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1 this.getCalls += 1;
const pk = options?.publicKey const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex)) const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined const held = match ? this.held.get(match) : undefined;
if (!held) return null if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first const salt = pk?.extensions?.prf?.eval?.first;
return { return {
type: 'public-key', type: 'public-key',
rawId: held.id, rawId: held.id,
@@ -107,135 +107,135 @@ class FakeAuthenticator implements PasskeyCredentials {
? { enabled: true, results: { first: this.prf(held.secret, salt) } } ? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {}, : {},
}), }),
} };
} };
// simulates the passkey's secret changing underneath a slot (credential // simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind) // re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => { rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId) const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
} };
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => { private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>, // set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects // which BufferSource rejects
const out = new Uint8Array(32) const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt))) out.set(hmac(sha256, secret, toBytes(salt)));
return out return out;
} };
} }
const readRawSlots = (): Array<Record<string, unknown>> => const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => { const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
} };
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}');
delete stored.ownerId delete stored.ownerId;
delete stored.version delete stored.version;
localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) localStorage.setItem('sattle_linking_key', JSON.stringify(stored));
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('registration and unlock', () => { describe('registration and unlock', () => {
it('registers a passkey and unlocks the same complete material', async () => { it('registers a passkey and unlocks the same complete material', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, { const slot = await registerPasskey(MATERIAL, {
credentials: auth, credentials: auth,
name: 'laptop', name: 'laptop',
}) });
expect(slot.name).toBe('laptop') expect(slot.name).toBe('laptop');
expect(readPasskeySlots()).toEqual([slot]) expect(readPasskeySlots()).toEqual([slot]);
expect(hasPasskeySlots()).toBe(true) expect(hasPasskeySlots()).toBe(true);
const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth}) const unwrapped = await unlockWalletMaterialWithPasskey({ credentials: auth });
expect(unwrapped).toEqual(MATERIAL) expect(unwrapped).toEqual(MATERIAL);
}) });
it('never stores the linking key in the clear', async () => { it('never stores the linking key in the clear', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, { credentials: auth });
const raw = localStorage.getItem('sattle_passkey_slots') const raw = localStorage.getItem('sattle_passkey_slots');
expect(raw).toBeTruthy() expect(raw).toBeTruthy();
expect(raw).not.toContain(bytesToHex(LINKING_KEY)) expect(raw).not.toContain(bytesToHex(LINKING_KEY));
}) });
it('yields the same wallet material unlock(password) yields', async () => { it('yields the same wallet material unlock(password) yields', async () => {
await saveWalletMaterial(MATERIAL, 'correct horse') await saveWalletMaterial(MATERIAL, 'correct horse');
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, { credentials: auth });
const viaPassword = await decryptSavedWalletMaterial('correct horse') const viaPassword = await decryptSavedWalletMaterial('correct horse');
const viaPasskey = await unlockWalletMaterialWithPasskey({credentials: auth}) const viaPasskey = await unlockWalletMaterialWithPasskey({ credentials: auth });
expect(viaPasskey).toEqual(viaPassword) expect(viaPasskey).toEqual(viaPassword);
// and the practical consequence: a bearer record encrypted after a // and the practical consequence: a bearer record encrypted after a
// password unlock decrypts after a passkey unlock // password unlock decrypts after a passkey unlock
const passwordAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPassword)) const passwordAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPassword));
const record = await encryptRecord(passwordAes, {note: 'still readable'}) const record = await encryptRecord(passwordAes, { note: 'still readable' });
const passkeyAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPasskey)) const passkeyAes = await deriveBearerAesKey(walletMaterialLinkingKey(viaPasskey));
await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({ await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
note: 'still readable', note: 'still readable',
}) });
}) });
it('falls back to a get ceremony when create only reports prf.enabled', async () => { it('falls back to a get ceremony when create only reports prf.enabled', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
auth.prfResultsOnCreate = false auth.prfResultsOnCreate = false;
const slot = await registerPasskey(MATERIAL, {credentials: auth}) const slot = await registerPasskey(MATERIAL, { credentials: auth });
expect(auth.getCalls).toBe(1) expect(auth.getCalls).toBe(1);
const unwrapped = await unlockWalletMaterialWithPasskey({credentials: auth}) const unwrapped = await unlockWalletMaterialWithPasskey({ credentials: auth });
expect(unwrapped).toEqual(MATERIAL) expect(unwrapped).toEqual(MATERIAL);
expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId) expect(readPasskeySlots()[0]?.credentialId).toBe(slot.credentialId);
}) });
it('refuses registration when the authenticator has no PRF support', async () => { it('refuses registration when the authenticator has no PRF support', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
auth.supportsPrf = false auth.supportsPrf = false;
await expect(registerPasskey(MATERIAL, {credentials: auth})).rejects.toThrow('PRF') await expect(registerPasskey(MATERIAL, { credentials: auth })).rejects.toThrow('PRF');
expect(hasPasskeySlots()).toBe(false) expect(hasPasskeySlots()).toBe(false);
}) });
it('throws on a cancelled registration ceremony', async () => { it('throws on a cancelled registration ceremony', async () => {
const cancelled: PasskeyCredentials = { const cancelled: PasskeyCredentials = {
create: async () => null, create: async () => null,
get: async () => null, get: async () => null,
} };
await expect(registerPasskey(MATERIAL, { credentials: cancelled })).rejects.toThrow( await expect(registerPasskey(MATERIAL, { credentials: cancelled })).rejects.toThrow(
'cancelled', 'cancelled',
) );
expect(hasPasskeySlots()).toBe(false) expect(hasPasskeySlots()).toBe(false);
}) });
it('throws before any ceremony when no passkeys are registered', async () => { it('throws before any ceremony when no passkeys are registered', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await expect(unlockWalletMaterialWithPasskey({ credentials: auth })).rejects.toThrow( await expect(unlockWalletMaterialWithPasskey({ credentials: auth })).rejects.toThrow(
'No passkeys', 'No passkeys',
) );
expect(auth.getCalls).toBe(0) expect(auth.getCalls).toBe(0);
}) });
it('rejects unlock when the passkey returns no PRF secret', async () => { it('rejects unlock when the passkey returns no PRF secret', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, { credentials: auth });
auth.prfResultsOnGet = false auth.prfResultsOnGet = false;
await expect(unlockWalletMaterialWithPasskey({ credentials: auth })).rejects.toThrow( await expect(unlockWalletMaterialWithPasskey({ credentials: auth })).rejects.toThrow(
'PRF secret', 'PRF secret',
) );
}) });
it('rejects unlock when the ceremony yields an unregistered credential', async () => { it('rejects unlock when the ceremony yields an unregistered credential', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
await registerPasskey(MATERIAL, {credentials: auth}) await registerPasskey(MATERIAL, { credentials: auth });
const rogue: PasskeyCredentials = { const rogue: PasskeyCredentials = {
create: async () => null, create: async () => null,
get: async () => ({ get: async () => ({
@@ -245,16 +245,16 @@ describe('registration and unlock', () => {
prf: { enabled: true, results: { first: new Uint8Array(32) } }, prf: { enabled: true, results: { first: new Uint8Array(32) } },
}), }),
}), }),
} };
await expect(unlockWalletMaterialWithPasskey({ credentials: rogue })).rejects.toThrow( await expect(unlockWalletMaterialWithPasskey({ credentials: rogue })).rejects.toThrow(
'not registered', 'not registered',
) );
}) });
it('rejects unlock after the authenticator secret changed underneath the slot', async () => { it('rejects unlock after the authenticator secret changed underneath the slot', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: auth}) const slot = await registerPasskey(MATERIAL, { credentials: auth });
auth.rotateSecret(slot.credentialId) auth.rotateSecret(slot.credentialId);
await expect(unlockWalletMaterialWithPasskey({credentials: auth})).rejects.toThrow() await expect(unlockWalletMaterialWithPasskey({ credentials: auth })).rejects.toThrow();
}) });
}) });
+93 -88
View File
@@ -1,156 +1,161 @@
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {deriveWalletMaterial, saveWalletMaterial} from './keys' import { deriveWalletMaterial, saveWalletMaterial } from './keys';
import {registerPasskey, rewrapAllSlots, unlockWalletMaterialWithPasskey} from './passkeys' import { registerPasskey, rewrapAllSlots, unlockWalletMaterialWithPasskey } from './passkeys';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import {stubLocalStorage} from './test-utils' import { stubLocalStorage } from './test-utils';
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const MNEMONIC =
const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow' 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const MATERIAL = deriveWalletMaterial(MNEMONIC) const OTHER_MNEMONIC =
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC) 'legal winner thank year wave sausage worth useful legal winner thank yellow';
const MATERIAL = deriveWalletMaterial(MNEMONIC);
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC);
// same linking key (same owner) as MATERIAL, but a different, VALID cash root // same linking key (same owner) as MATERIAL, but a different, VALID cash root
const ALTERED_ROOT = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex} const ALTERED_ROOT = { ...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex };
const SLOT_STORAGE_KEY = 'sattle_passkey_slots' const SLOT_STORAGE_KEY = 'sattle_passkey_slots';
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2' const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2';
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
private readonly credentialId = new Uint8Array(16).fill(12) private readonly credentialId = new Uint8Array(16).fill(12);
private readonly secret = new Uint8Array(32).fill(13) private readonly secret = new Uint8Array(32).fill(13);
onCreate?: () => Promise<void> onCreate?: () => Promise<void>;
onGet?: () => Promise<void> onGet?: () => Promise<void>;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential> => {
await this.onCreate?.() await this.onCreate?.();
return this.credential(options?.publicKey?.extensions?.prf?.eval?.first) return this.credential(options?.publicKey?.extensions?.prf?.eval?.first);
} };
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
await this.onGet?.() await this.onGet?.();
const allowed = options?.publicKey?.allowCredentials ?? [] const allowed = options?.publicKey?.allowCredentials ?? [];
return allowed.some( return allowed.some(
(descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId), (descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId),
) )
? this.credential(options?.publicKey?.extensions?.prf?.eval?.first) ? this.credential(options?.publicKey?.extensions?.prf?.eval?.first)
: null : null;
} };
private credential = (salt: BufferSource | undefined): CeremonyCredential => ({ private credential = (salt: BufferSource | undefined): CeremonyCredential => ({
type: 'public-key', type: 'public-key',
rawId: this.credentialId, rawId: this.credentialId,
getClientExtensionResults: () => ({ getClientExtensionResults: () => ({
prf: salt prf: salt
? {enabled: true, results: {first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt)))}} ? {
enabled: true,
results: { first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt))) },
}
: {}, : {},
}), }),
}) });
} }
const storageState = (): { slots: string | null; material: string | null } => ({ const storageState = (): { slots: string | null; material: string | null } => ({
slots: localStorage.getItem(SLOT_STORAGE_KEY), slots: localStorage.getItem(SLOT_STORAGE_KEY),
material: localStorage.getItem(MATERIAL_STORAGE_KEY), material: localStorage.getItem(MATERIAL_STORAGE_KEY),
}) });
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
describe('passkey exact-material commitment re-verification', () => { describe('passkey exact-material commitment re-verification', () => {
it('rejects same-owner altered-root registration with byte-identical storage', async () => { it('rejects same-owner altered-root registration with byte-identical storage', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
const before = storageState() const before = storageState();
await expect(registerPasskey(ALTERED_ROOT, { credentials: authenticator })).rejects.toThrow( await expect(registerPasskey(ALTERED_ROOT, { credentials: authenticator })).rejects.toThrow(
'saved wallet material', 'saved wallet material',
) );
expect(storageState()).toEqual(before) expect(storageState()).toEqual(before);
}) });
it('rejects same-owner altered-root registration against encrypted saved material', async () => { it('rejects same-owner altered-root registration against encrypted saved material', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await saveWalletMaterial(MATERIAL, 'password') await saveWalletMaterial(MATERIAL, 'password');
const before = storageState() const before = storageState();
await expect(registerPasskey(ALTERED_ROOT, { credentials: authenticator })).rejects.toThrow( await expect(registerPasskey(ALTERED_ROOT, { credentials: authenticator })).rejects.toThrow(
'saved wallet material', 'saved wallet material',
) );
expect(storageState()).toEqual(before) expect(storageState()).toEqual(before);
}) });
it('writes nothing when same-owner saved material changes during registration', async () => { it('writes nothing when same-owner saved material changes during registration', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
let swapped: {slots: string | null; material: string | null} | null = null let swapped: { slots: string | null; material: string | null } | null = null;
authenticator.onCreate = async () => { authenticator.onCreate = async () => {
await saveWalletMaterial(ALTERED_ROOT) await saveWalletMaterial(ALTERED_ROOT);
swapped = storageState() swapped = storageState();
} };
await expect(registerPasskey(MATERIAL, { credentials: authenticator })).rejects.toThrow( await expect(registerPasskey(MATERIAL, { credentials: authenticator })).rejects.toThrow(
'saved wallet material', 'saved wallet material',
) );
expect(swapped).not.toBeNull() expect(swapped).not.toBeNull();
expect(storageState()).toEqual(swapped) expect(storageState()).toEqual(swapped);
}) });
it('rejects unlock after same-owner saved replacement with byte-identical storage', async () => { it('rejects unlock after same-owner saved replacement with byte-identical storage', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
await registerPasskey(MATERIAL, {credentials: authenticator}) await registerPasskey(MATERIAL, { credentials: authenticator });
await saveWalletMaterial(ALTERED_ROOT, 'password') await saveWalletMaterial(ALTERED_ROOT, 'password');
const before = storageState() const before = storageState();
await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow() await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).rejects.toThrow();
expect(storageState()).toEqual(before) expect(storageState()).toEqual(before);
}) });
it('writes nothing when same-owner saved material changes during the unlock ceremony', async () => { it('writes nothing when same-owner saved material changes during the unlock ceremony', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
await registerPasskey(MATERIAL, {credentials: authenticator}) await registerPasskey(MATERIAL, { credentials: authenticator });
let swapped: {slots: string | null; material: string | null} | null = null let swapped: { slots: string | null; material: string | null } | null = null;
authenticator.onGet = async () => { authenticator.onGet = async () => {
await saveWalletMaterial(ALTERED_ROOT) await saveWalletMaterial(ALTERED_ROOT);
swapped = storageState() swapped = storageState();
} };
await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).rejects.toThrow( await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).rejects.toThrow(
'changed', 'changed',
) );
expect(swapped).not.toBeNull() expect(swapped).not.toBeNull();
expect(storageState()).toEqual(swapped) expect(storageState()).toEqual(swapped);
}) });
it('rejects a same-owner altered-root re-wrap before changing any bytes', async () => { it('rejects a same-owner altered-root re-wrap before changing any bytes', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
const slot = await registerPasskey(MATERIAL, {credentials: authenticator}) const slot = await registerPasskey(MATERIAL, { credentials: authenticator });
const before = storageState() const before = storageState();
await expect( await expect(
rewrapAllSlots(ALTERED_ROOT, new Map([[slot.credentialId, new Uint8Array(32).fill(13)]])), rewrapAllSlots(ALTERED_ROOT, new Map([[slot.credentialId, new Uint8Array(32).fill(13)]])),
).rejects.toThrow('saved wallet material') ).rejects.toThrow('saved wallet material');
expect(storageState()).toEqual(before) expect(storageState()).toEqual(before);
}) });
it('returns the exact saved material on a committed unlock', async () => { it('returns the exact saved material on a committed unlock', async () => {
const authenticator = new FakeAuthenticator() const authenticator = new FakeAuthenticator();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
await registerPasskey(MATERIAL, {credentials: authenticator}) await registerPasskey(MATERIAL, { credentials: authenticator });
const before = storageState() const before = storageState();
await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).resolves.toEqual( await expect(unlockWalletMaterialWithPasskey({ credentials: authenticator })).resolves.toEqual(
MATERIAL, MATERIAL,
) );
expect(storageState()).toEqual(before) expect(storageState()).toEqual(before);
}) });
}) });
+70 -70
View File
@@ -4,12 +4,12 @@
// unguessable without the authenticator. Everything except a real // unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here. // authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -23,7 +23,7 @@ import {
unlockWalletMaterialWithPasskey, unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
@@ -35,43 +35,43 @@ import {
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
} from './keys' } from './keys';
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import { parseJsonObject, parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates // Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks // PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never. // found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource // id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>() private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true supportsPrf = true;
prfResultsOnCreate = true prfResultsOnCreate = true;
prfResultsOnGet = true prfResultsOnGet = true;
createCalls = 0 createCalls = 0;
getCalls = 0 getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1 this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16)) const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32)) const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), {id, secret}) this.held.set(bytesToHex(id), { id, secret });
return { return {
type: 'public-key', type: 'public-key',
rawId: id, rawId: id,
@@ -84,19 +84,19 @@ class FakeAuthenticator implements PasskeyCredentials {
} }
: {}, : {},
}), }),
} };
} };
// answers with the first allowed credential it holds, like a real // answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none // authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1 this.getCalls += 1;
const pk = options?.publicKey const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex)) const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined const held = match ? this.held.get(match) : undefined;
if (!held) return null if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first const salt = pk?.extensions?.prf?.eval?.first;
return { return {
type: 'public-key', type: 'public-key',
rawId: held.id, rawId: held.id,
@@ -106,55 +106,55 @@ class FakeAuthenticator implements PasskeyCredentials {
? { enabled: true, results: { first: this.prf(held.secret, salt) } } ? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {}, : {},
}), }),
} };
} };
// simulates the passkey's secret changing underneath a slot (credential // simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind) // re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => { rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId) const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
} };
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => { private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>, // set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects // which BufferSource rejects
const out = new Uint8Array(32) const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt))) out.set(hmac(sha256, secret, toBytes(salt)));
return out return out;
} };
} }
const readRawSlots = (): Array<Record<string, unknown>> => const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => { const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
} };
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}');
delete stored.ownerId delete stored.ownerId;
delete stored.version delete stored.version;
localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) localStorage.setItem('sattle_linking_key', JSON.stringify(stored));
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('rewrap for the current owner', () => { describe('rewrap for the current owner', () => {
it('refreshes every current-owner wrap all-or-nothing', async () => { it('refreshes every current-owner wrap all-or-nothing', async () => {
const laptop = new FakeAuthenticator() const laptop = new FakeAuthenticator();
const phone = new FakeAuthenticator() const phone = new FakeAuthenticator();
const laptopSlot = await registerPasskey(MATERIAL, { const laptopSlot = await registerPasskey(MATERIAL, {
credentials: laptop, credentials: laptop,
}) });
const phoneSlot = await registerPasskey(MATERIAL, { const phoneSlot = await registerPasskey(MATERIAL, {
credentials: phone, credentials: phone,
}) });
// partial coverage aborts before writing // partial coverage aborts before writing
const partial = new Map([ const partial = new Map([
@@ -164,9 +164,9 @@ describe('rewrap for the current owner', () => {
credentials: laptop, credentials: laptop,
}), }),
], ],
]) ]);
await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap') await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap');
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL) expect(await unlockWalletMaterialWithPasskey({ credentials: laptop })).toEqual(MATERIAL);
// full coverage refreshes both wraps around the same proven owner key // full coverage refreshes both wraps around the same proven owner key
const fresh = new Map([ const fresh = new Map([
@@ -182,16 +182,16 @@ describe('rewrap for the current owner', () => {
credentials: phone, credentials: phone,
}), }),
], ],
]) ]);
await rewrapAllSlots(MATERIAL, fresh) await rewrapAllSlots(MATERIAL, fresh);
expect(await unlockWalletMaterialWithPasskey({credentials: laptop})).toEqual(MATERIAL) expect(await unlockWalletMaterialWithPasskey({ credentials: laptop })).toEqual(MATERIAL);
expect(await unlockWalletMaterialWithPasskey({credentials: phone})).toEqual(MATERIAL) expect(await unlockWalletMaterialWithPasskey({ credentials: phone })).toEqual(MATERIAL);
expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial) expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial);
// credential ids and labels survive the re-wrap // credential ids and labels survive the re-wrap
expect( expect(
readPasskeySlots() readPasskeySlots()
.map((s) => s.credentialId) .map((s) => s.credentialId)
.sort(), .sort(),
).toEqual([laptopSlot.credentialId, phoneSlot.credentialId].sort()) ).toEqual([laptopSlot.credentialId, phoneSlot.credentialId].sort());
}) });
}) });
+71 -66
View File
@@ -4,12 +4,12 @@
// unguessable without the authenticator. Everything except a real // unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here. // authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -22,7 +22,7 @@ import {
rewrapAllSlots, rewrapAllSlots,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
@@ -34,43 +34,48 @@ import {
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
} from './keys' } from './keys';
import {parseJsonArray, parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import {
parseJsonArray,
parseJsonObject,
parseJsonObjectArray,
stubLocalStorage,
} from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates // Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks // PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never. // found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource // id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>() private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true supportsPrf = true;
prfResultsOnCreate = true prfResultsOnCreate = true;
prfResultsOnGet = true prfResultsOnGet = true;
createCalls = 0 createCalls = 0;
getCalls = 0 getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1 this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16)) const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32)) const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), {id, secret}) this.held.set(bytesToHex(id), { id, secret });
return { return {
type: 'public-key', type: 'public-key',
rawId: id, rawId: id,
@@ -83,19 +88,19 @@ class FakeAuthenticator implements PasskeyCredentials {
} }
: {}, : {},
}), }),
} };
} };
// answers with the first allowed credential it holds, like a real // answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none // authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1 this.getCalls += 1;
const pk = options?.publicKey const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex)) const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined const held = match ? this.held.get(match) : undefined;
if (!held) return null if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first const salt = pk?.extensions?.prf?.eval?.first;
return { return {
type: 'public-key', type: 'public-key',
rawId: held.id, rawId: held.id,
@@ -105,60 +110,60 @@ class FakeAuthenticator implements PasskeyCredentials {
? { enabled: true, results: { first: this.prf(held.secret, salt) } } ? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {}, : {},
}), }),
} };
} };
// simulates the passkey's secret changing underneath a slot (credential // simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind) // re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => { rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId) const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
} };
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => { private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>, // set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects // which BufferSource rejects
const out = new Uint8Array(32) const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt))) out.set(hmac(sha256, secret, toBytes(salt)));
return out return out;
} };
} }
const readRawSlots = (): Array<Record<string, unknown>> => const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => { const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
} };
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}');
delete stored.ownerId delete stored.ownerId;
delete stored.version delete stored.version;
localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) localStorage.setItem('sattle_linking_key', JSON.stringify(stored));
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('slot storage hygiene', () => { describe('slot storage hygiene', () => {
it('drops malformed entries instead of throwing', async () => { it('drops malformed entries instead of throwing', async () => {
const auth = new FakeAuthenticator() const auth = new FakeAuthenticator();
const slot = await registerPasskey(MATERIAL, {credentials: auth}) const slot = await registerPasskey(MATERIAL, { credentials: auth });
const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') const stored = parseJsonArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
localStorage.setItem( localStorage.setItem(
'sattle_passkey_slots', 'sattle_passkey_slots',
JSON.stringify([...stored, { credentialId: 'zz', hkdfSalt: 1 }, 'garbage', null]), JSON.stringify([...stored, { credentialId: 'zz', hkdfSalt: 1 }, 'garbage', null]),
) );
expect(readPasskeySlots()).toEqual([slot]) expect(readPasskeySlots()).toEqual([slot]);
}) });
it('treats unparseable storage as empty', () => { it('treats unparseable storage as empty', () => {
localStorage.setItem('sattle_passkey_slots', '{not json') localStorage.setItem('sattle_passkey_slots', '{not json');
expect(readPasskeySlots()).toEqual([]) expect(readPasskeySlots()).toEqual([]);
expect(hasPasskeySlots()).toBe(false) expect(hasPasskeySlots()).toBe(false);
}) });
}) });
+66 -66
View File
@@ -4,12 +4,12 @@
// unguessable without the authenticator. Everything except a real // unguessable without the authenticator. Everything except a real
// authenticator's touch is covered here. // authenticator's touch is covered here.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {hmac} from '@noble/hashes/hmac.js' import { hmac } from '@noble/hashes/hmac.js';
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import type {CeremonyCredential, PasskeyCredentials} from './passkeys' import type { CeremonyCredential, PasskeyCredentials } from './passkeys';
import { import {
derivePasskeyWrapKey, derivePasskeyWrapKey,
getPasskeyPrfOutput, getPasskeyPrfOutput,
@@ -22,7 +22,7 @@ import {
rewrapAllSlots, rewrapAllSlots,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys';
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
@@ -34,43 +34,43 @@ import {
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial, saveWalletMaterial,
} from './keys' } from './keys';
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' import { parseJsonObject, parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3);
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4);
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// Fake platform authenticator: holds credentials (id -> secret), evaluates // Fake platform authenticator: holds credentials (id -> secret), evaluates
// PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks // PRF as HMAC-SHA256(secret, salt). Flags emulate the authenticator quirks
// found in the wild: PRF unsupported, results only on get, results never. // found in the wild: PRF unsupported, results only on get, results never.
class FakeAuthenticator implements PasskeyCredentials { class FakeAuthenticator implements PasskeyCredentials {
// id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource // id typed Uint8Array<ArrayBuffer>: rawId must satisfy BufferSource
private held = new Map<string, {id: Uint8Array<ArrayBuffer>; secret: Uint8Array}>() private held = new Map<string, { id: Uint8Array<ArrayBuffer>; secret: Uint8Array }>();
supportsPrf = true supportsPrf = true;
prfResultsOnCreate = true prfResultsOnCreate = true;
prfResultsOnGet = true prfResultsOnGet = true;
createCalls = 0 createCalls = 0;
getCalls = 0 getCalls = 0;
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => { create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
this.createCalls += 1 this.createCalls += 1;
const salt = options?.publicKey?.extensions?.prf?.eval?.first const salt = options?.publicKey?.extensions?.prf?.eval?.first;
const id = crypto.getRandomValues(new Uint8Array(16)) const id = crypto.getRandomValues(new Uint8Array(16));
const secret = crypto.getRandomValues(new Uint8Array(32)) const secret = crypto.getRandomValues(new Uint8Array(32));
this.held.set(bytesToHex(id), {id, secret}) this.held.set(bytesToHex(id), { id, secret });
return { return {
type: 'public-key', type: 'public-key',
rawId: id, rawId: id,
@@ -83,19 +83,19 @@ class FakeAuthenticator implements PasskeyCredentials {
} }
: {}, : {},
}), }),
} };
} };
// answers with the first allowed credential it holds, like a real // answers with the first allowed credential it holds, like a real
// authenticator picking among allowCredentials; null when it holds none // authenticator picking among allowCredentials; null when it holds none
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => { get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
this.getCalls += 1 this.getCalls += 1;
const pk = options?.publicKey const pk = options?.publicKey;
const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id))) const allowed = (pk?.allowCredentials ?? []).map((d) => bytesToHex(toBytes(d.id)));
const match = allowed.find((hex) => this.held.has(hex)) const match = allowed.find((hex) => this.held.has(hex));
const held = match ? this.held.get(match) : undefined const held = match ? this.held.get(match) : undefined;
if (!held) return null if (!held) return null;
const salt = pk?.extensions?.prf?.eval?.first const salt = pk?.extensions?.prf?.eval?.first;
return { return {
type: 'public-key', type: 'public-key',
rawId: held.id, rawId: held.id,
@@ -105,51 +105,51 @@ class FakeAuthenticator implements PasskeyCredentials {
? { enabled: true, results: { first: this.prf(held.secret, salt) } } ? { enabled: true, results: { first: this.prf(held.secret, salt) } }
: {}, : {},
}), }),
} };
} };
// simulates the passkey's secret changing underneath a slot (credential // simulates the passkey's secret changing underneath a slot (credential
// re-created on the authenticator while the slot stayed behind) // re-created on the authenticator while the slot stayed behind)
rotateSecret = (credentialId: string): void => { rotateSecret = (credentialId: string): void => {
const held = this.held.get(credentialId) const held = this.held.get(credentialId);
if (held) held.secret = crypto.getRandomValues(new Uint8Array(32)) if (held) held.secret = crypto.getRandomValues(new Uint8Array(32));
} };
private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => { private prf = (secret: Uint8Array, salt: BufferSource): Uint8Array<ArrayBuffer> => {
// set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>, // set into a fresh array: hmac returns Uint8Array<ArrayBufferLike>,
// which BufferSource rejects // which BufferSource rejects
const out = new Uint8Array(32) const out = new Uint8Array(32);
out.set(hmac(sha256, secret, toBytes(salt))) out.set(hmac(sha256, secret, toBytes(salt)));
return out return out;
} };
} }
const readRawSlots = (): Array<Record<string, unknown>> => const readRawSlots = (): Array<Record<string, unknown>> =>
parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]') parseJsonObjectArray(localStorage.getItem('sattle_passkey_slots') ?? '[]');
const writeRawSlots = (slots: Array<Record<string, unknown>>): void => { const writeRawSlots = (slots: Array<Record<string, unknown>>): void => {
localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots)) localStorage.setItem('sattle_passkey_slots', JSON.stringify(slots));
} };
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}') const stored = parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}');
delete stored.ownerId delete stored.ownerId;
delete stored.version delete stored.version;
localStorage.setItem('sattle_linking_key', JSON.stringify(stored)) localStorage.setItem('sattle_linking_key', JSON.stringify(stored));
} };
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY);
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
describe('passkeySupported', () => { describe('passkeySupported', () => {
it('is false without a PublicKeyCredential probe', async () => { it('is false without a PublicKeyCredential probe', async () => {
// node test env has no PublicKeyCredential global: the default lookup // node test env has no PublicKeyCredential global: the default lookup
// finds nothing // finds nothing
await expect(passkeySupported()).resolves.toBe(false) await expect(passkeySupported()).resolves.toBe(false);
}) });
it('is false without a user-verifying platform authenticator', async () => { it('is false without a user-verifying platform authenticator', async () => {
await expect( await expect(
@@ -157,32 +157,32 @@ describe('passkeySupported', () => {
isUserVerifyingPlatformAuthenticatorAvailable: async () => false, isUserVerifyingPlatformAuthenticatorAvailable: async () => false,
getClientCapabilities: async () => ({ 'extension:prf': true }), getClientCapabilities: async () => ({ 'extension:prf': true }),
}), }),
).resolves.toBe(false) ).resolves.toBe(false);
}) });
it('checks extension:prf when client capabilities are available', async () => { it('checks extension:prf when client capabilities are available', async () => {
const platform = { const platform = {
isUserVerifyingPlatformAuthenticatorAvailable: async () => true, isUserVerifyingPlatformAuthenticatorAvailable: async () => true,
} };
await expect( await expect(
passkeySupported({ passkeySupported({
...platform, ...platform,
getClientCapabilities: async () => ({ 'extension:prf': true }), getClientCapabilities: async () => ({ 'extension:prf': true }),
}), }),
).resolves.toBe(true) ).resolves.toBe(true);
await expect( await expect(
passkeySupported({ passkeySupported({
...platform, ...platform,
getClientCapabilities: async () => ({ 'extension:prf': false }), getClientCapabilities: async () => ({ 'extension:prf': false }),
}), }),
).resolves.toBe(false) ).resolves.toBe(false);
}) });
it('is optimistic when capabilities cannot be pre-detected', async () => { it('is optimistic when capabilities cannot be pre-detected', async () => {
await expect( await expect(
passkeySupported({ passkeySupported({
isUserVerifyingPlatformAuthenticatorAvailable: async () => true, isUserVerifyingPlatformAuthenticatorAvailable: async () => true,
}), }),
).resolves.toBe(true) ).resolves.toBe(true);
}) });
}) });
+12 -12
View File
@@ -1,12 +1,12 @@
import './passkeys.crypto.cases' import './passkeys.crypto.cases';
import './passkeys.commitment.cases' import './passkeys.commitment.cases';
import './passkeys.reverify.cases' import './passkeys.reverify.cases';
import './passkeys.material.cases' import './passkeys.material.cases';
import './passkeys.registration.cases' import './passkeys.registration.cases';
import './passkeys.ownership-a.cases' import './passkeys.ownership-a.cases';
import './passkeys.ownership-b.cases' import './passkeys.ownership-b.cases';
import './passkeys.multiple.cases' import './passkeys.multiple.cases';
import './passkeys.rewrap.cases' import './passkeys.rewrap.cases';
import './passkeys.storage.cases' import './passkeys.storage.cases';
import './passkeys.support.cases' import './passkeys.support.cases';
import './passkeys.version.cases' import './passkeys.version.cases';
+105 -103
View File
@@ -21,11 +21,11 @@
// an independent, unguessable wrap secret. A per-slot random HKDF salt then // an independent, unguessable wrap secret. A per-slot random HKDF salt then
// separates the actual wrap keys. // separates the actual wrap keys.
import {sha256} from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js';
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import {withStorageLock} from './storageLock' import { withStorageLock } from './storageLock';
import type {PasskeySlot} from './storage/passkeySlots' import type { PasskeySlot } from './storage/passkeySlots';
import { import {
PASSKEY_SLOTS_STORAGE_KEY, PASSKEY_SLOTS_STORAGE_KEY,
PASSKEY_SLOT_VERSION, PASSKEY_SLOT_VERSION,
@@ -33,67 +33,67 @@ import {
readPasskeySlots, readPasskeySlots,
requireCurrentPasskeyMaterialOwner, requireCurrentPasskeyMaterialOwner,
writePasskeySlots, writePasskeySlots,
} from './storage/passkeySlots' } from './storage/passkeySlots';
import type {WalletMaterialV2} from './storage/storedSecret' import type { WalletMaterialV2 } from './storage/storedSecret';
import {walletMaterialHash, walletMaterialOwnerId} from './storage/storedSecret' import { walletMaterialHash, walletMaterialOwnerId } from './storage/storedSecret';
import {savedWalletMaterialHash, savedWalletMaterialOwnerId} from './walletMaterialStorage' import { savedWalletMaterialHash, savedWalletMaterialOwnerId } from './walletMaterialStorage';
import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap' import { unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf } from './passkeyWrap';
export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots' export type { PasskeySlot, PasskeyWrap } from './storage/passkeySlots';
export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots' export { readPasskeySlots, hasPasskeySlots } from './storage/passkeySlots';
export {migrateLegacyPasskeySlots} from './passkeyOwnership' export { migrateLegacyPasskeySlots } from './passkeyOwnership';
export {rewrapAllSlots} from './passkeyRewrap' export { rewrapAllSlots } from './passkeyRewrap';
export { export {
derivePasskeyWrapKey, derivePasskeyWrapKey,
InvalidPasskeyMaterialError, InvalidPasskeyMaterialError,
wrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf,
unwrapWalletMaterialWithPrf, unwrapWalletMaterialWithPrf,
} from './passkeyWrap' } from './passkeyWrap';
// 32 bytes, fixed - the authenticator requires exactly 32 // 32 bytes, fixed - the authenticator requires exactly 32
const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1')) const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1'));
// ---- WebAuthn glue (browser-only; credentials container injected) ---- // ---- WebAuthn glue (browser-only; credentials container injected) ----
// the structural slice of a PublicKeyCredential the engine consumes - a // the structural slice of a PublicKeyCredential the engine consumes - a
// fake authenticator in tests implements exactly this // fake authenticator in tests implements exactly this
export type CeremonyCredential = { export type CeremonyCredential = {
type: string type: string;
rawId: BufferSource rawId: BufferSource;
getClientExtensionResults(): AuthenticationExtensionsClientOutputs getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
} };
// the slice of navigator.credentials the ceremonies need // the slice of navigator.credentials the ceremonies need
export type PasskeyCredentials = { export type PasskeyCredentials = {
create(options?: CredentialCreationOptions): Promise<CeremonyCredential | null> create(options?: CredentialCreationOptions): Promise<CeremonyCredential | null>;
get(options?: CredentialRequestOptions): Promise<CeremonyCredential | null> get(options?: CredentialRequestOptions): Promise<CeremonyCredential | null>;
} };
export type PasskeySupportProbe = { export type PasskeySupportProbe = {
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>;
getClientCapabilities?(): Promise<Record<string, boolean>> getClientCapabilities?(): Promise<Record<string, boolean>>;
} };
// the one runtime narrow at the browser boundary: navigator.credentials // the one runtime narrow at the browser boundary: navigator.credentials
// resolves to the Credential supertype, but a publicKey ceremony always // resolves to the Credential supertype, but a publicKey ceremony always
// produces a PublicKeyCredential // produces a PublicKeyCredential
const asCeremonyCredential = (credential: Credential | null): CeremonyCredential | null => { const asCeremonyCredential = (credential: Credential | null): CeremonyCredential | null => {
if (typeof PublicKeyCredential === 'undefined' || !(credential instanceof PublicKeyCredential)) { if (typeof PublicKeyCredential === 'undefined' || !(credential instanceof PublicKeyCredential)) {
return null return null;
}
return credential
} }
return credential;
};
const defaultCredentials = (): PasskeyCredentials => { const defaultCredentials = (): PasskeyCredentials => {
if (typeof navigator === 'undefined' || !navigator.credentials) { if (typeof navigator === 'undefined' || !navigator.credentials) {
throw new Error('WebAuthn is not available in this environment.') throw new Error('WebAuthn is not available in this environment.');
} }
const container = navigator.credentials const container = navigator.credentials;
return { return {
create: (options) => container.create(options).then(asCeremonyCredential), create: (options) => container.create(options).then(asCeremonyCredential),
get: (options) => container.get(options).then(asCeremonyCredential), get: (options) => container.get(options).then(asCeremonyCredential),
} };
} };
// Feature detection: a user-verifying platform authenticator (Touch ID, // Feature detection: a user-verifying platform authenticator (Touch ID,
// Windows Hello, Android biometrics) plus the PRF extension. PRF has no // Windows Hello, Android biometrics) plus the PRF extension. PRF has no
@@ -101,27 +101,27 @@ const defaultCredentials = (): PasskeyCredentials => {
// exists we can ask for it, elsewhere this returns true optimistically and // exists we can ask for it, elsewhere this returns true optimistically and
// registration itself fails with a clear error. // registration itself fails with a clear error.
export const passkeySupported = async (probe?: PasskeySupportProbe): Promise<boolean> => { export const passkeySupported = async (probe?: PasskeySupportProbe): Promise<boolean> => {
const p = probe ?? (typeof PublicKeyCredential !== 'undefined' ? PublicKeyCredential : undefined) const p = probe ?? (typeof PublicKeyCredential !== 'undefined' ? PublicKeyCredential : undefined);
if (!p) return false if (!p) return false;
if (!(await p.isUserVerifyingPlatformAuthenticatorAvailable())) return false if (!(await p.isUserVerifyingPlatformAuthenticatorAvailable())) return false;
if (p.getClientCapabilities) { if (p.getClientCapabilities) {
const capabilities = await p.getClientCapabilities() const capabilities = await p.getClientCapabilities();
return capabilities['extension:prf'] === true return capabilities['extension:prf'] === true;
}
return true
} }
return true;
};
const toBytes = (source: BufferSource): Uint8Array => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
? new Uint8Array(source) ? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength) : new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
// pulls the evaluated PRF secret out of a ceremony result; null when the // pulls the evaluated PRF secret out of a ceremony result; null when the
// authenticator did not evaluate the extension (no hmac-secret support) // authenticator did not evaluate the extension (no hmac-secret support)
const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => { const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => {
const first = credential.getClientExtensionResults().prf?.results?.first const first = credential.getClientExtensionResults().prf?.results?.first;
return first ? toBytes(first) : null return first ? toBytes(first) : null;
} };
// one get() ceremony against a single known credential, returning its fresh // one get() ceremony against a single known credential, returning its fresh
// PRF output - the building block for complete-material re-wrap ceremonies // PRF output - the building block for complete-material re-wrap ceremonies
@@ -129,7 +129,7 @@ export const getPasskeyPrfOutput = async (
credentialId: string, credentialId: string,
options: { credentials?: PasskeyCredentials } = {}, options: { credentials?: PasskeyCredentials } = {},
): Promise<Uint8Array> => { ): Promise<Uint8Array> => {
const credentials = options.credentials ?? defaultCredentials() const credentials = options.credentials ?? defaultCredentials();
const assertion = await credentials.get({ const assertion = await credentials.get({
publicKey: { publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)), challenge: crypto.getRandomValues(new Uint8Array(32)),
@@ -137,20 +137,20 @@ export const getPasskeyPrfOutput = async (
userVerification: 'required', userVerification: 'required',
extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } }, extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } },
}, },
}) });
if (!assertion) throw new Error('Passkey ceremony was cancelled.') if (!assertion) throw new Error('Passkey ceremony was cancelled.');
const prfOutput = prfOutputOf(assertion) const prfOutput = prfOutputOf(assertion);
if (!prfOutput) { if (!prfOutput) {
throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.');
}
return prfOutput
} }
return prfOutput;
};
export type RegisterPasskeyOptions = { export type RegisterPasskeyOptions = {
credentials?: PasskeyCredentials credentials?: PasskeyCredentials;
name?: string name?: string;
authenticatorAttachment?: AuthenticatorAttachment authenticatorAttachment?: AuthenticatorAttachment;
} };
// Registers a new passkey and persists a slot wrapping the complete material. // Registers a new passkey and persists a slot wrapping the complete material.
// The ceremony is navigator.credentials.create with the PRF // The ceremony is navigator.credentials.create with the PRF
@@ -161,8 +161,8 @@ export const registerPasskey = async (
material: WalletMaterialV2, material: WalletMaterialV2,
options: RegisterPasskeyOptions = {}, options: RegisterPasskeyOptions = {},
): Promise<PasskeySlot> => { ): Promise<PasskeySlot> => {
const ownerId = requireCurrentPasskeyMaterialOwner(material) const ownerId = requireCurrentPasskeyMaterialOwner(material);
const credentials = options.credentials ?? defaultCredentials() const credentials = options.credentials ?? defaultCredentials();
const credential = await credentials.create({ const credential = await credentials.create({
publicKey: { publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)), challenge: crypto.getRandomValues(new Uint8Array(32)),
@@ -186,17 +186,17 @@ export const registerPasskey = async (
attestation: 'none', attestation: 'none',
extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } }, extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } },
}, },
}) });
if (!credential) throw new Error('Passkey registration was cancelled.') if (!credential) throw new Error('Passkey registration was cancelled.');
const credentialId = bytesToHex(toBytes(credential.rawId)) const credentialId = bytesToHex(toBytes(credential.rawId));
let prfOutput = prfOutputOf(credential) let prfOutput = prfOutputOf(credential);
if (!prfOutput) { if (!prfOutput) {
if (credential.getClientExtensionResults().prf?.enabled !== true) { if (credential.getClientExtensionResults().prf?.enabled !== true) {
throw new Error('This authenticator does not support the WebAuthn PRF extension.') throw new Error('This authenticator does not support the WebAuthn PRF extension.');
} }
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) prfOutput = await getPasskeyPrfOutput(credentialId, { credentials });
} }
const wrap = await wrapWalletMaterialWithPrf(prfOutput, material) const wrap = await wrapWalletMaterialWithPrf(prfOutput, material);
const slot: PasskeySlot = { const slot: PasskeySlot = {
credentialId, credentialId,
...wrap, ...wrap,
@@ -204,17 +204,17 @@ export const registerPasskey = async (
...(options.name !== undefined ? { name: options.name } : {}), ...(options.name !== undefined ? { name: options.name } : {}),
ownerId, ownerId,
version: PASSKEY_SLOT_VERSION, version: PASSKEY_SLOT_VERSION,
} };
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) { if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) {
throw new Error('Saved wallet material changed during passkey registration.') throw new Error('Saved wallet material changed during passkey registration.');
}
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
slots.push(slot)
writePasskeySlots(ownerId, slots)
})
return slot
} }
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId);
slots.push(slot);
writePasskeySlots(ownerId, slots);
});
return slot;
};
// Unlocks complete material via one get() ceremony offering every current // Unlocks complete material via one get() ceremony offering every current
// slot. The selected slot is re-read after the ceremony before any plaintext // slot. The selected slot is re-read after the ceremony before any plaintext
@@ -222,16 +222,16 @@ export const registerPasskey = async (
export const unlockWalletMaterialWithPasskey = async ( export const unlockWalletMaterialWithPasskey = async (
options: { credentials?: PasskeyCredentials } = {}, options: { credentials?: PasskeyCredentials } = {},
): Promise<WalletMaterialV2> => { ): Promise<WalletMaterialV2> => {
const ownerId = savedWalletMaterialOwnerId() const ownerId = savedWalletMaterialOwnerId();
const materialHash = savedWalletMaterialHash() const materialHash = savedWalletMaterialHash();
const slots = readPasskeySlots() const slots = readPasskeySlots();
if (ownerId === null || materialHash === null || slots.length === 0) { if (ownerId === null || materialHash === null || slots.length === 0) {
throw new Error('No passkeys registered on this device.') throw new Error('No passkeys registered on this device.');
} }
if (slots.some((slot) => slot.materialHash !== materialHash)) { if (slots.some((slot) => slot.materialHash !== materialHash)) {
throw new Error('Passkey slot belongs to different wallet material.') throw new Error('Passkey slot belongs to different wallet material.');
} }
const credentials = options.credentials ?? defaultCredentials() const credentials = options.credentials ?? defaultCredentials();
const assertion = await credentials.get({ const assertion = await credentials.get({
publicKey: { publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)), challenge: crypto.getRandomValues(new Uint8Array(32)),
@@ -242,49 +242,51 @@ export const unlockWalletMaterialWithPasskey = async (
userVerification: 'required', userVerification: 'required',
extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } }, extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } },
}, },
}) });
if (!assertion) throw new Error('Passkey ceremony was cancelled.') if (!assertion) throw new Error('Passkey ceremony was cancelled.');
const credentialId = bytesToHex(toBytes(assertion.rawId)) const credentialId = bytesToHex(toBytes(assertion.rawId));
const slot = slots.find((s) => s.credentialId === credentialId) const slot = slots.find((s) => s.credentialId === credentialId);
if (!slot) { if (!slot) {
throw new Error('The passkey used is not registered with this wallet.') throw new Error('The passkey used is not registered with this wallet.');
} }
const prfOutput = prfOutputOf(assertion) const prfOutput = prfOutputOf(assertion);
if (!prfOutput) { if (!prfOutput) {
throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.');
} }
if (savedWalletMaterialOwnerId() !== ownerId) { if (savedWalletMaterialOwnerId() !== ownerId) {
throw new Error('This passkey belongs to a different wallet.') throw new Error('This passkey belongs to a different wallet.');
} }
if (savedWalletMaterialHash() !== materialHash) { if (savedWalletMaterialHash() !== materialHash) {
throw new Error('Saved wallet material changed during the unlock ceremony.') throw new Error('Saved wallet material changed during the unlock ceremony.');
} }
const currentSlot = readPasskeySlots().find((candidate) => candidate.credentialId === credentialId) const currentSlot = readPasskeySlots().find(
(candidate) => candidate.credentialId === credentialId,
);
if (currentSlot === undefined || !passkeySlotsEqual(slot, currentSlot)) { if (currentSlot === undefined || !passkeySlotsEqual(slot, currentSlot)) {
throw new Error('The passkey slot changed during the unlock ceremony.') throw new Error('The passkey slot changed during the unlock ceremony.');
} }
const material = await unwrapWalletMaterialWithPrf(prfOutput, currentSlot) const material = await unwrapWalletMaterialWithPrf(prfOutput, currentSlot);
if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) { if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) {
throw new Error('This passkey belongs to a different wallet.') throw new Error('This passkey belongs to a different wallet.');
} }
if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) { if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) {
throw new Error('Saved wallet material changed during the unlock ceremony.') throw new Error('Saved wallet material changed during the unlock ceremony.');
}
return material
} }
return material;
};
// Removes the slot only: WebAuthn has no API to delete the credential from // Removes the slot only: WebAuthn has no API to delete the credential from
// the authenticator - an orphaned passkey simply finds nothing to unwrap. // the authenticator - an orphaned passkey simply finds nothing to unwrap.
// Returns whether a slot was actually removed. // Returns whether a slot was actually removed.
export const removePasskey = async (credentialId: string): Promise<boolean> => { export const removePasskey = async (credentialId: string): Promise<boolean> => {
const ownerId = savedWalletMaterialOwnerId() const ownerId = savedWalletMaterialOwnerId();
if (ownerId === null) return false if (ownerId === null) return false;
let removed = false let removed = false;
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
const slots = readPasskeySlots() const slots = readPasskeySlots();
const kept = slots.filter((s) => s.credentialId !== credentialId) const kept = slots.filter((s) => s.credentialId !== credentialId);
removed = kept.length !== slots.length removed = kept.length !== slots.length;
if (removed) writePasskeySlots(ownerId, kept) if (removed) writePasskeySlots(ownerId, kept);
}) });
return removed return removed;
} };
+48 -48
View File
@@ -1,5 +1,5 @@
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import { import {
deriveWalletMaterial, deriveWalletMaterial,
@@ -9,21 +9,21 @@ import {
savedWalletMaterialOwnerId, savedWalletMaterialOwnerId,
saveWalletMaterial, saveWalletMaterial,
walletMaterialHash, walletMaterialHash,
} from './keys' } from './keys';
import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys' import { migrateLegacyPasskeySlots, readPasskeySlots } from './passkeys';
import type {PasskeySlot} from './passkeys' import type { PasskeySlot } from './passkeys';
import {parseJsonObjectArray, stubLocalStorage} from './test-utils' import { parseJsonObjectArray, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const MATERIAL_STORAGE = 'sattle_wallet_material_v2' const MATERIAL_STORAGE = 'sattle_wallet_material_v2';
const SLOT_STORAGE = 'sattle_passkey_slots' const SLOT_STORAGE = 'sattle_passkey_slots';
const MATERIAL = { const MATERIAL = {
...deriveWalletMaterial( ...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
const SLOT_BASE = { const SLOT_BASE = {
credentialId: '11'.repeat(16), credentialId: '11'.repeat(16),
@@ -32,76 +32,76 @@ const SLOT_BASE = {
materialHash: walletMaterialHash(MATERIAL), materialHash: walletMaterialHash(MATERIAL),
wrappedMaterial: '55'.repeat(128), wrappedMaterial: '55'.repeat(128),
createdAt: 1, createdAt: 1,
} as const } as const;
const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => { const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots)) localStorage.setItem(SLOT_STORAGE, JSON.stringify(slots));
} };
// strips the v2 record's owner marker back to the unproven post-restore form // strips the v2 record's owner marker back to the unproven post-restore form
const removeSavedOwnerMarker = (): void => { const removeSavedOwnerMarker = (): void => {
const stored: unknown = JSON.parse(localStorage.getItem(MATERIAL_STORAGE) ?? '{}') const stored: unknown = JSON.parse(localStorage.getItem(MATERIAL_STORAGE) ?? '{}');
if (typeof stored !== 'object' || stored === null) throw new TypeError('expected a record') if (typeof stored !== 'object' || stored === null) throw new TypeError('expected a record');
const stripped: Record<string, unknown> = {...stored} const stripped: Record<string, unknown> = { ...stored };
delete stripped.ownerId delete stripped.ownerId;
delete stripped.version delete stripped.version;
localStorage.setItem(MATERIAL_STORAGE, JSON.stringify(stripped)) localStorage.setItem(MATERIAL_STORAGE, JSON.stringify(stripped));
} };
describe('passkey-slot schema version', () => { describe('passkey-slot schema version', () => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage();
await saveWalletMaterial(MATERIAL) await saveWalletMaterial(MATERIAL);
}) });
it('reads a current version 2 slot for the exact saved owner', () => { it('reads a current version 2 slot for the exact saved owner', () => {
// Given a current saved-material owner marker and passkey record // Given a current saved-material owner marker and passkey record
const ownerId = linkingPubKeyHex(LINKING_KEY) const ownerId = linkingPubKeyHex(LINKING_KEY);
const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 2} const slot: PasskeySlot = { ...SLOT_BASE, ownerId, version: 2 };
writeRawSlots([slot]) writeRawSlots([slot]);
// When current-owner slots are read // When current-owner slots are read
// Then the recognized version is exposed unchanged // Then the recognized version is exposed unchanged
expect(readPasskeySlots()).toEqual([slot]) expect(readPasskeySlots()).toEqual([slot]);
}) });
it('upgrades markerless and unversioned same-owner slots only after saved-material proof', async () => { it('upgrades markerless and unversioned same-owner slots only after saved-material proof', async () => {
// Given records written before schema versioning and a saved material // Given records written before schema versioning and a saved material
// whose owner marker was never proven (the post-restore form) // whose owner marker was never proven (the post-restore form)
const ownerId = linkingPubKeyHex(LINKING_KEY) const ownerId = linkingPubKeyHex(LINKING_KEY);
removeSavedOwnerMarker() removeSavedOwnerMarker();
writeRawSlots([SLOT_BASE, {...SLOT_BASE, credentialId: '55'.repeat(16), ownerId}]) writeRawSlots([SLOT_BASE, { ...SLOT_BASE, credentialId: '55'.repeat(16), ownerId }]);
// When migration is attempted before and after the saved material proves ownership // When migration is attempted before and after the saved material proves ownership
await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner') await expect(migrateLegacyPasskeySlots(LINKING_KEY)).rejects.toThrow('proven owner');
const provenMaterial = getPlainWalletMaterial() const provenMaterial = getPlainWalletMaterial();
if (provenMaterial === null) throw new Error('expected the compatible plaintext material') if (provenMaterial === null) throw new Error('expected the compatible plaintext material');
ensureSavedWalletMaterialOwner(provenMaterial) ensureSavedWalletMaterialOwner(provenMaterial);
await migrateLegacyPasskeySlots(LINKING_KEY) await migrateLegacyPasskeySlots(LINKING_KEY);
// Then both compatible legacy forms become current records // Then both compatible legacy forms become current records
expect(savedWalletMaterialOwnerId()).toBe(ownerId) expect(savedWalletMaterialOwnerId()).toBe(ownerId);
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([ expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
{ ...SLOT_BASE, ownerId, version: 2 }, { ...SLOT_BASE, ownerId, version: 2 },
{ ...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 2 }, { ...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 2 },
]) ]);
}) });
it.each([1, 3, '2', null])('hides unsupported or malformed version %j', (version) => { it.each([1, 3, '2', null])('hides unsupported or malformed version %j', (version) => {
// Given an otherwise valid slot carrying unrecognized metadata // Given an otherwise valid slot carrying unrecognized metadata
const ownerId = linkingPubKeyHex(LINKING_KEY) const ownerId = linkingPubKeyHex(LINKING_KEY);
writeRawSlots([{...SLOT_BASE, ownerId, version}]) writeRawSlots([{ ...SLOT_BASE, ownerId, version }]);
// When slots are parsed // When slots are parsed
// Then future or malformed records are unavailable and not downgraded to legacy // Then future or malformed records are unavailable and not downgraded to legacy
expect(readPasskeySlots()).toEqual([]) expect(readPasskeySlots()).toEqual([]);
}) });
it('hides a foreign version 1 slot from the current owner', () => { it('hides a foreign version 1 slot from the current owner', () => {
// Given the saved owner and slot owner differ // Given the saved owner and slot owner differ
writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 2}]) writeRawSlots([{ ...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 2 }]);
// When the current wallet reads passkeys // When the current wallet reads passkeys
// Then the foreign credential is unavailable // Then the foreign credential is unavailable
expect(readPasskeySlots()).toEqual([]) expect(readPasskeySlots()).toEqual([]);
}) });
}) });
+19 -19
View File
@@ -2,9 +2,9 @@
// PBKDF2 parameters and wire fields stay centralized so legacy linking-key and // PBKDF2 parameters and wire fields stay centralized so legacy linking-key and
// v2 wallet-material records remain interoperable with their own readers. // v2 wallet-material records remain interoperable with their own readers.
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
const PBKDF2_ITERATIONS = 210_000 const PBKDF2_ITERATIONS = 210_000;
const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<CryptoKey> => const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<CryptoKey> =>
crypto.subtle crypto.subtle
@@ -22,42 +22,42 @@ const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<C
false, false,
['encrypt', 'decrypt'], ['encrypt', 'decrypt'],
), ),
) );
export type EncryptedSecretParts = { export type EncryptedSecretParts = {
readonly salt: string readonly salt: string;
readonly iv: string readonly iv: string;
readonly ciphertext: string readonly ciphertext: string;
} };
export const encryptSecretParts = async ( export const encryptSecretParts = async (
value: string, value: string,
password: string, password: string,
): Promise<EncryptedSecretParts> => { ): Promise<EncryptedSecretParts> => {
const salt = crypto.getRandomValues(new Uint8Array(16)) const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = crypto.getRandomValues(new Uint8Array(12));
const aesKey = await deriveAesKeyFromPassword(password, salt) const aesKey = await deriveAesKeyFromPassword(password, salt);
const ciphertext = new Uint8Array( const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, utf8ToBytes(value)), await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, utf8ToBytes(value)),
) );
return { return {
salt: bytesToHex(salt), salt: bytesToHex(salt),
iv: bytesToHex(iv), iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext), ciphertext: bytesToHex(ciphertext),
} };
} };
export const decryptSecretParts = async ( export const decryptSecretParts = async (
parts: EncryptedSecretParts, parts: EncryptedSecretParts,
password: string, password: string,
): Promise<string> => { ): Promise<string> => {
const salt = hexToBytes(parts.salt) const salt = hexToBytes(parts.salt);
const iv = hexToBytes(parts.iv) const iv = hexToBytes(parts.iv);
const aesKey = await deriveAesKeyFromPassword(password, salt) const aesKey = await deriveAesKeyFromPassword(password, salt);
const plaintext = await crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv }, { name: 'AES-GCM', iv },
aesKey, aesKey,
hexToBytes(parts.ciphertext), hexToBytes(parts.ciphertext),
) );
return new TextDecoder().decode(plaintext) return new TextDecoder().decode(plaintext);
} };
+20 -20
View File
@@ -9,9 +9,9 @@ import {
NoteSpentError, NoteSpentError,
NoteUnknownError, NoteUnknownError,
PendingNoteError, PendingNoteError,
} from 'lnurlcash-kit' } from 'lnurlcash-kit';
import type {LnurlcashOptions} from 'lnurlcash-kit' import type { LnurlcashOptions } from 'lnurlcash-kit';
import type {Bearer, NewBearer} from './types' import type { Bearer, NewBearer } from './types';
// shared by Scan and Paste: resolve whatever came in to a note URL, ask the // shared by Scan and Paste: resolve whatever came in to a note URL, ask the
// issuing service what it is worth (an informational GET - per spec this // issuing service what it is worth (an informational GET - per spec this
@@ -19,23 +19,23 @@ import type {Bearer, NewBearer} from './types'
// after, see secureReceivedNote). Returns the note even when the info fetch // after, see secureReceivedNote). Returns the note even when the info fetch
// fails - a bearer is better stored unverified than dropped. // fails - a bearer is better stored unverified than dropped.
export const receiveNote = async (input: string, existing: Bearer[]): Promise<NewBearer> => { export const receiveNote = async (input: string, existing: Bearer[]): Promise<NewBearer> => {
const url = resolveNoteInput(input) const url = resolveNoteInput(input);
if (!url) { if (!url) {
throw new Error('Not an LNURLcash bearer note (needs a k1).') throw new Error('Not an LNURLcash bearer note (needs a k1).');
} }
const k1 = noteK1(url) const k1 = noteK1(url);
if (existing.some((b) => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url))) { if (existing.some((b) => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url))) {
throw new Error('This note is already in your wallet.') throw new Error('This note is already in your wallet.');
} }
try { try {
const info = await fetchNoteInfo(url) const info = await fetchNoteInfo(url);
return { return {
url, url,
callback: info.callback, callback: info.callback,
amount: info.maxWithdrawable, amount: info.maxWithdrawable,
verified: true, verified: true,
mintPubkey: info.mintPubkey, mintPubkey: info.mintPubkey,
} };
} catch (err) { } catch (err) {
// the service positively told us this k1 is dead, unknown, or locked // the service positively told us this k1 is dead, unknown, or locked
// mid-melt (pending) - all definitive states the caller must surface // mid-melt (pending) - all definitive states the caller must surface
@@ -47,7 +47,7 @@ export const receiveNote = async (input: string, existing: Bearer[]): Promise<Ne
err instanceof NoteUnknownError || err instanceof NoteUnknownError ||
err instanceof PendingNoteError err instanceof PendingNoteError
) { ) {
throw err throw err;
} }
// service unreachable (or some other non-definitive failure) - fall // service unreachable (or some other non-definitive failure) - fall
// back to the sender's own (unverified) declared amount so the note // back to the sender's own (unverified) declared amount so the note
@@ -57,9 +57,9 @@ export const receiveNote = async (input: string, existing: Bearer[]): Promise<Ne
callback: '', callback: '',
amount: noteDeclaredAmount(url) ?? 0, amount: noteDeclaredAmount(url) ?? 0,
verified: false, verified: false,
};
} }
} };
}
// After receiving a note, rotate it: the previous holder (and anything that // After receiving a note, rotate it: the previous holder (and anything that
// logged the URL in transit, since the informational GET above already put // logged the URL in transit, since the informational GET above already put
@@ -71,16 +71,16 @@ export const receiveNote = async (input: string, existing: Bearer[]): Promise<Ne
// rescue a rotate that landed despite its refusal. // rescue a rotate that landed despite its refusal.
export const secureReceivedNote = async ( export const secureReceivedNote = async (
note: { note: {
url: string url: string;
callback: string callback: string;
amount: number amount: number;
}, },
options: LnurlcashOptions = {}, options: LnurlcashOptions = {},
): Promise<string> => { ): Promise<string> => {
const k1 = noteK1(note.url) const k1 = noteK1(note.url);
if (!k1 || !note.callback) { if (!k1 || !note.callback) {
throw new Error('Note has no callback to rotate against yet.') throw new Error('Note has no callback to rotate against yet.');
}
const result = await rotateNote(note.callback, k1, options)
return withNewK1(note.url, result.k1, note.amount, result.signature)
} }
const result = await rotateNote(note.callback, k1, options);
return withNewK1(note.url, result.k1, note.amount, result.signature);
};
+95 -95
View File
@@ -4,20 +4,20 @@
// restore merges upward-only, and pending journal state never leaves the // restore merges upward-only, and pending journal state never leaves the
// device. // device.
import {beforeEach, describe, expect, it, vi} from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest';
import {bytesToHex} from '@noble/hashes/utils.js' import { bytesToHex } from '@noble/hashes/utils.js';
import {deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial} from './keys' import { deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial } from './keys';
import {applyBackup, buildBackup, parseBackupFile} from './storage' import { applyBackup, buildBackup, parseBackupFile } from './storage';
import {readFundsDocument, writeFundsDocument} from './storage/bearers' import { readFundsDocument, writeFundsDocument } from './storage/bearers';
import {addTrustedMint, readTrustedMints} from './trustedMints' import { addTrustedMint, readTrustedMints } from './trustedMints';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
const KEY_A = '02' + 'aa'.repeat(32) const KEY_A = '02' + 'aa'.repeat(32);
const KEY_B = '03' + 'bb'.repeat(32) const KEY_B = '03' + 'bb'.repeat(32);
// full v2 material for this harness's fixed linking key (real BIP-32 cash // full v2 material for this harness's fixed linking key (real BIP-32 cash
// root, same construction as the lifecycle test harness) // root, same construction as the lifecycle test harness)
const MATERIAL = { const MATERIAL = {
@@ -25,29 +25,29 @@ const MATERIAL = {
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
), ),
linkingKeyHex: bytesToHex(LINKING_KEY), linkingKeyHex: bytesToHex(LINKING_KEY),
} };
type LockRequest = { type LockRequest = {
readonly callback: () => unknown readonly callback: () => unknown;
readonly resolve: (value: unknown) => void readonly resolve: (value: unknown) => void;
readonly reject: (reason: unknown) => void readonly reject: (reason: unknown) => void;
} };
class DeferredLocks { class DeferredLocks {
readonly requests: LockRequest[] = [] readonly requests: LockRequest[] = [];
readonly request = (_name: string, callback: () => unknown): Promise<unknown> => readonly request = (_name: string, callback: () => unknown): Promise<unknown> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.requests.push({callback, resolve, reject}) this.requests.push({ callback, resolve, reject });
}) });
async releaseNext(): Promise<void> { async releaseNext(): Promise<void> {
const request = this.requests.shift() const request = this.requests.shift();
if (!request) throw new Error('Expected a queued lock request.') if (!request) throw new Error('Expected a queued lock request.');
try { try {
request.resolve(await request.callback()) request.resolve(await request.callback());
} catch (error) { } catch (error) {
request.reject(error instanceof Error ? error : new Error(String(error))) request.reject(error instanceof Error ? error : new Error(String(error)));
} }
} }
} }
@@ -67,65 +67,65 @@ const backup = (server: string, mintPubkey: string) => ({
pendingMintPubkey: KEY_B, pendingMintPubkey: KEY_B,
}, },
], ],
}) });
const installProvenOwner = (): Promise<void> => saveWalletMaterial(MATERIAL) const installProvenOwner = (): Promise<void> => saveWalletMaterial(MATERIAL);
beforeEach(() => { beforeEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals();
stubLocalStorage() stubLocalStorage();
}) });
describe('owner-bound backup restore', () => { describe('owner-bound backup restore', () => {
it('exports only the active owner trusted-mint registry', async () => { it('exports only the active owner trusted-mint registry', async () => {
await installProvenOwner() await installProvenOwner();
await addTrustedMint('backup-mint.example', KEY_A, {ownerId: OWNER_ID}) await addTrustedMint('backup-mint.example', KEY_A, { ownerId: OWNER_ID });
expect(buildBackup(OWNER_ID).trustedMints).toEqual([ expect(buildBackup(OWNER_ID).trustedMints).toEqual([
expect.objectContaining({ server: 'backup-mint.example', mintPubkey: KEY_A }), expect.objectContaining({ server: 'backup-mint.example', mintPubkey: KEY_A }),
]) ]);
expect(buildBackup(OWNER_ID).ownerId).toBe(OWNER_ID) expect(buildBackup(OWNER_ID).ownerId).toBe(OWNER_ID);
expect(buildBackup().trustedMints).toEqual([]) expect(buildBackup().trustedMints).toEqual([]);
}) });
it('drops fresh-device mint trust instead of using a valid file owner marker', async () => { it('drops fresh-device mint trust instead of using a valid file owner marker', async () => {
const result = await applyBackup({ const result = await applyBackup({
...backup('file-mint.example', KEY_A), ...backup('file-mint.example', KEY_A),
ownerId: OWNER_ID, ownerId: OWNER_ID,
}) });
expect(result.trustedMintsAdded).toBe(0) expect(result.trustedMintsAdded).toBe(0);
expect(readTrustedMints(OWNER_ID)).toEqual([]) expect(readTrustedMints(OWNER_ID)).toEqual([]);
expect(localStorage.getItem('sattle_trusted_mints')).toBeNull() expect(localStorage.getItem('sattle_trusted_mints')).toBeNull();
}) });
it('does not attach file mints to a malformed owner marker', async () => { it('does not attach file mints to a malformed owner marker', async () => {
const result = await applyBackup({ const result = await applyBackup({
...backup('file-mint.example', KEY_A), ...backup('file-mint.example', KEY_A),
ownerId: 'malformed-owner', ownerId: 'malformed-owner',
}) });
expect(result.trustedMintsAdded).toBe(0) expect(result.trustedMintsAdded).toBe(0);
expect(readTrustedMints(OWNER_ID)).toEqual([]) expect(readTrustedMints(OWNER_ID)).toEqual([]);
}) });
it('does not resolve before the trusted-mint merge commits', async () => { it('does not resolve before the trusted-mint merge commits', async () => {
await installProvenOwner() await installProvenOwner();
const locks = new DeferredLocks() const locks = new DeferredLocks();
vi.stubGlobal('navigator', {locks}) vi.stubGlobal('navigator', { locks });
let settled = false let settled = false;
const restoring = applyBackup(backup('backup-mint.example', KEY_A), OWNER_ID).then((result) => { const restoring = applyBackup(backup('backup-mint.example', KEY_A), OWNER_ID).then((result) => {
settled = true settled = true;
return result return result;
}) });
await vi.waitFor(() => expect(locks.requests).toHaveLength(1)) await vi.waitFor(() => expect(locks.requests).toHaveLength(1));
expect(settled).toBe(false) expect(settled).toBe(false);
await locks.releaseNext() await locks.releaseNext();
expect((await restoring).trustedMintsAdded).toBe(1) expect((await restoring).trustedMintsAdded).toBe(1);
const restored = readTrustedMints(OWNER_ID) const restored = readTrustedMints(OWNER_ID);
expect(restored).toEqual([ expect(restored).toEqual([
expect.objectContaining({ expect.objectContaining({
server: 'backup-mint.example', server: 'backup-mint.example',
@@ -133,12 +133,12 @@ describe('owner-bound backup restore', () => {
locked: false, locked: false,
unconfirmed: true, unconfirmed: true,
}), }),
]) ]);
expect(restored[0]?.pendingMintPubkey).toBeUndefined() expect(restored[0]?.pendingMintPubkey).toBeUndefined();
}) });
it('keeps a local locked pin and pending rekey authoritative', async () => { it('keeps a local locked pin and pending rekey authoritative', async () => {
await installProvenOwner() await installProvenOwner();
localStorage.setItem( localStorage.setItem(
'sattle_trusted_mints', 'sattle_trusted_mints',
JSON.stringify({ JSON.stringify({
@@ -154,20 +154,20 @@ describe('owner-bound backup restore', () => {
}, },
], ],
}), }),
) );
const result = await applyBackup(backup('local.example', KEY_B), OWNER_ID) const result = await applyBackup(backup('local.example', KEY_B), OWNER_ID);
expect(result.trustedMintsAdded).toBe(0) expect(result.trustedMintsAdded).toBe(0);
expect(readTrustedMints(OWNER_ID)).toEqual([ expect(readTrustedMints(OWNER_ID)).toEqual([
expect.objectContaining({ expect.objectContaining({
mintPubkey: KEY_A, mintPubkey: KEY_A,
locked: true, locked: true,
pendingMintPubkey: KEY_B, pendingMintPubkey: KEY_B,
}), }),
]) ]);
}) });
}) });
describe('backup counter projection', () => { describe('backup counter projection', () => {
const fundsDoc = (nextByHost: Record<string, number>) => ({ const fundsDoc = (nextByHost: Record<string, number>) => ({
@@ -176,29 +176,29 @@ describe('backup counter projection', () => {
pending: [] as { id: string; kind: string; phase: string; iv: string; ciphertext: string }[], pending: [] as { id: string; kind: string; phase: string; iv: string; ciphertext: string }[],
nextByHost, nextByHost,
revision: 1, revision: 1,
}) });
it('exports the counters and never the pending journal', () => { it('exports the counters and never the pending journal', () => {
const doc = fundsDoc({'mint.example': 5}) const doc = fundsDoc({ 'mint.example': 5 });
doc.pending.push({ doc.pending.push({
id: 'pending-sentinel-never-exported', id: 'pending-sentinel-never-exported',
kind: 'cash-allocation', kind: 'cash-allocation',
phase: 'reserved', phase: 'reserved',
iv: '00', iv: '00',
ciphertext: '00', ciphertext: '00',
}) });
writeFundsDocument(doc) writeFundsDocument(doc);
const built = buildBackup(OWNER_ID) const built = buildBackup(OWNER_ID);
expect(built.version).toBe(2) expect(built.version).toBe(2);
expect(built.nextByHost).toEqual({'mint.example': 5}) expect(built.nextByHost).toEqual({ 'mint.example': 5 });
expect('pending' in built).toBe(false) expect('pending' in built).toBe(false);
expect(JSON.stringify(built)).not.toContain('pending-sentinel-never-exported') expect(JSON.stringify(built)).not.toContain('pending-sentinel-never-exported');
}) });
it('round-trips counters through a restore and merges upward-only', async () => { it('round-trips counters through a restore and merges upward-only', async () => {
writeFundsDocument(fundsDoc({'mint.example': 5, 'other.example': 2})) writeFundsDocument(fundsDoc({ 'mint.example': 5, 'other.example': 2 }));
const result = await applyBackup({ const result = await applyBackup({
type: 'sattle-backup', type: 'sattle-backup',
@@ -206,20 +206,20 @@ describe('backup counter projection', () => {
createdAt: 1, createdAt: 1,
bearers: [], bearers: [],
nextByHost: { 'mint.example': 3, 'other.example': 9, 'new.example': 4 }, nextByHost: { 'mint.example': 3, 'other.example': 9, 'new.example': 4 },
}) });
expect(result.added).toBe(0) expect(result.added).toBe(0);
// lower incoming values never rewind a counter; higher ones advance it; // lower incoming values never rewind a counter; higher ones advance it;
// unknown hosts adopt the backup's value // unknown hosts adopt the backup's value
expect(readFundsDocument().nextByHost).toEqual({ expect(readFundsDocument().nextByHost).toEqual({
'mint.example': 5, 'mint.example': 5,
'other.example': 9, 'other.example': 9,
'new.example': 4, 'new.example': 4,
}) });
}) });
it('skips out-of-bounds counter entries instead of rejecting the restore', async () => { it('skips out-of-bounds counter entries instead of rejecting the restore', async () => {
writeFundsDocument(fundsDoc({'mint.example': 5})) writeFundsDocument(fundsDoc({ 'mint.example': 5 }));
await applyBackup({ await applyBackup({
type: 'sattle-backup', type: 'sattle-backup',
@@ -233,14 +233,14 @@ describe('backup counter projection', () => {
'fractional.example': 1.5, 'fractional.example': 1.5,
'valid.example': 7, 'valid.example': 7,
}, },
}) });
expect(readFundsDocument().nextByHost).toEqual({'mint.example': 5, 'valid.example': 7}) expect(readFundsDocument().nextByHost).toEqual({ 'mint.example': 5, 'valid.example': 7 });
}) });
it('never grows the counter map beyond ten hosts on restore', async () => { it('never grows the counter map beyond ten hosts on restore', async () => {
const incoming: Record<string, number> = {} const incoming: Record<string, number> = {};
for (let i = 0; i < 12; i += 1) incoming[`mint-${i}.example`] = i + 1 for (let i = 0; i < 12; i += 1) incoming[`mint-${i}.example`] = i + 1;
await applyBackup({ await applyBackup({
type: 'sattle-backup', type: 'sattle-backup',
@@ -248,21 +248,21 @@ describe('backup counter projection', () => {
createdAt: 1, createdAt: 1,
bearers: [], bearers: [],
nextByHost: incoming, nextByHost: incoming,
}) });
const merged = readFundsDocument().nextByHost const merged = readFundsDocument().nextByHost;
expect(Object.keys(merged)).toHaveLength(10) expect(Object.keys(merged)).toHaveLength(10);
}) });
it('rejects a legacy version-1 backup file', () => { it('rejects a legacy version-1 backup file', () => {
expect(() => expect(() =>
parseBackupFile({ type: 'sattle-backup', version: 1, createdAt: 1, bearers: [] }), parseBackupFile({ type: 'sattle-backup', version: 1, createdAt: 1, bearers: [] }),
).toThrow('Not a valid sattle backup file.') ).toThrow('Not a valid sattle backup file.');
}) });
it('rejects a v2 file without a counter map', () => { it('rejects a v2 file without a counter map', () => {
expect(() => expect(() =>
parseBackupFile({ type: 'sattle-backup', version: 2, createdAt: 1, bearers: [] }), parseBackupFile({ type: 'sattle-backup', version: 2, createdAt: 1, bearers: [] }),
).toThrow('Not a valid sattle backup file.') ).toThrow('Not a valid sattle backup file.');
}) });
}) });
+117 -117
View File
@@ -2,10 +2,10 @@
// fund-critical changeset boundary without mixing it into unrelated storage // fund-critical changeset boundary without mixing it into unrelated storage
// round-trip and backup cases. // round-trip and backup cases.
import {describe, expect, it, vi} from 'vitest' import { describe, expect, it, vi } from 'vitest';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import {deriveBearerAesKey, encryptRecord} from './keys' import { deriveBearerAesKey, encryptRecord } from './keys';
import { import {
applyBearerChangeset, applyBearerChangeset,
deleteBearerRecord, deleteBearerRecord,
@@ -13,25 +13,25 @@ import {
newBearerId, newBearerId,
persistBearer, persistBearer,
readEncryptedBearers, readEncryptedBearers,
} from './storage' } from './storage';
import type {BearerChangeset} from './storage' import type { BearerChangeset } from './storage';
import type {Bearer, NewBearer} from './types' import type { Bearer, NewBearer } from './types';
import {FUNDS_STORAGE_KEY, readFundsDocument, writeFundsDocument} from './storage/bearers' import { FUNDS_STORAGE_KEY, readFundsDocument, writeFundsDocument } from './storage/bearers';
// mutations of the funds document require Web Locks; tests that don't care // mutations of the funds document require Web Locks; tests that don't care
// about lock timing install a present-but-non-serializing fake // about lock timing install a present-but-non-serializing fake
const stubPassthroughLocks = (): void => { const stubPassthroughLocks = (): void => {
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
}) });
} };
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const K1_C = 'cc'.repeat(32) const K1_C = 'cc'.repeat(32);
const K1_D = 'dd'.repeat(32) const K1_D = 'dd'.repeat(32);
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: newBearerId(), id: newBearerId(),
@@ -42,7 +42,7 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({ const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
url: buildNoteUrl('https://mint.example/w', K1_C, 3_000), url: buildNoteUrl('https://mint.example/w', K1_C, 3_000),
@@ -50,37 +50,37 @@ const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
amount: 3_000, amount: 3_000,
verified: true, verified: true,
...overrides, ...overrides,
}) });
describe('baseline: per-record bearer persistence', () => { describe('baseline: per-record bearer persistence', () => {
it('writes once for every persist or delete call', async () => { it('writes once for every persist or delete call', async () => {
stubPassthroughLocks() stubPassthroughLocks();
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const writes = vi.spyOn(localStorage, 'setItem') const writes = vi.spyOn(localStorage, 'setItem');
await persistBearer(key, bearerFixture({id: 'a'})) await persistBearer(key, bearerFixture({ id: 'a' }));
await persistBearer(key, bearerFixture({id: 'b'})) await persistBearer(key, bearerFixture({ id: 'b' }));
await deleteBearerRecord('a') await deleteBearerRecord('a');
expect( expect(
writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
).toHaveLength(3) ).toHaveLength(3);
}) });
}) });
describe('applyBearerChangeset', () => { describe('applyBearerChangeset', () => {
it('commits additions and spent replacements with exactly one write', async () => { it('commits additions and spent replacements with exactly one write', async () => {
stubPassthroughLocks() stubPassthroughLocks();
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const oldA = bearerFixture({id: 'old-a'}) const oldA = bearerFixture({ id: 'old-a' });
const oldB = bearerFixture({ const oldB = bearerFixture({
id: 'old-b', id: 'old-b',
url: buildNoteUrl('https://mint.example/w', K1_B, 5_000), url: buildNoteUrl('https://mint.example/w', K1_B, 5_000),
amount: 5_000, amount: 5_000,
}) });
await persistBearer(key, oldA) await persistBearer(key, oldA);
await persistBearer(key, oldB) await persistBearer(key, oldB);
const snapshot = [oldA, oldB] const snapshot = [oldA, oldB];
const changeset: BearerChangeset = { const changeset: BearerChangeset = {
add: [ add: [
newBearerFixture(), newBearerFixture(),
@@ -90,37 +90,37 @@ describe('applyBearerChangeset', () => {
}), }),
], ],
markSpent: ['old-a', 'old-b'], markSpent: ['old-a', 'old-b'],
} };
const writes = vi.spyOn(localStorage, 'setItem') const writes = vi.spyOn(localStorage, 'setItem');
const result = await applyBearerChangeset(key, snapshot, changeset) const result = await applyBearerChangeset(key, snapshot, changeset);
expect( expect(
writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
).toHaveLength(1) ).toHaveLength(1);
expect(result).toHaveLength(4) expect(result).toHaveLength(4);
expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true]) expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true]);
expect(result[0]?.id).not.toBe(result[1]?.id) expect(result[0]?.id).not.toBe(result[1]?.id);
expect(snapshot.map((bearer) => bearer.spent)).toEqual([undefined, undefined]) expect(snapshot.map((bearer) => bearer.spent)).toEqual([undefined, undefined]);
expect(changeset.markSpent).toEqual(['old-a', 'old-b']) expect(changeset.markSpent).toEqual(['old-a', 'old-b']);
expect(changeset.add.some((note) => 'id' in note)).toBe(false) expect(changeset.add.some((note) => 'id' in note)).toBe(false);
expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual( expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual(
result.map((bearer) => bearer.id).sort(), result.map((bearer) => bearer.id).sort(),
) );
const raw = localStorage.getItem(FUNDS_STORAGE_KEY) ?? '' const raw = localStorage.getItem(FUNDS_STORAGE_KEY) ?? '';
expect(raw).not.toContain(K1_C) expect(raw).not.toContain(K1_C);
expect(raw).not.toContain(K1_D) expect(raw).not.toContain(K1_D);
}) });
it('writes nothing when the second record encryption fails', async () => { it('writes nothing when the second record encryption fails', async () => {
stubPassthroughLocks() stubPassthroughLocks();
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
await persistBearer(key, bearerFixture({id: 'kept'})) await persistBearer(key, bearerFixture({ id: 'kept' }));
const writes = vi.spyOn(localStorage, 'setItem') const writes = vi.spyOn(localStorage, 'setItem');
const encrypt = vi.spyOn(crypto.subtle, 'encrypt') const encrypt = vi.spyOn(crypto.subtle, 'encrypt');
encrypt encrypt
.mockResolvedValueOnce(new ArrayBuffer(32)) .mockResolvedValueOnce(new ArrayBuffer(32))
.mockRejectedValueOnce(new Error('second encryption failed')) .mockRejectedValueOnce(new Error('second encryption failed'));
try { try {
await expect( await expect(
@@ -133,26 +133,26 @@ describe('applyBearerChangeset', () => {
], ],
markSpent: [], markSpent: [],
}), }),
).rejects.toThrow('second encryption failed') ).rejects.toThrow('second encryption failed');
expect(encrypt).toHaveBeenCalledTimes(2) expect(encrypt).toHaveBeenCalledTimes(2);
expect( expect(
writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
).toHaveLength(0) ).toHaveLength(0);
expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept']) expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept']);
} finally { } finally {
encrypt.mockRestore() encrypt.mockRestore();
} }
}) });
it('rejects a failed storage write without changing persisted state', async () => { it('rejects a failed storage write without changing persisted state', async () => {
stubPassthroughLocks() stubPassthroughLocks();
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const kept = bearerFixture({id: 'kept'}) const kept = bearerFixture({ id: 'kept' });
await persistBearer(key, kept) await persistBearer(key, kept);
const before = localStorage.getItem(FUNDS_STORAGE_KEY) const before = localStorage.getItem(FUNDS_STORAGE_KEY);
const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError') throw new Error('QuotaExceededError');
}) });
try { try {
await expect( await expect(
@@ -160,43 +160,43 @@ describe('applyBearerChangeset', () => {
add: [newBearerFixture()], add: [newBearerFixture()],
markSpent: ['kept'], markSpent: ['kept'],
}), }),
).rejects.toThrow('QuotaExceededError') ).rejects.toThrow('QuotaExceededError');
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before);
} finally { } finally {
write.mockRestore() write.mockRestore();
vi.unstubAllGlobals() vi.unstubAllGlobals();
} }
}) });
it('deduplicates repeated spent ids into one stored replacement', async () => { it('deduplicates repeated spent ids into one stored replacement', async () => {
stubPassthroughLocks() stubPassthroughLocks();
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const bearer = bearerFixture({id: 'duplicate'}) const bearer = bearerFixture({ id: 'duplicate' });
await persistBearer(key, bearer) await persistBearer(key, bearer);
const result = await applyBearerChangeset(key, [bearer], { const result = await applyBearerChangeset(key, [bearer], {
add: [], add: [],
markSpent: ['duplicate', 'duplicate'], markSpent: ['duplicate', 'duplicate'],
}) });
expect(readEncryptedBearers().filter((record) => record.id === 'duplicate')).toHaveLength(1) expect(readEncryptedBearers().filter((record) => record.id === 'duplicate')).toHaveLength(1);
expect(result[0]?.spent).toBe(true) expect(result[0]?.spent).toBe(true);
expect((await loadBearers(key))[0]?.spent).toBe(true) expect((await loadBearers(key))[0]?.spent).toBe(true);
}) });
it('fresh-reads inside the lock and preserves a fresh unrelated record', async () => { it('fresh-reads inside the lock and preserves a fresh unrelated record', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const other = await deriveBearerAesKey(OTHER_KEY) const other = await deriveBearerAesKey(OTHER_KEY);
const mine = bearerFixture({id: 'mine'}) const mine = bearerFixture({ id: 'mine' });
const foreign = bearerFixture({ const foreign = bearerFixture({
id: 'fresh-tab', id: 'fresh-tab',
url: buildNoteUrl('https://mint.example/w', K1_B, 9_000), url: buildNoteUrl('https://mint.example/w', K1_B, 9_000),
}) });
await persistBearer(key, mine) await persistBearer(key, mine);
const {id: foreignId, ...foreignPlain} = foreign const { id: foreignId, ...foreignPlain } = foreign;
const foreignParts = await encryptRecord(other, foreignPlain) const foreignParts = await encryptRecord(other, foreignPlain);
const encrypted = vi.spyOn(crypto.subtle, 'encrypt') const encrypted = vi.spyOn(crypto.subtle, 'encrypt');
const queue: {release: () => Promise<void>}[] = [] const queue: { release: () => Promise<void> }[] = [];
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
locks: { locks: {
request: (_name: string, fn: () => unknown): Promise<unknown> => request: (_name: string, fn: () => unknown): Promise<unknown> =>
@@ -205,59 +205,59 @@ describe('applyBearerChangeset', () => {
release: async () => { release: async () => {
await Promise.resolve(fn) await Promise.resolve(fn)
.then((callback) => callback()) .then((callback) => callback())
.then(resolve, reject) .then(resolve, reject);
}, },
}) });
}), }),
}, },
}) });
try { try {
const commit = applyBearerChangeset(key, [mine], { const commit = applyBearerChangeset(key, [mine], {
add: [newBearerFixture()], add: [newBearerFixture()],
markSpent: [], markSpent: [],
}) });
await vi.waitFor(() => expect(queue).toHaveLength(1)) await vi.waitFor(() => expect(queue).toHaveLength(1));
// nothing is encrypted before the lock: the document is re-read fresh // nothing is encrypted before the lock: the document is re-read fresh
// inside it, so a record another tab commits in the meantime survives // inside it, so a record another tab commits in the meantime survives
expect(encrypted).toHaveBeenCalledTimes(0) expect(encrypted).toHaveBeenCalledTimes(0);
const doc = readFundsDocument() const doc = readFundsDocument();
doc.bearers.push({id: foreignId, ...foreignParts}) doc.bearers.push({ id: foreignId, ...foreignParts });
writeFundsDocument(doc) writeFundsDocument(doc);
const pendingLock = queue.at(0) const pendingLock = queue.at(0);
if (pendingLock === undefined) throw new Error('Expected pending lock') if (pendingLock === undefined) throw new Error('Expected pending lock');
await pendingLock.release() await pendingLock.release();
const result = await commit const result = await commit;
expect( expect(
readEncryptedBearers() readEncryptedBearers()
.map((record) => record.id) .map((record) => record.id)
.sort(), .sort(),
).toEqual(['fresh-tab', 'mine', result[0]?.id].sort()) ).toEqual(['fresh-tab', 'mine', result[0]?.id].sort());
expect((await loadBearers(other)).map((bearer) => bearer.id)).toEqual(['fresh-tab']) expect((await loadBearers(other)).map((bearer) => bearer.id)).toEqual(['fresh-tab']);
} finally { } finally {
encrypted.mockRestore() encrypted.mockRestore();
vi.unstubAllGlobals() vi.unstubAllGlobals();
} }
}) });
it('replaces corrupt JSON with the single committed changeset write', async () => { it('replaces corrupt JSON with the single committed changeset write', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{') localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{');
const writes = vi.spyOn(localStorage, 'setItem') const writes = vi.spyOn(localStorage, 'setItem');
const result = await applyBearerChangeset(key, [], { const result = await applyBearerChangeset(key, [], {
add: [newBearerFixture()], add: [newBearerFixture()],
markSpent: ['missing'], markSpent: ['missing'],
}) });
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect( expect(
writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
).toHaveLength(1) ).toHaveLength(1);
expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual( expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual(
result.map((bearer) => bearer.id), result.map((bearer) => bearer.id),
) );
}) });
}) });
+130 -130
View File
@@ -1,12 +1,12 @@
// Storage round-trips and backup merge semantics. Runs in Node against an // Storage round-trips and backup merge semantics. Runs in Node against an
// in-memory localStorage stub; WebCrypto (crypto.subtle) is native. // in-memory localStorage stub; WebCrypto (crypto.subtle) is native.
import {beforeEach, describe, expect, it} from 'vitest' import { beforeEach, describe, expect, it } from 'vitest';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import './storage.changeset.cases' import './storage.changeset.cases';
import type {Bearer} from './types' import type { Bearer } from './types';
import {deriveBearerAesKey} from './keys' import { deriveBearerAesKey } from './keys';
import { import {
applyBackup, applyBackup,
buildBackup, buildBackup,
@@ -20,16 +20,16 @@ import {
persistBearer, persistBearer,
readEncryptedBearers, readEncryptedBearers,
MAX_ACTIVITY_ENTRIES, MAX_ACTIVITY_ENTRIES,
} from './storage' } from './storage';
import {saveLinkingKey} from './keys' import { saveLinkingKey } from './keys';
import {FUNDS_STORAGE_KEY} from './storage/bearers' import { FUNDS_STORAGE_KEY } from './storage/bearers';
import {requiredValue, stubLocalStorage} from './test-utils' import { requiredValue, stubLocalStorage } from './test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9);
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32);
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32);
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: newBearerId(), id: newBearerId(),
@@ -40,204 +40,204 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
...overrides, ...overrides,
}) });
beforeEach(() => { beforeEach(() => {
stubLocalStorage() stubLocalStorage();
}) });
describe('encrypted bearer records', () => { describe('encrypted bearer records', () => {
it('round-trips a bearer through AES-GCM storage', async () => { it('round-trips a bearer through AES-GCM storage', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const bearer = bearerFixture() const bearer = bearerFixture();
await persistBearer(key, bearer) await persistBearer(key, bearer);
// at rest, nothing plaintext leaks: no k1, no amounts // at rest, nothing plaintext leaks: no k1, no amounts
const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY));
expect(raw).not.toContain(K1_A) expect(raw).not.toContain(K1_A);
expect(raw).not.toContain('21000') expect(raw).not.toContain('21000');
const loaded = await loadBearers(key) const loaded = await loadBearers(key);
expect(loaded).toEqual([bearer]) expect(loaded).toEqual([bearer]);
}) });
it('skips records written under a different seed without destroying them', async () => { it('skips records written under a different seed without destroying them', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const other = await deriveBearerAesKey(OTHER_KEY) const other = await deriveBearerAesKey(OTHER_KEY);
await persistBearer(key, bearerFixture({id: 'mine'})) await persistBearer(key, bearerFixture({ id: 'mine' }));
await persistBearer( await persistBearer(
other, other,
bearerFixture({ id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }), bearerFixture({ id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }),
) );
const loaded = await loadBearers(key) const loaded = await loadBearers(key);
expect(loaded.map((b) => b.id)).toEqual(['mine']) expect(loaded.map((b) => b.id)).toEqual(['mine']);
// the foreign ciphertext is still there, untouched // the foreign ciphertext is still there, untouched
expect( expect(
readEncryptedBearers() readEncryptedBearers()
.map((r) => r.id) .map((r) => r.id)
.sort(), .sort(),
).toEqual(['foreign', 'mine']) ).toEqual(['foreign', 'mine']);
}) });
it('overwrites a record when the same id is persisted again', async () => { it('overwrites a record when the same id is persisted again', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const bearer = bearerFixture() const bearer = bearerFixture();
await persistBearer(key, bearer) await persistBearer(key, bearer);
await persistBearer(key, {...bearer, spent: true, updatedAt: 2000}) await persistBearer(key, { ...bearer, spent: true, updatedAt: 2000 });
const loaded = await loadBearers(key) const loaded = await loadBearers(key);
expect(loaded).toHaveLength(1) expect(loaded).toHaveLength(1);
expect(requiredValue(loaded[0]).spent).toBe(true) expect(requiredValue(loaded[0]).spent).toBe(true);
}) });
it('deletes a record by id and clears all', async () => { it('deletes a record by id and clears all', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const a = bearerFixture({id: 'a'}) const a = bearerFixture({ id: 'a' });
const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}) const b = bearerFixture({ id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) });
await persistBearer(key, a) await persistBearer(key, a);
await persistBearer(key, b) await persistBearer(key, b);
await deleteBearerRecord('a') await deleteBearerRecord('a');
expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b']) expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b']);
clearAllBearers() clearAllBearers();
expect(readEncryptedBearers()).toEqual([]) expect(readEncryptedBearers()).toEqual([]);
}) });
}) });
describe('activity log', () => { describe('activity log', () => {
it('round-trips events newest-first', async () => { it('round-trips events newest-first', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
await persistActivityEvent(key, {id: '1', kind: 'mint', message: 'a', createdAt: 1000}) await persistActivityEvent(key, { id: '1', kind: 'mint', message: 'a', createdAt: 1000 });
await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000}) await persistActivityEvent(key, { id: '2', kind: 'melt', message: 'b', createdAt: 2000 });
const loaded = await loadActivity(key) const loaded = await loadActivity(key);
expect(loaded.map((e) => e.id)).toEqual(['2', '1']) expect(loaded.map((e) => e.id)).toEqual(['2', '1']);
}) });
it('caps the log, rolling the oldest entries off', async () => { it('caps the log, rolling the oldest entries off', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
for (let i = 0; i < MAX_ACTIVITY_ENTRIES + 5; i++) { for (let i = 0; i < MAX_ACTIVITY_ENTRIES + 5; i++) {
await persistActivityEvent(key, { await persistActivityEvent(key, {
id: `ev-${i}`, id: `ev-${i}`,
kind: 'receive', kind: 'receive',
message: `event ${i}`, message: `event ${i}`,
createdAt: i, createdAt: i,
}) });
} }
const loaded = await loadActivity(key) const loaded = await loadActivity(key);
expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES) expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES);
// the five oldest rolled off; the newest is first // the five oldest rolled off; the newest is first
expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`) expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`);
expect(requiredValue(loaded.at(-1)).id).toBe('ev-5') expect(requiredValue(loaded.at(-1)).id).toBe('ev-5');
}, 30_000) }, 30_000);
}) });
describe('mergeBearers (union by note id, spent-wins)', () => { describe('mergeBearers (union by note id, spent-wins)', () => {
it('unions notes with distinct secrets', () => { it('unions notes with distinct secrets', () => {
const a = bearerFixture({id: 'a'}) const a = bearerFixture({ id: 'a' });
const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}) const b = bearerFixture({ id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) });
const merged = mergeBearers([a], [b]) const merged = mergeBearers([a], [b]);
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']) expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']);
}) });
it('lets the spent copy of a note win over a still-spendable one', () => { it('lets the spent copy of a note win over a still-spendable one', () => {
const spendable = bearerFixture({id: 'old-copy', updatedAt: 3000}) const spendable = bearerFixture({ id: 'old-copy', updatedAt: 3000 });
const spent = bearerFixture({id: 'new-copy', spent: true, updatedAt: 1000}) const spent = bearerFixture({ id: 'new-copy', spent: true, updatedAt: 1000 });
// same note (same server + k1), different record ids, and the spent // same note (same server + k1), different record ids, and the spent
// copy is even the STALER one - spent still wins, or a restored backup // copy is even the STALER one - spent still wins, or a restored backup
// would resurrect burned money // would resurrect burned money
const merged = mergeBearers([spendable], [spent]) const merged = mergeBearers([spendable], [spent]);
expect(merged).toHaveLength(1) expect(merged).toHaveLength(1);
expect(requiredValue(merged[0]).id).toBe('new-copy') expect(requiredValue(merged[0]).id).toBe('new-copy');
expect(requiredValue(merged[0]).spent).toBe(true) expect(requiredValue(merged[0]).spent).toBe(true);
}) });
it('keeps the newer copy when both agree on spent state', () => { it('keeps the newer copy when both agree on spent state', () => {
const stale = bearerFixture({id: 'stale', updatedAt: 1000, amount: 1}) const stale = bearerFixture({ id: 'stale', updatedAt: 1000, amount: 1 });
const fresh = bearerFixture({id: 'fresh', updatedAt: 2000, amount: 2}) const fresh = bearerFixture({ id: 'fresh', updatedAt: 2000, amount: 2 });
const merged = mergeBearers([stale], [fresh]) const merged = mergeBearers([stale], [fresh]);
expect(merged).toHaveLength(1) expect(merged).toHaveLength(1);
expect(requiredValue(merged[0]).id).toBe('fresh') expect(requiredValue(merged[0]).id).toBe('fresh');
}) });
it('treats the same secret on different servers as different notes', () => { it('treats the same secret on different servers as different notes', () => {
const here = bearerFixture({id: 'here'}) const here = bearerFixture({ id: 'here' });
const there = bearerFixture({ const there = bearerFixture({
id: 'there', id: 'there',
url: buildNoteUrl('https://other.example/w', K1_A, 21_000), url: buildNoteUrl('https://other.example/w', K1_A, 21_000),
}) });
expect(mergeBearers([here], [there])).toHaveLength(2) expect(mergeBearers([here], [there])).toHaveLength(2);
}) });
}) });
describe('backup', () => { describe('backup', () => {
it('never exports a plaintext linking key', async () => { it('never exports a plaintext linking key', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
await saveLinkingKey(LINKING_KEY) // no password: stored plaintext await saveLinkingKey(LINKING_KEY); // no password: stored plaintext
await persistBearer(key, bearerFixture()) await persistBearer(key, bearerFixture());
const backup = buildBackup() const backup = buildBackup();
expect(backup.type).toBe('sattle-backup') expect(backup.type).toBe('sattle-backup');
expect(backup.linkingKey).toBeUndefined() expect(backup.linkingKey).toBeUndefined();
expect(backup.bearers).toHaveLength(1) expect(backup.bearers).toHaveLength(1);
}) });
it('exports the linking key when it is itself password-encrypted', async () => { it('exports the linking key when it is itself password-encrypted', async () => {
await saveLinkingKey(LINKING_KEY, 'hunter2') await saveLinkingKey(LINKING_KEY, 'hunter2');
const backup = buildBackup() const backup = buildBackup();
expect(backup.linkingKey?.enc).toBe(true) expect(backup.linkingKey?.enc).toBe(true);
}) });
it('merges a backup by record id - union, never overwrite', async () => { it('merges a backup by record id - union, never overwrite', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
await persistBearer(key, bearerFixture({id: 'existing'})) await persistBearer(key, bearerFixture({ id: 'existing' }));
// a backup holding the same record id plus a new one // a backup holding the same record id plus a new one
const backup = buildBackup() const backup = buildBackup();
const incoming = { const incoming = {
...backup, ...backup,
bearers: [ bearers: [
...backup.bearers, ...backup.bearers,
{ id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) }, { id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) },
], ],
} };
const result = await applyBackup(incoming) const result = await applyBackup(incoming);
expect(result.added).toBe(1) expect(result.added).toBe(1);
expect(result.skipped).toBe(1) expect(result.skipped).toBe(1);
expect( expect(
readEncryptedBearers() readEncryptedBearers()
.map((r) => r.id) .map((r) => r.id)
.sort(), .sort(),
).toEqual(['existing', 'from-backup']) ).toEqual(['existing', 'from-backup']);
}) });
it('restores the linking key only onto a device that has none', async () => { it('restores the linking key only onto a device that has none', async () => {
await saveLinkingKey(LINKING_KEY, 'hunter2') await saveLinkingKey(LINKING_KEY, 'hunter2');
const backup = buildBackup() const backup = buildBackup();
// same device: a key already exists, so the backup's key is skipped // same device: a key already exists, so the backup's key is skipped
const here = await applyBackup(backup) const here = await applyBackup(backup);
expect(here.linkingKeySkipped).toBe(true) expect(here.linkingKeySkipped).toBe(true);
expect(here.linkingKeyRestored).toBe(false) expect(here.linkingKeyRestored).toBe(false);
// fresh device: the key installs // fresh device: the key installs
stubLocalStorage() stubLocalStorage();
const fresh = await applyBackup(backup) const fresh = await applyBackup(backup);
expect(fresh.linkingKeyRestored).toBe(true) expect(fresh.linkingKeyRestored).toBe(true);
expect(fresh.linkingKeySkipped).toBe(false) expect(fresh.linkingKeySkipped).toBe(false);
}) });
it('rejects a file that is not a sattle backup', async () => { it('rejects a file that is not a sattle backup', async () => {
await expect( await expect(
applyBackup({ type: 'lnurlwallet-backup', version: 1, bearers: [] }), applyBackup({ type: 'lnurlwallet-backup', version: 1, bearers: [] }),
).rejects.toThrow() ).rejects.toThrow();
await expect(applyBackup(null)).rejects.toThrow() await expect(applyBackup(null)).rejects.toThrow();
await expect(applyBackup({type: 'sattle-backup', version: 1, bearers: []})).rejects.toThrow() await expect(applyBackup({ type: 'sattle-backup', version: 1, bearers: [] })).rejects.toThrow();
// a v2 file without the counter map is not a valid backup either // a v2 file without the counter map is not a valid backup either
await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow() await expect(applyBackup({ type: 'sattle-backup', version: 2, bearers: [] })).rejects.toThrow();
}) });
it('skips malformed records instead of failing the whole restore', async () => { it('skips malformed records instead of failing the whole restore', async () => {
const result = await applyBackup({ const result = await applyBackup({
@@ -250,8 +250,8 @@ describe('backup', () => {
'garbage', 'garbage',
], ],
nextByHost: {}, nextByHost: {},
}) });
expect(result.added).toBe(1) expect(result.added).toBe(1);
expect(result.skipped).toBe(2) expect(result.skipped).toBe(2);
}) });
}) });
+9 -9
View File
@@ -12,7 +12,7 @@
// storage/settings.ts - plaintext wallet settings // storage/settings.ts - plaintext wallet settings
// storage/backup.ts - buildBackup / applyBackup // storage/backup.ts - buildBackup / applyBackup
export type {Bearer, NewBearer} from './types' export type { Bearer, NewBearer } from './types';
export { export {
compareBearerOrder, compareBearerOrder,
@@ -31,7 +31,7 @@ export {
clearAllBearers, clearAllBearers,
mergeBearers, mergeBearers,
FUNDS_STORAGE_KEY, FUNDS_STORAGE_KEY,
} from './storage/bearers' } from './storage/bearers';
export type { export type {
BearerChangeset, BearerChangeset,
EncryptedBearerRecord, EncryptedBearerRecord,
@@ -41,7 +41,7 @@ export type {
PendingStaging, PendingStaging,
ReservedCashRange, ReservedCashRange,
StoredFundsV2, StoredFundsV2,
} from './storage/bearers' } from './storage/bearers';
export { export {
newActivityId, newActivityId,
@@ -50,11 +50,11 @@ export {
persistActivityEvent, persistActivityEvent,
clearAllActivity, clearAllActivity,
MAX_ACTIVITY_ENTRIES, MAX_ACTIVITY_ENTRIES,
} from './storage/activityLog' } from './storage/activityLog';
export type {ActivityKind, ActivityEvent, EncryptedActivityRecord} from './storage/activityLog' export type { ActivityKind, ActivityEvent, EncryptedActivityRecord } from './storage/activityLog';
export {loadSettings, persistSettings, clearSettings} from './storage/settings' export { loadSettings, persistSettings, clearSettings } from './storage/settings';
export type {WalletSettings} from './storage/settings' export type { WalletSettings } from './storage/settings';
export {buildBackup, applyBackup, parseBackupFile, MAX_BACKUP_FILE_BYTES} from './storage/backup' export { buildBackup, applyBackup, parseBackupFile, MAX_BACKUP_FILE_BYTES } from './storage/backup';
export type {BackupFile, RestoreResult} from './storage/backup' export type { BackupFile, RestoreResult } from './storage/backup';
+43 -43
View File
@@ -2,10 +2,10 @@
// AES-GCM under the same bearer key, append-only, capped so a wallet used // AES-GCM under the same bearer key, append-only, capped so a wallet used
// for years doesn't grow localStorage without limit. // for years doesn't grow localStorage without limit.
import type {EncryptedRecordParts} from '../keys' import type { EncryptedRecordParts } from '../keys';
import {encryptRecord, decryptRecord} from '../keys' import { encryptRecord, decryptRecord } from '../keys';
import {isJsonObject} from '../jsonParsing' import { isJsonObject } from '../jsonParsing';
import {withStorageLock} from '../storageLock' import { withStorageLock } from '../storageLock';
// `message` is the full human-readable sentence rather than structured // `message` is the full human-readable sentence rather than structured
// fields the UI reassembles, so the log stays simple to read and to extend // fields the UI reassembles, so the log stays simple to read and to extend
@@ -20,16 +20,16 @@ export type ActivityKind =
| 'spent' | 'spent'
| 'deleted' | 'deleted'
// a payment or mint initiated by a Nostr Wallet Connect client (M5) // a payment or mint initiated by a Nostr Wallet Connect client (M5)
| 'nwc' | 'nwc';
export type ActivityEvent = { export type ActivityEvent = {
id: string id: string;
kind: ActivityKind kind: ActivityKind;
message: string message: string;
createdAt: number createdAt: number;
} };
export type EncryptedActivityRecord = {id: string} & EncryptedRecordParts export type EncryptedActivityRecord = { id: string } & EncryptedRecordParts;
const isActivityKind = (value: unknown): value is ActivityKind => { const isActivityKind = (value: unknown): value is ActivityKind => {
switch (value) { switch (value) {
@@ -42,65 +42,65 @@ const isActivityKind = (value: unknown): value is ActivityKind => {
case 'spent': case 'spent':
case 'deleted': case 'deleted':
case 'nwc': case 'nwc':
return true return true;
default: default:
return false return false;
}
} }
};
const isEncryptedActivityRecord = (value: unknown): value is EncryptedActivityRecord => const isEncryptedActivityRecord = (value: unknown): value is EncryptedActivityRecord =>
isJsonObject(value) && isJsonObject(value) &&
typeof value.id === 'string' && typeof value.id === 'string' &&
typeof value.iv === 'string' && typeof value.iv === 'string' &&
typeof value.ciphertext === 'string' typeof value.ciphertext === 'string';
const isStoredActivity = (value: unknown): value is Omit<ActivityEvent, 'id'> => const isStoredActivity = (value: unknown): value is Omit<ActivityEvent, 'id'> =>
isJsonObject(value) && isJsonObject(value) &&
isActivityKind(value.kind) && isActivityKind(value.kind) &&
typeof value.message === 'string' && typeof value.message === 'string' &&
typeof value.createdAt === 'number' typeof value.createdAt === 'number';
const ACTIVITY_STORAGE_KEY = 'sattle_activity' const ACTIVITY_STORAGE_KEY = 'sattle_activity';
// bounds how far back the log ever reaches - the oldest entries simply // bounds how far back the log ever reaches - the oldest entries simply
// roll off once this many are kept // roll off once this many are kept
export const MAX_ACTIVITY_ENTRIES = 500 export const MAX_ACTIVITY_ENTRIES = 500;
export const newActivityId = (): string => export const newActivityId = (): string =>
Array.from(crypto.getRandomValues(new Uint8Array(8))) Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map((b) => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join('') .join('');
export const readEncryptedActivity = (): EncryptedActivityRecord[] => { export const readEncryptedActivity = (): EncryptedActivityRecord[] => {
const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY) const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY);
if (!raw) return [] if (!raw) return [];
try { try {
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(isEncryptedActivityRecord) : [] return Array.isArray(parsed) ? parsed.filter(isEncryptedActivityRecord) : [];
} catch { } catch {
return [] return [];
}
} }
};
const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => { const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => {
localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records)) localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records));
} };
// same tolerance as loadBearers - an entry that fails to decrypt with this // same tolerance as loadBearers - an entry that fails to decrypt with this
// key (written by a different seed) is skipped, not destroyed // key (written by a different seed) is skipped, not destroyed
export const loadActivity = async (aesKey: CryptoKey): Promise<ActivityEvent[]> => { export const loadActivity = async (aesKey: CryptoKey): Promise<ActivityEvent[]> => {
const events: ActivityEvent[] = [] const events: ActivityEvent[] = [];
for (const record of readEncryptedActivity()) { for (const record of readEncryptedActivity()) {
try { try {
const event = await decryptRecord(aesKey, record) const event = await decryptRecord(aesKey, record);
if (!isStoredActivity(event)) throw new Error('Malformed encrypted activity record.') if (!isStoredActivity(event)) throw new Error('Malformed encrypted activity record.');
events.push({...event, id: record.id}) events.push({ ...event, id: record.id });
} catch (error) { } catch (error) {
// undecryptable with this key - leave it in place // undecryptable with this key - leave it in place
if (!(error instanceof Error)) throw error if (!(error instanceof Error)) throw error;
} }
} }
return events.sort((a, b) => b.createdAt - a.createdAt) return events.sort((a, b) => b.createdAt - a.createdAt);
} };
// append-only (the log never edits or removes a single entry, only clears // append-only (the log never edits or removes a single entry, only clears
// outright - see clearAllActivity) - records are stored oldest-first so // outright - see clearAllActivity) - records are stored oldest-first so
@@ -109,15 +109,15 @@ export const persistActivityEvent = async (
aesKey: CryptoKey, aesKey: CryptoKey,
event: ActivityEvent, event: ActivityEvent,
): Promise<void> => { ): Promise<void> => {
const {id, ...plain} = event const { id, ...plain } = event;
const parts = await encryptRecord(aesKey, plain) const parts = await encryptRecord(aesKey, plain);
await withStorageLock(ACTIVITY_STORAGE_KEY, () => { await withStorageLock(ACTIVITY_STORAGE_KEY, () => {
const records = readEncryptedActivity() const records = readEncryptedActivity();
records.push({id, ...parts}) records.push({ id, ...parts });
writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES)) writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES));
}) });
} };
export const clearAllActivity = (): void => { export const clearAllActivity = (): void => {
localStorage.removeItem(ACTIVITY_STORAGE_KEY) localStorage.removeItem(ACTIVITY_STORAGE_KEY);
} };
+81 -81
View File
@@ -10,51 +10,51 @@
// atomic unit, so the backup projects both from the same single read - // atomic unit, so the backup projects both from the same single read -
// they can never disagree about which generation they came from. // they can never disagree about which generation they came from.
import type {StoredSecret} from '../keys' import type { StoredSecret } from '../keys';
import { import {
getSavedLinkingKeyStored, getSavedLinkingKeyStored,
savedKeyExists, savedKeyExists,
savedKeyIsEncrypted, savedKeyIsEncrypted,
restoreLinkingKeyStored, restoreLinkingKeyStored,
isValidStoredSecret, isValidStoredSecret,
} from '../keys' } from '../keys';
import type {TrustedMint} from '../trustedMints' import type { TrustedMint } from '../trustedMints';
import {readTrustedMints, mergeTrustedMints} from '../trustedMints' import { readTrustedMints, mergeTrustedMints } from '../trustedMints';
import {isWalletOwnerId} from './walletOwner' import { isWalletOwnerId } from './walletOwner';
import type {EncryptedBearerRecord} from './bearers' import type { EncryptedBearerRecord } from './bearers';
import {commitFundsRestore, readFundsDocument} from './bearers' import { commitFundsRestore, readFundsDocument } from './bearers';
import type {WalletSettings} from './settings' import type { WalletSettings } from './settings';
import {loadSettings, persistSettings} from './settings' import { loadSettings, persistSettings } from './settings';
import {isJsonObject} from '../jsonParsing' import { isJsonObject } from '../jsonParsing';
export type BackupFile = { export type BackupFile = {
type: 'sattle-backup' type: 'sattle-backup';
version: 2 version: 2;
createdAt: number createdAt: number;
ownerId?: unknown ownerId?: unknown;
linkingKey?: StoredSecret linkingKey?: StoredSecret;
bearers: EncryptedBearerRecord[] bearers: EncryptedBearerRecord[];
nextByHost: Record<string, number> nextByHost: Record<string, number>;
trustedMints?: TrustedMint[] trustedMints?: TrustedMint[];
settings?: WalletSettings settings?: WalletSettings;
} };
type ParsedBackupFile = { type ParsedBackupFile = {
type: 'sattle-backup' type: 'sattle-backup';
version: 2 version: 2;
createdAt?: unknown createdAt?: unknown;
ownerId?: unknown ownerId?: unknown;
linkingKey?: unknown linkingKey?: unknown;
bearers: unknown[] bearers: unknown[];
nextByHost: Record<string, unknown> nextByHost: Record<string, unknown>;
trustedMints?: unknown trustedMints?: unknown;
settings?: unknown settings?: unknown;
} };
export const buildBackup = (ownerId?: string): BackupFile => { export const buildBackup = (ownerId?: string): BackupFile => {
// one document, one getItem: bearers and counters are snapshot-consistent // one document, one getItem: bearers and counters are snapshot-consistent
// by construction; pending journal records are never read here // by construction; pending journal records are never read here
const funds = readFundsDocument() const funds = readFundsDocument();
const backup: BackupFile = { const backup: BackupFile = {
type: 'sattle-backup', type: 'sattle-backup',
version: 2, version: 2,
@@ -63,19 +63,19 @@ export const buildBackup = (ownerId?: string): BackupFile => {
nextByHost: funds.nextByHost, nextByHost: funds.nextByHost,
trustedMints: readTrustedMints(ownerId), trustedMints: readTrustedMints(ownerId),
settings: loadSettings(), settings: loadSettings(),
} };
if (isWalletOwnerId(ownerId)) backup.ownerId = ownerId if (isWalletOwnerId(ownerId)) backup.ownerId = ownerId;
const storedKey = getSavedLinkingKeyStored() const storedKey = getSavedLinkingKeyStored();
if (savedKeyIsEncrypted() && storedKey) { if (savedKeyIsEncrypted() && storedKey) {
backup.linkingKey = storedKey backup.linkingKey = storedKey;
}
return backup
} }
return backup;
};
export type RestoreResult = { export type RestoreResult = {
added: number added: number;
skipped: number skipped: number;
linkingKeyRestored: boolean linkingKeyRestored: boolean;
// true when the backup carried a linking key but this device already had // true when the backup carried a linking key but this device already had
// one, so it was deliberately NOT installed (see below) - distinct from // one, so it was deliberately NOT installed (see below) - distinct from
// "no key in this backup at all". The bearer records above still merged // "no key in this backup at all". The bearer records above still merged
@@ -83,36 +83,36 @@ export type RestoreResult = {
// whatever wallet is active on this device - unless that's the exact same // whatever wallet is active on this device - unless that's the exact same
// seed, they won't decrypt here, and the caller should say so rather than // seed, they won't decrypt here, and the caller should say so rather than
// let that read as a silent no-op. // let that read as a silent no-op.
linkingKeySkipped: boolean linkingKeySkipped: boolean;
trustedMintsAdded: number trustedMintsAdded: number;
// true when the backup's settings filled in a field this device had never // true when the backup's settings filled in a field this device had never
// set - never when it would overwrite one, same merge direction as the // set - never when it would overwrite one, same merge direction as the
// trusted mints (the device's own current state always wins) // trusted mints (the device's own current state always wins)
settingsRestored: boolean settingsRestored: boolean;
} };
// restore-time bounds - a crafted or corrupt file must not be able to fill // restore-time bounds - a crafted or corrupt file must not be able to fill
// localStorage with junk records that never decrypt (quota exhaustion turns // localStorage with junk records that never decrypt (quota exhaustion turns
// every later write into a failure, which can strand a just-rotated note), // every later write into a failure, which can strand a just-rotated note),
// nor hang the tab in JSON.parse. A real backup holds a handful of notes, // nor hang the tab in JSON.parse. A real backup holds a handful of notes,
// each well under a kilobyte encrypted, so these are generous // each well under a kilobyte encrypted, so these are generous
export const MAX_BACKUP_FILE_BYTES = 10 * 1024 * 1024 export const MAX_BACKUP_FILE_BYTES = 10 * 1024 * 1024;
const MAX_BACKUP_RECORDS = 10_000 const MAX_BACKUP_RECORDS = 10_000;
const MAX_BACKUP_FIELD_LENGTH = 64 * 1024 const MAX_BACKUP_FIELD_LENGTH = 64 * 1024;
const isBackupFile = (data: unknown): data is ParsedBackupFile => const isBackupFile = (data: unknown): data is ParsedBackupFile =>
isJsonObject(data) && isJsonObject(data) &&
data.type === 'sattle-backup' && data.type === 'sattle-backup' &&
data.version === 2 && data.version === 2 &&
Array.isArray(data.bearers) && Array.isArray(data.bearers) &&
isJsonObject(data.nextByHost) isJsonObject(data.nextByHost);
export const parseBackupFile = (data: unknown): ParsedBackupFile => { export const parseBackupFile = (data: unknown): ParsedBackupFile => {
if (!isBackupFile(data)) { if (!isBackupFile(data)) {
throw new Error('Not a valid sattle backup file.') throw new Error('Not a valid sattle backup file.');
}
return data
} }
return data;
};
// merges a backup into localStorage: bearer records are added by id // merges a backup into localStorage: bearer records are added by id
// (already present ids are left as-is - union, never overwrite) and counter // (already present ids are left as-is - union, never overwrite) and counter
@@ -129,14 +129,14 @@ export const parseBackupFile = (data: unknown): ParsedBackupFile => {
// note arriving under a different record id, spent-wins) happens after // note arriving under a different record id, spent-wins) happens after
// decrypt, in bearers.ts's mergeBearers. // decrypt, in bearers.ts's mergeBearers.
export const applyBackup = async (data: unknown, ownerId?: string): Promise<RestoreResult> => { export const applyBackup = async (data: unknown, ownerId?: string): Promise<RestoreResult> => {
const backup = parseBackupFile(data) const backup = parseBackupFile(data);
if (backup.bearers.length > MAX_BACKUP_RECORDS) { if (backup.bearers.length > MAX_BACKUP_RECORDS) {
throw new Error( throw new Error(
`Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.`, `Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.`,
) );
} }
const incomingBearers: EncryptedBearerRecord[] = [] const incomingBearers: EncryptedBearerRecord[] = [];
let skipped = 0 let skipped = 0;
for (const record of backup.bearers) { for (const record of backup.bearers) {
if ( if (
!isJsonObject(record) || !isJsonObject(record) ||
@@ -147,44 +147,44 @@ export const applyBackup = async (data: unknown, ownerId?: string): Promise<Rest
record.iv.length > MAX_BACKUP_FIELD_LENGTH || record.iv.length > MAX_BACKUP_FIELD_LENGTH ||
record.ciphertext.length > MAX_BACKUP_FIELD_LENGTH record.ciphertext.length > MAX_BACKUP_FIELD_LENGTH
) { ) {
skipped++ skipped++;
continue continue;
} }
incomingBearers.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext}) incomingBearers.push({ id: record.id, iv: record.iv, ciphertext: record.ciphertext });
} }
// candidate counters: validity (host length, safe range, host cap) is // candidate counters: validity (host length, safe range, host cap) is
// enforced by commitFundsRestore; here we only keep the raw shape honest // enforced by commitFundsRestore; here we only keep the raw shape honest
const incomingCounters: Record<string, number> = {} const incomingCounters: Record<string, number> = {};
for (const [host, next] of Object.entries(backup.nextByHost)) { for (const [host, next] of Object.entries(backup.nextByHost)) {
if (typeof next === 'number') incomingCounters[host] = next if (typeof next === 'number') incomingCounters[host] = next;
} }
let added: number let added: number;
try { try {
const merged = await commitFundsRestore(incomingBearers, incomingCounters) const merged = await commitFundsRestore(incomingBearers, incomingCounters);
added = merged.added added = merged.added;
skipped += merged.skipped skipped += merged.skipped;
} catch (error) { } catch (error) {
const name = error instanceof Error ? error.name : '' const name = error instanceof Error ? error.name : '';
const message = error instanceof Error ? error.message : '' const message = error instanceof Error ? error.message : '';
if (name === 'QuotaExceededError' || /quota|full/i.test(message)) { if (name === 'QuotaExceededError' || /quota|full/i.test(message)) {
throw new Error( throw new Error(
'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.', 'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.',
{ cause: error }, { cause: error },
) );
} }
throw error throw error;
} }
let linkingKeyRestored = false let linkingKeyRestored = false;
let linkingKeySkipped = false let linkingKeySkipped = false;
// an invalid key record reads as "no key in this backup", never as skipped // an invalid key record reads as "no key in this backup", never as skipped
if (isValidStoredSecret(backup.linkingKey)) { if (isValidStoredSecret(backup.linkingKey)) {
if (savedKeyExists()) { if (savedKeyExists()) {
linkingKeySkipped = true linkingKeySkipped = true;
} else { } else {
restoreLinkingKeyStored(backup.linkingKey) restoreLinkingKeyStored(backup.linkingKey);
linkingKeyRestored = true linkingKeyRestored = true;
} }
} }
@@ -194,22 +194,22 @@ export const applyBackup = async (data: unknown, ownerId?: string): Promise<Rest
const trustedMintsAdded = const trustedMintsAdded =
ownerId && Array.isArray(backup.trustedMints) ownerId && Array.isArray(backup.trustedMints)
? await mergeTrustedMints(backup.trustedMints, ownerId) ? await mergeTrustedMints(backup.trustedMints, ownerId)
: 0 : 0;
// settings merge: fill only fields this device has never set. Flat // settings merge: fill only fields this device has never set. Flat
// optional fields (see settings.ts), so the merge is field by field - // optional fields (see settings.ts), so the merge is field by field -
// today that is just defaultMint // today that is just defaultMint
let settingsRestored = false let settingsRestored = false;
if (isJsonObject(backup.settings)) { if (isJsonObject(backup.settings)) {
const incoming = backup.settings.defaultMint const incoming = backup.settings.defaultMint;
const local = loadSettings() const local = loadSettings();
if ( if (
local.defaultMint === undefined && local.defaultMint === undefined &&
typeof incoming === 'string' && typeof incoming === 'string' &&
incoming.length <= MAX_BACKUP_FIELD_LENGTH incoming.length <= MAX_BACKUP_FIELD_LENGTH
) { ) {
persistSettings({...local, defaultMint: incoming}) persistSettings({ ...local, defaultMint: incoming });
settingsRestored = true settingsRestored = true;
} }
} }
@@ -220,5 +220,5 @@ export const applyBackup = async (data: unknown, ownerId?: string): Promise<Rest
linkingKeySkipped, linkingKeySkipped,
trustedMintsAdded, trustedMintsAdded,
settingsRestored, settingsRestored,
} };
} };
+20 -20
View File
@@ -1,13 +1,13 @@
import {describe, expect, it, vi} from 'vitest' import { describe, expect, it, vi } from 'vitest';
import {buildNoteUrl} from 'lnurlcash-kit' import { buildNoteUrl } from 'lnurlcash-kit';
import type {Bearer} from '../types' import type { Bearer } from '../types';
import {deriveBearerAesKey} from '../keys' import { deriveBearerAesKey } from '../keys';
import {deleteBearerRecord, newBearerId, persistBearer} from '../storage' import { deleteBearerRecord, newBearerId, persistBearer } from '../storage';
import {FUNDS_STORAGE_KEY} from './bearers' import { FUNDS_STORAGE_KEY } from './bearers';
import {stubLocalStorage} from '../test-utils' import { stubLocalStorage } from '../test-utils';
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7);
const K1 = 'aa'.repeat(32) const K1 = 'aa'.repeat(32);
const bearerFixture = (): Bearer => ({ const bearerFixture = (): Bearer => ({
id: newBearerId(), id: newBearerId(),
url: buildNoteUrl('https://mint.example/w', K1, 21_000), url: buildNoteUrl('https://mint.example/w', K1, 21_000),
@@ -16,18 +16,18 @@ const bearerFixture = (): Bearer => ({
verified: true, verified: true,
createdAt: 1000, createdAt: 1000,
updatedAt: 1000, updatedAt: 1000,
}) });
describe('baseline: per-record bearer persistence', () => { describe('baseline: per-record bearer persistence', () => {
it('persistBearer/deleteBearerRecord perform one write per call', async () => { it('persistBearer/deleteBearerRecord perform one write per call', async () => {
const storage = stubLocalStorage() const storage = stubLocalStorage();
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
}) });
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY);
const writes = vi.spyOn(storage, 'setItem') const writes = vi.spyOn(storage, 'setItem');
await persistBearer(key, {...bearerFixture(), id: 'a'}) await persistBearer(key, { ...bearerFixture(), id: 'a' });
await persistBearer(key, {...bearerFixture(), id: 'b'}) await persistBearer(key, { ...bearerFixture(), id: 'b' });
await deleteBearerRecord('a') await deleteBearerRecord('a');
expect(writes.mock.calls.filter(([keyName]) => keyName === FUNDS_STORAGE_KEY)).toHaveLength(3) expect(writes.mock.calls.filter(([keyName]) => keyName === FUNDS_STORAGE_KEY)).toHaveLength(3);
}) });
}) });

Some files were not shown because too many files have changed in this diff Show More