From ee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 16 Feb 2026 11:51:41 -0800 Subject: passkey auth --- bun.lock | 43 ++++++ index.ts | 4 + lib/passkey.ts | 134 ++++++++++++++++ lib/register.ts | 4 +- lib/whitelist.ts | 1 + package.json | 1 + routes/payments.ts | 31 +++- routes/users.ts | 58 +++++++ test/forward.test.ts | 162 ++++++++++++++++++++ test/integration/ark-reconcile.test.ts | 272 +++++++++++++++++++++++++++++++++ 10 files changed, 708 insertions(+), 2 deletions(-) create mode 100644 lib/passkey.ts create mode 100644 test/integration/ark-reconcile.test.ts diff --git a/bun.lock b/bun.lock index 9c5de8d0..755fa0e7 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1", "@scure/btc-signer": "^2.0.1", + "@simplewebauthn/server": "^13.2.2", "bech32": "^2.0.0", "clightning-client": "github:asoltys/lightning-client-js#master", "eventsource": "^4.1.0", @@ -216,6 +217,8 @@ "@fastify/websocket": ["@fastify/websocket@11.2.0", "", { "dependencies": { "duplexify": "^4.1.3", "fastify-plugin": "^5.0.0", "ws": "^8.16.0" } }, "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -260,6 +263,8 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], "@marcbachmann/cel-js": ["@marcbachmann/cel-js@7.3.1", "", { "bin": { "cel-evaluate": "bin/cel-evaluate.js" } }, "sha512-P6o26TvjStT8V8+8EF+yq9Pp7ZFV00bpiUMbssr76XbIZGxaB+NNWeBp6WNxOrR9gp0JPzvJueCKHpOs5LE9PQ=="], @@ -282,6 +287,30 @@ "@otplib/preset-v11": ["@otplib/preset-v11@12.0.1", "", { "dependencies": { "@otplib/core": "^12.0.1", "@otplib/plugin-crypto": "^12.0.1", "@otplib/plugin-thirty-two": "^12.0.1" } }, "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg=="], + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pfx": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@redis/bloom": ["@redis/bloom@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A=="], @@ -304,6 +333,8 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + "@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="], @@ -422,6 +453,8 @@ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], + "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -900,6 +933,10 @@ "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qs": ["qs@6.11.0", "", { "dependencies": { "side-channel": "^1.0.4" } }, "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -920,6 +957,8 @@ "redis": ["redis@5.10.0", "", { "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", "@redis/json": "5.10.0", "@redis/search": "5.10.0", "@redis/time-series": "5.10.0" } }, "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "require-addon": ["require-addon@1.2.0", "", { "dependencies": { "bare-addon-resolve": "^1.3.0" } }, "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1034,6 +1073,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], @@ -1184,6 +1225,8 @@ "socks-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], diff --git a/index.ts b/index.ts index 9e7beefd..c5c6b398 100644 --- a/index.ts +++ b/index.ts @@ -164,6 +164,10 @@ app.post( app.post("/flash", users.flash); app.get("/challenge", users.challenge); app.post("/nostrAuth", users.nostrAuth); +app.post("/passkey/register/options", auth, users.passkeyRegisterOptions); +app.post("/passkey/register/verify", auth, users.passkeyRegisterVerify); +app.post("/passkey/login/options", users.passkeyLoginOptions); +app.post("/passkey/login/verify", users.passkeyLoginVerify); app.get("/app/:pubkey", auth, users.app); app.get("/apps", auth, users.apps); app.post("/app", auth, users.updateApp); diff --git a/lib/passkey.ts b/lib/passkey.ts new file mode 100644 index 00000000..b2cb96b4 --- /dev/null +++ b/lib/passkey.ts @@ -0,0 +1,134 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import type { + AuthenticatorTransportFuture, +} from "@simplewebauthn/types"; +import { db, g, s } from "$lib/db"; +import { fail, getUser } from "$lib/utils"; +import { v4 } from "uuid"; + +const rpName = "coinos"; + +function getRpID(origin: string) { + try { + return new URL(origin).hostname; + } catch { + return "localhost"; + } +} + +export async function generatePasskeyRegistration(user: any, origin: string) { + const rpID = getRpID(origin); + const passkeys = user.passkeys || []; + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: user.username, + userID: new TextEncoder().encode(user.id), + attestationType: "none", + excludeCredentials: passkeys.map((p: any) => ({ + id: p.credentialID, + transports: p.transports as AuthenticatorTransportFuture[], + })), + authenticatorSelection: { + residentKey: "required", + userVerification: "preferred", + }, + }); + + await db.set(`challenge:${user.id}`, options.challenge, { EX: 300 }); + + return options; +} + +export async function verifyPasskeyRegistration(user: any, response: any, origin: string) { + const rpID = getRpID(origin); + const expectedChallenge = await db.get(`challenge:${user.id}`); + if (!expectedChallenge) fail("Challenge expired"); + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + }); + + if (!verification.verified || !verification.registrationInfo) { + fail("Passkey verification failed"); + } + + await db.del(`challenge:${user.id}`); + + const { credential } = verification.registrationInfo; + + const cred = { + credentialID: credential.id, + credentialPublicKey: Buffer.from(credential.publicKey).toString("base64url"), + counter: credential.counter, + transports: response.response.transports || [], + createdAt: Date.now(), + }; + + await db.set(`passkey:${credential.id}`, user.id); + + return cred; +} + +export async function generatePasskeyLogin(origin: string) { + const rpID = getRpID(origin); + + const options = await generateAuthenticationOptions({ + rpID, + userVerification: "preferred", + }); + + const challengeId = v4(); + await db.set(`challenge:passkey:${challengeId}`, options.challenge, { EX: 300 }); + + return { ...options, challengeId }; +} + +export async function verifyPasskeyLogin(response: any, challengeId: string, origin: string) { + const rpID = getRpID(origin); + const userId = await db.get(`passkey:${response.id}`); + if (!userId) fail("Passkey not recognized"); + + const user = await g(`user:${userId}`); + if (!user) fail("User not found"); + + const passkeys = user.passkeys || []; + const passkey = passkeys.find( + (p: any) => p.credentialID === response.id, + ); + if (!passkey) fail("Passkey not found"); + + const expectedChallenge = await db.get(`challenge:passkey:${challengeId}`); + if (!expectedChallenge) fail("Challenge expired"); + + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: passkey.credentialID, + publicKey: Buffer.from(passkey.credentialPublicKey, "base64url"), + counter: passkey.counter, + transports: passkey.transports as AuthenticatorTransportFuture[], + }, + }); + + if (!verification.verified) fail("Passkey authentication failed"); + + await db.del(`challenge:passkey:${challengeId}`); + + passkey.counter = verification.authenticationInfo.newCounter; + await s(`user:${user.id}`, user); + + return user; +} diff --git a/lib/register.ts b/lib/register.ts index 816782c1..e99238b1 100644 --- a/lib/register.ts +++ b/lib/register.ts @@ -58,12 +58,14 @@ export default async (user, ip) => { user.migrated = true; user.locktime = 300; + user.passkeys = []; + let sk; if (!pubkey) { sk = randomBytes(32); pubkey = getPublicKey(sk); user.pubkey = pubkey; - user.nsec = nip49encrypt(sk, password); + if (password) user.nsec = nip49encrypt(sk, password); } user.npub = nip19.npubEncode(pubkey); diff --git a/lib/whitelist.ts b/lib/whitelist.ts index ab34be9f..c3094c6b 100644 --- a/lib/whitelist.ts +++ b/lib/whitelist.ts @@ -32,6 +32,7 @@ export default [ "npub", "nsec", "nwc", + "passkeys", "payments", "picture", "profile", diff --git a/package.json b/package.json index 16e147d6..f93facad 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1", "@scure/btc-signer": "^2.0.1", + "@simplewebauthn/server": "^13.2.2", "bech32": "^2.0.0", "clightning-client": "github:asoltys/lightning-client-js#master", "eventsource": "^4.1.0", diff --git a/routes/payments.ts b/routes/payments.ts index 4bdd0d30..c77da697 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -933,8 +933,9 @@ export default { async arkSync(req, res) { try { const { user } = req; - const { transactions, aid } = req.body; + const { transactions = [], aid } = req.body; const { id: uid } = user; + l("arkSync", user.username, aid, "txs:", transactions.length, "bal:", req.body.balance); // Per-account lock prevents concurrent syncs from racing const lockKey = `arksynclock:${aid}`; @@ -1003,6 +1004,34 @@ export default { } } + // Reconcile expired VTXOs + const { balance } = req.body; + if (typeof balance === "number" && balance >= 0) { + const paymentIds = await db.lRange(`${aid}:payments`, 0, -1); + let expectedBalance = 0; + for (const pid of paymentIds) { + const pay = await g(`payment:${pid}`); + if (pay) expectedBalance += pay.amount; + } + + if (expectedBalance > balance) { + const p = await createArkPayment({ + aid, + uid, + amount: balance - expectedBalance, + hash: `expired-${Date.now()}`, + rate, + currency, + created: Date.now(), + extraHashMappings: [], + }); + p.memo = "expired"; + await s(`payment:${p.id}`, p); + payments.push(p); + synced++; + } + } + // Check for pending custodial ark invoice to forward let forward; if (received > 0) { diff --git a/routes/users.ts b/routes/users.ts index 904a5bb0..8d67444d 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -6,6 +6,12 @@ import { db, g, ga, gf, s, scan } from "$lib/db"; import { err, l, warn } from "$lib/logging"; import { mail, templates } from "$lib/mail"; import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr"; +import { + generatePasskeyRegistration, + verifyPasskeyRegistration, + generatePasskeyLogin, + verifyPasskeyLogin, +} from "$lib/passkey"; import register from "$lib/register"; import { emit } from "$lib/sockets"; import upload from "$lib/upload"; @@ -1164,6 +1170,58 @@ export default { res.send({}); }, + async passkeyRegisterOptions(req, res) { + try { + const { user } = req; + const origin = req.body.origin || `https://${config.hostname}`; + const options = await generatePasskeyRegistration(user, origin); + res.send(options); + } catch (e) { + console.log(e); + bail(res, e.message); + } + }, + + async passkeyRegisterVerify(req, res) { + try { + const { user } = req; + const origin = req.body.origin || `https://${config.hostname}`; + const credential = await verifyPasskeyRegistration(user, req.body, origin); + if (!user.passkeys) user.passkeys = []; + user.passkeys.push(credential); + await s(`user:${user.id}`, user); + res.send({ ok: true }); + } catch (e) { + console.log(e); + bail(res, e.message); + } + }, + + async passkeyLoginOptions(req, res) { + try { + const origin = req.body.origin || `https://${config.hostname}`; + const options = await generatePasskeyLogin(origin); + res.send(options); + } catch (e) { + console.log(e); + bail(res, e.message); + } + }, + + async passkeyLoginVerify(req, res) { + try { + const { credential, challengeId, origin: reqOrigin } = req.body; + const origin = reqOrigin || `https://${config.hostname}`; + const user = await verifyPasskeyLogin(credential, challengeId, origin); + const payload = { id: user.id }; + const token = jwt.sign(payload, config.jwt); + res.send({ user: pick(user, whitelist), token }); + } catch (e) { + console.log(e); + bail(res, e.message); + } + }, + async credits(req, res) { const { id } = req.user; const bitcoin = await getCredit(id, "bitcoin"); diff --git a/test/forward.test.ts b/test/forward.test.ts index 7f548b67..692c5b1e 100644 --- a/test/forward.test.ts +++ b/test/forward.test.ts @@ -376,6 +376,168 @@ describe("arkSync", () => { }); }); +// ===================================================================== +// Tests: arkSync expired VTXO reconciliation +// ===================================================================== + +describe("arkSync reconciliation", () => { + beforeEach(resetAll); + + test("creates expired debit when payment sum exceeds wallet balance", async () => { + // Pre-seed two received payments totaling 7128 + const pay1 = { id: "pay-1", aid: arkAccountId, amount: 5000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() - 2000 }; + const pay2 = { id: "pay-2", aid: arkAccountId, amount: 2128, hash: "h2", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() - 1000 }; + store().kvStore[`payment:pay-1`] = JSON.stringify(pay1); + store().kvStore[`payment:pay-2`] = JSON.stringify(pay2); + store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1"); + store().kvStore[`payment:${arkAccountId}:h2`] = JSON.stringify("pay-2"); + store().listStore[`${arkAccountId}:payments`] = ["pay-2", "pay-1"]; + + // Sync with no new transactions but balance=0 (VTXOs expired) + const req = { + body: { + aid: arkAccountId, + transactions: [], + balance: 0, + }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkSync(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent.synced).toBe(1); + expect(sent.payments).toHaveLength(1); + + const reconPayment = sent.payments[0]; + expect(reconPayment.amount).toBe(-7128); + expect(reconPayment.memo).toBe("expired"); + expect(reconPayment.type).toBe("ark"); + + // Payment list should now have 3 entries + expect(store().listStore[`${arkAccountId}:payments`]).toHaveLength(3); + }); + + test("does not reconcile when sum matches balance", async () => { + // Pre-seed a payment of 5000 + const pay1 = { id: "pay-1", aid: arkAccountId, amount: 5000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() }; + store().kvStore[`payment:pay-1`] = JSON.stringify(pay1); + store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1"); + store().listStore[`${arkAccountId}:payments`] = ["pay-1"]; + + const req = { + body: { + aid: arkAccountId, + transactions: [], + balance: 5000, + }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkSync(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent.synced).toBe(0); + expect(sent.payments).toHaveLength(0); + expect(store().listStore[`${arkAccountId}:payments`]).toHaveLength(1); + }); + + test("does not reconcile when balance is not provided (backward compat)", async () => { + const pay1 = { id: "pay-1", aid: arkAccountId, amount: 5000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() }; + store().kvStore[`payment:pay-1`] = JSON.stringify(pay1); + store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1"); + store().listStore[`${arkAccountId}:payments`] = ["pay-1"]; + + const req = { + body: { + aid: arkAccountId, + transactions: [], + }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkSync(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent.synced).toBe(0); + expect(sent.payments).toHaveLength(0); + }); + + test("is idempotent — second sync does not create duplicate", async () => { + // Seed payment sum = 5000, balance = 0 + const pay1 = { id: "pay-1", aid: arkAccountId, amount: 5000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() }; + store().kvStore[`payment:pay-1`] = JSON.stringify(pay1); + store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1"); + store().listStore[`${arkAccountId}:payments`] = ["pay-1"]; + + const req = { + body: { aid: arkAccountId, transactions: [], balance: 0 }, + user: makeUser(), + }; + + // First sync — should create reconciliation + const res1 = makeRes(); + await routes.arkSync(req as any, res1 as any); + const sent1 = (res1 as any).getSent(); + expect(sent1.synced).toBe(1); + expect(sent1.payments[0].amount).toBe(-5000); + + // Second sync — sum is now 0, matches balance, no new reconciliation + const res2 = makeRes(); + await routes.arkSync(req as any, res2 as any); + const sent2 = (res2 as any).getSent(); + expect(sent2.synced).toBe(0); + expect(sent2.payments).toHaveLength(0); + }); + + test("reconciles partial expiry", async () => { + // 10000 received, 3000 sent, sum = 7000. Actual balance = 2000 (5000 expired) + const pay1 = { id: "pay-1", aid: arkAccountId, amount: 10000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() }; + const pay2 = { id: "pay-2", aid: arkAccountId, amount: -3000, hash: "h2", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() }; + store().kvStore[`payment:pay-1`] = JSON.stringify(pay1); + store().kvStore[`payment:pay-2`] = JSON.stringify(pay2); + store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1"); + store().kvStore[`payment:${arkAccountId}:h2`] = JSON.stringify("pay-2"); + store().listStore[`${arkAccountId}:payments`] = ["pay-2", "pay-1"]; + + const req = { + body: { aid: arkAccountId, transactions: [], balance: 2000 }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkSync(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent.synced).toBe(1); + expect(sent.payments[0].amount).toBe(-5000); + expect(sent.payments[0].memo).toBe("expired"); + }); + + test("does not reconcile when sum is less than balance", async () => { + // Sum = 3000, balance = 5000 (e.g., recovered VTXOs already synced) + const pay1 = { id: "pay-1", aid: arkAccountId, amount: 3000, hash: "h1", confirmed: true, type: "ark", uid: custodialUid, rate: 50000, currency: "USD", created: Date.now() }; + store().kvStore[`payment:pay-1`] = JSON.stringify(pay1); + store().kvStore[`payment:${arkAccountId}:h1`] = JSON.stringify("pay-1"); + store().listStore[`${arkAccountId}:payments`] = ["pay-1"]; + + const req = { + body: { aid: arkAccountId, transactions: [], balance: 5000 }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkSync(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent.synced).toBe(0); + expect(sent.payments).toHaveLength(0); + }); +}); + // ===================================================================== // Tests: BOLT12 offer autowithdraw // ===================================================================== diff --git a/test/integration/ark-reconcile.test.ts b/test/integration/ark-reconcile.test.ts new file mode 100644 index 00000000..5e2061e6 --- /dev/null +++ b/test/integration/ark-reconcile.test.ts @@ -0,0 +1,272 @@ +process.env.INTEGRATION = "1"; + +import { describe, test, expect, beforeAll } from "bun:test"; + +// ===================================================================== +// Helpers +// ===================================================================== + +const APP = "http://localhost:3119"; + +const api = async (path: string, token: string, opts: any = {}) => { + const res = await fetch(`${APP}${path}`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...opts.headers, + }, + ...opts, + }); + return res.json(); +}; + +const register = async (username: string, password: string) => { + const res = await fetch(`${APP}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user: { username, password } }), + }); + if (!res.ok) throw new Error(`register failed: ${await res.text()}`); + return res.json(); +}; + +const login = async (username: string, password: string) => { + const res = await fetch(`${APP}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) throw new Error(`login failed: ${await res.text()}`); + return res.json(); +}; + +const createInvoice = (token: string, invoice: any) => + api("/invoice", token, { + method: "POST", + body: JSON.stringify({ invoice }), + }); + +const getPayments = (token: string, aid?: string) => + api(`/payments${aid ? `?aid=${aid}` : ""}`, token); + +// ===================================================================== +// Test state +// ===================================================================== + +let token: string; +let user: any; +let arkAccountId: string; + +const ts = Math.random().toString(36).slice(2, 8); +const username = `arkrec${ts}`; + +// ===================================================================== +// Setup +// ===================================================================== + +beforeAll(async () => { + const result = await register(username, "testpass"); + token = result.token; + user = result.user; + + // Create an ark account for this user + const res = await fetch(`${APP}/accounts`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ type: "ark", name: "ark vault" }), + }); + if (res.ok) { + const account = await res.json(); + arkAccountId = account.id; + } else { + // Fallback: use user's own ID as the account + arkAccountId = user.id; + } +}); + +// ===================================================================== +// Tests +// ===================================================================== + +describe("ark sync reconciliation", () => { + test("syncs new transactions and creates payment records", async () => { + const hash = `test-recv-${Date.now()}`; + const result = await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [ + { + arkTxid: hash, + commitmentTxid: null, + amount: 10000, + settled: true, + createdAt: Date.now(), + }, + ], + balance: 10000, + }), + }); + + expect(result.synced).toBe(1); + expect(result.received).toBe(10000); + expect(result.payments).toHaveLength(1); + expect(result.payments[0].amount).toBe(10000); + }); + + test("deduplicates already-synced transactions", async () => { + // Sync the same hash again — should be skipped + const hash = `test-recv-${Date.now()}`; + + // First sync + await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [ + { arkTxid: hash, amount: 3000, settled: true, createdAt: Date.now() }, + ], + balance: 13000, + }), + }); + + // Second sync with same hash + const result = await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [ + { arkTxid: hash, amount: 3000, settled: true, createdAt: Date.now() }, + ], + balance: 13000, + }), + }); + + expect(result.synced).toBe(0); + expect(result.payments).toHaveLength(0); + }); + + test("creates expired debit when balance < payment sum", async () => { + // Current payment sum is 13000 (10000 + 3000 from previous tests) + // Send balance=0 to simulate all VTXOs expired + const result = await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [], + balance: 0, + }), + }); + + expect(result.synced).toBe(1); + expect(result.payments).toHaveLength(1); + + const expired = result.payments[0]; + expect(expired.amount).toBe(-13000); + expect(expired.memo).toBe("expired"); + expect(expired.type).toBe("ark"); + }); + + test("is idempotent — no duplicate expired debit on re-sync", async () => { + // Sum is now 0 (13000 - 13000), balance is still 0 + const result = await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [], + balance: 0, + }), + }); + + expect(result.synced).toBe(0); + expect(result.payments).toHaveLength(0); + }); + + test("new receive after expiry works correctly", async () => { + // Simulate VTXOs recovered and new receive + const hash = `test-recovery-${Date.now()}`; + const result = await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [ + { arkTxid: hash, amount: 5000, settled: true, createdAt: Date.now() }, + ], + balance: 5000, + }), + }); + + expect(result.synced).toBe(1); + expect(result.received).toBe(5000); + + // Sum is now 5000 (13000 - 13000 + 5000), matches balance=5000 + // No reconciliation needed + expect( + result.payments.filter((p: any) => p.memo === "expired"), + ).toHaveLength(0); + }); + + test("partial expiry creates correct debit", async () => { + // Balance dropped from 5000 to 2000 (3000 worth of VTXOs expired) + const result = await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [], + balance: 2000, + }), + }); + + expect(result.synced).toBe(1); + expect(result.payments).toHaveLength(1); + expect(result.payments[0].amount).toBe(-3000); + expect(result.payments[0].memo).toBe("expired"); + }); + + test("matches ark invoice when receiving", async () => { + // Create an ark invoice first + const inv = await createInvoice(token, { + type: "ark", + amount: 2000, + aid: arkAccountId, + }); + expect(inv.id).toBeTruthy(); + + const hash = `test-invoice-recv-${Date.now()}`; + const result = await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [ + { arkTxid: hash, amount: 2000, settled: true, createdAt: Date.now() }, + ], + balance: 4000, // previous 2000 + new 2000 + }), + }); + + expect(result.synced).toBe(1); + expect(result.received).toBe(2000); + + // Payment should be matched to the invoice + const matched = result.payments.find((p: any) => p.iid === inv.id); + expect(matched).toBeTruthy(); + expect(matched.amount).toBe(2000); + }); + + test("backward compat — no reconciliation without balance field", async () => { + // Call without balance field (old client) + const result = await api("/ark/sync", token, { + method: "POST", + body: JSON.stringify({ + aid: arkAccountId, + transactions: [], + }), + }); + + expect(result.synced).toBe(0); + expect(result.payments).toHaveLength(0); + }); +}); -- cgit v1.2.3