aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 11:51:41 -0800
committerAdam Soltys <[email protected]>2026-02-16 11:51:41 -0800
commitee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40 (patch)
tree04d097d676c4c0a48287f631a539b4055522dcfe
parent20cee249905ee6975e88089b76b2eccb4019e19a (diff)
downloadcoinos-server-ee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40.tar.gz
coinos-server-ee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40.zip
passkey auth
-rw-r--r--bun.lock43
-rw-r--r--index.ts4
-rw-r--r--lib/passkey.ts134
-rw-r--r--lib/register.ts4
-rw-r--r--lib/whitelist.ts1
-rw-r--r--package.json1
-rw-r--r--routes/payments.ts31
-rw-r--r--routes/users.ts58
-rw-r--r--test/forward.test.ts162
-rw-r--r--test/integration/ark-reconcile.test.ts272
10 files changed, 708 insertions, 2 deletions
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/[email protected]", "", { "dependencies": { "duplexify": "^4.1.3", "fastify-plugin": "^5.0.0", "ws": "^8.16.0" } }, "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w=="],
+ "@hexagon/base64": ["@hexagon/[email protected]", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
+
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"@img/sharp-darwin-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
@@ -260,6 +263,8 @@
"@keyv/serialize": ["@keyv/[email protected]", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
+ "@levischuck/tiny-cbor": ["@levischuck/[email protected]", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
+
"@lukeed/ms": ["@lukeed/[email protected]", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@marcbachmann/cel-js": ["@marcbachmann/[email protected]", "", { "bin": { "cel-evaluate": "bin/cel-evaluate.js" } }, "sha512-P6o26TvjStT8V8+8EF+yq9Pp7ZFV00bpiUMbssr76XbIZGxaB+NNWeBp6WNxOrR9gp0JPzvJueCKHpOs5LE9PQ=="],
@@ -282,6 +287,30 @@
"@otplib/preset-v11": ["@otplib/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
+
+ "@peculiar/asn1-cms": ["@peculiar/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
+
+ "@peculiar/asn1-x509": ["@peculiar/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@redis/bloom": ["@redis/[email protected]", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A=="],
@@ -304,6 +333,8 @@
"@sec-ant/readable-stream": ["@sec-ant/[email protected]", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
+ "@simplewebauthn/server": ["@simplewebauthn/[email protected]", "", { "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/[email protected]", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@smithy/abort-controller": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="],
@@ -422,6 +453,8 @@
"asn1.js": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
+
"async": ["[email protected]", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
"asynckit": ["[email protected]", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
@@ -900,6 +933,10 @@
"pump": ["[email protected]", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
+ "pvtsutils": ["[email protected]", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
+
+ "pvutils": ["[email protected]", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
+
"qs": ["[email protected]", "", { "dependencies": { "side-channel": "^1.0.4" } }, "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q=="],
"quick-format-unescaped": ["[email protected]", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
@@ -920,6 +957,8 @@
"redis": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
+
"require-addon": ["[email protected]", "", { "dependencies": { "bare-addon-resolve": "^1.3.0" } }, "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA=="],
"require-from-string": ["[email protected]", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -1034,6 +1073,8 @@
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "tsyringe": ["[email protected]", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
+
"type-fest": ["[email protected]", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["[email protected]", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
@@ -1184,6 +1225,8 @@
"socks-proxy-agent/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "tsyringe/tslib": ["[email protected]", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
+
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "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/[email protected]", "", { "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
@@ -377,6 +377,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);
+ });
+});