diff --git a/auth-proxy/src/index.ts b/auth-proxy/src/index.ts index a87e380..d2faa4b 100644 --- a/auth-proxy/src/index.ts +++ b/auth-proxy/src/index.ts @@ -109,8 +109,14 @@ app.post<{ Body: { cashaddr: string; state?: string; redirect_uri?: string }; }>("/challenge", async (req, reply) => { const { cashaddr, state, redirect_uri } = req.body ?? {}; - if (!cashaddr || !cashaddr.startsWith("bitcoincash:")) { - return reply.code(400).send({ error: "cashaddr must start with bitcoincash:" }); + // Accept any CashAddr prefix the chain actually uses: + // bitcoincash: (mainnet) + // bchtest: (chipnet / testnet — what Silent Mode's whole stack runs on today) + // bchreg: (regtest) + if (!cashaddr || !/^(bitcoincash|bchtest|bchreg):/.test(cashaddr)) { + return reply.code(400).send({ + error: "cashaddr must start with bitcoincash:, bchtest: or bchreg:", + }); } const rec = createChallenge(cashaddr, state, redirect_uri); const message = buildChallenge({ @@ -123,10 +129,14 @@ app.post<{ }); // --- Signature verification & OAuth code mint --- +// `state` in the body is a fallback so scripted callers who bypass /challenge's +// state-passing (or use their own OAuth kickoff wrapper) can still get RFC 6749 +// §4.1.2 compliant callbacks. If state was stored via /challenge it's used +// automatically; explicit body override wins if both are present. app.post<{ - Body: { nonce: string; signature: string }; + Body: { nonce: string; signature: string; state?: string; redirect_uri?: string }; }>("/verify", async (req, reply) => { - const { nonce, signature } = req.body ?? {}; + const { nonce, signature, state: stateOverride, redirect_uri: redirectOverride } = req.body ?? {}; if (!nonce || !signature) { return reply.code(400).send({ error: "nonce and signature required" }); } @@ -142,11 +152,12 @@ app.post<{ const recovered = verifyBchSignedMessage(rec.cashaddr, message, signature); if (!recovered) return reply.code(401).send({ error: "signature mismatch" }); - const redirectUri = rec.redirectUri ?? FORGEJO_REDIRECT_URI; + const redirectUri = redirectOverride ?? rec.redirectUri ?? FORGEJO_REDIRECT_URI; const code = mintAuthCode(recovered, CLIENT_ID, redirectUri); const url = new URL(redirectUri); url.searchParams.set("code", code); - if (rec.clientState) url.searchParams.set("state", rec.clientState); + const stateToEcho = stateOverride ?? rec.clientState; + if (stateToEcho) url.searchParams.set("state", stateToEcho); return { redirect: url.toString() }; }); diff --git a/auth-proxy/src/verify.ts b/auth-proxy/src/verify.ts index a55d384..76e266b 100644 --- a/auth-proxy/src/verify.ts +++ b/auth-proxy/src/verify.ts @@ -62,13 +62,28 @@ function magicHash(message: string): Uint8Array { /** * Verify a base64 recoverable-ECDSA signature against a message and cashaddr. * Returns the recovered cashaddr on success, or null on any mismatch. + * + * The prefix is auto-detected from the claimed cashaddr, so both + * bitcoincash:qq... (mainnet) + * bchtest:qq... (chipnet / testnet) + * bchreg:qq... (regtest) + * work with identical key material. This matters because chipnet wallets + * (which the whole Silent Mode stack runs on today) sign the same message + * with the same key, but display their address with the bchtest: prefix. */ +const KNOWN_PREFIXES = ["bitcoincash", "bchtest", "bchreg"] as const; +type Prefix = (typeof KNOWN_PREFIXES)[number]; + export function verifyBchSignedMessage( claimedCashaddr: string, message: string, signatureBase64: string, - prefix: "bitcoincash" | "bchtest" = "bitcoincash", ): string | null { + const prefixMatch = claimedCashaddr.match(/^([a-z]+):/); + if (!prefixMatch) return null; + const prefix = prefixMatch[1] as Prefix; + if (!KNOWN_PREFIXES.includes(prefix)) return null; + const sig = base64ToBin(signatureBase64); if (typeof sig === "string" || sig.length !== 65) return null;