Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@
"semicolons": "asNeeded"
}
},
"overrides": [
{
"includes": ["**/*.test.{ts,tsx}"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
],
"assist": {
"actions": {
"source": {
Expand Down
24 changes: 24 additions & 0 deletions src/app/api/chains/OWNERSHIP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Blockchain API ownership authentication

## Deployment

- Set `API_POW_SESSION_SECRET` to an independently generated random server-only secret of at least 32 characters. Use the same value on all instances. Rotating it invalidates all challenges and sessions.
- Set `API_OWNERSHIP_ORIGIN` to the exact canonical app origin, without a trailing slash (HTTPS in production). Each preview deployment needs its own configured origin. Development defaults to `http://localhost:3000`.
- No shared nonce storage is required. Existing per-instance IP rate limits are **not distributed protection**; use a shared limiter or WAF for production quota protection.

## Protocol v2

The server HMAC-signs a five-minute challenge containing family, address, audience, timestamp and PoW difficulty. The client validates these fields, signs a domain-separated authentication message containing the challenge with that address's signer, and solves the SHA-256 PoW. The POST endpoint verifies all three proofs before issuing an HttpOnly cookie.

Cookies are scoped to family/address and expire 30 minutes after the original challenge timestamp. A replay within the five-minute challenge window produces the same session token and expiration, never a fresh lifetime. This is time-bounded authentication, not a single-use protocol; there is no individual revocation without state. Old PoW-only cookies are rejected by the v2 token types and cookie names.

EVM uses personal-message signing, Solana uses Ed25519 message signing, and XRP uses ripple-keypairs with public-key/address verification. BTC/DOGE use a GoodWallet-specific, domain-separated SHA-256/ECDSA authentication protocol with network-specific public-key/address derivation. This is not BIP-322 and does not sign transactions. Imported external BTC/DOGE/XRP signers must implement `signAuthMessage`; unsupported signers fail closed. External EVM wallets may prompt for authentication and contract-wallet signatures (EIP-1271) are not supported by this verifier.

## Scope and remaining limitations

- Balance/history/UTXO requests require an ownership cookie for the exact chain family and address.
- Non-verbose raw transaction requests require the address cookie, but the existing transaction handler does **not** verify that the hash belongs to the supplied address. This change authenticates the requesting address; it does not establish transaction association.
- Fee queries and verbose transaction confirmation queries retain their existing rate-limited, unauthenticated behavior. Counterparty name lookups and direct client RPC calls are unchanged.
- Authentication proves ownership, not use of the official UI. Maintain upstream budgets, rate limits and caching independently.

Run the isolated crypto/protocol tests with `yarn vitest run --config src/app/api/chains/ownershipSignature.vitest.config.ts`.
131 changes: 131 additions & 0 deletions src/app/api/chains/auth/address/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { type NextRequest, NextResponse } from "next/server"
import z from "zod"

import { OwnershipScope, ownershipMessage } from "../../ownershipProtocol"
import { verifyOwnershipSignature } from "../../ownershipSignature"
import {
getAddressSessionCookieName,
isOwnershipOrigin,
issuePowChallenge,
issuePowSession,
OwnershipOriginError,
verifyPowSolution,
} from "../../powSession"

const VerifySchema = OwnershipScope.extend({
challenge: z.string().min(1).max(2048),
nonce: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
signature: z.string().min(1).max(300),
publicKey: z.string().max(130).optional(),
})

const noStore = { "Cache-Control": "private, no-store" }

function getAllowedOrigin(request: NextRequest) {
const origin = request.headers.get("origin")
return origin && isOwnershipOrigin(origin) ? origin : null
}

export async function GET(request: NextRequest) {
try {
const { family, address } = OwnershipScope.parse(
Object.fromEntries(request.nextUrl.searchParams),
)
const challenge = await issuePowChallenge(
family,
address,
request.nextUrl.searchParams.get("origin"),
)

return NextResponse.json(challenge, { status: 200, headers: noStore })
} catch (error) {
return NextResponse.json(
{
message: "Unable to issue ownership challenge",
},
{
status:
error instanceof z.ZodError || error instanceof OwnershipOriginError
? 400
: 503,
headers: noStore,
},
)
}
}

export async function POST(request: NextRequest) {
try {
const origin = getAllowedOrigin(request)
if (!origin)
return NextResponse.json(
{ message: "Forbidden" },
{ status: 403, headers: noStore },
)
const text = await request.text()
if (text.length > 4096)
return NextResponse.json(
{ message: "Payload too large" },
{ status: 413, headers: noStore },
)
const parsedBody = VerifySchema.parse(JSON.parse(text))
const isValid = await verifyPowSolution(
parsedBody.family,
parsedBody.address,
parsedBody.challenge,
parsedBody.nonce,
origin,
)

if (
!isValid ||
!(await verifyOwnershipSignature(
parsedBody.family,
parsedBody.address,
ownershipMessage(parsedBody.challenge),
parsedBody.signature,
parsedBody.publicKey,
))
) {
return NextResponse.json(
{ message: "Invalid ownership proof" },
{ status: 401, headers: noStore },
)
}

const session = await issuePowSession(parsedBody.challenge)
const cookieName = await getAddressSessionCookieName(
parsedBody.family,
parsedBody.address,
)
const response = NextResponse.json(
{ ok: true, expiresAt: session.expiresAt },
{ status: 200, headers: noStore },
)

response.cookies.set({
name: cookieName,
value: session.token,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/api/chains",
expires: new Date(session.expiresAt),
})

return response
} catch (error) {
return NextResponse.json(
{
message: "Failed to create address session",
},
{
status:
error instanceof z.ZodError || error instanceof SyntaxError
? 400
: 503,
headers: noStore,
},
)
}
}
Loading
Loading