Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
15 changes: 10 additions & 5 deletions api/btcindexer/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import (
)

type Client struct {
baseUrl string
c http.Client
baseUrl string
authToken string
c http.Client
}

const (
Expand All @@ -20,10 +21,11 @@ const (
pathDepositsBySender = "/bitcoin/deposits/"
)

func NewClient(workerUrl string) Client {
func NewClient(workerUrl string, authToken string) Client {
return Client{
baseUrl: workerUrl,
c: http.Client{Timeout: time.Second * 30},
baseUrl: workerUrl,
authToken: authToken,
c: http.Client{Timeout: time.Second * 30},
}
}

Expand All @@ -38,6 +40,9 @@ func (c Client) PutBlocks(putBlocks PutBlocksReq) (*http.Response, error) {
return nil, err
}
req.Header.Set("Content-Type", "application/msgpack")
if c.authToken != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.authToken))
}
return c.c.Do(req)
}

Expand Down
70 changes: 69 additions & 1 deletion api/btcindexer/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import (
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"

"gotest.tools/v3/assert"
)

type roundTripperFunc func(*http.Request) (*http.Response, error)

func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

// The Bitcoin mainnet genesis block. See packages/btcindexer/src/api/put-blocks.test.ts
const blockHex = "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c0101000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000"

Expand Down Expand Up @@ -45,11 +53,71 @@ func TestPutBlocksInt(t *testing.T) {

pb := PutBlock{Network: NetworkRegtest, Height: 156, Block: blockBz}

c := NewClient("http://localhost:8787")
c := NewClient("http://localhost:8787", "")
resp, err := c.PutBlocks(PutBlocksReq{pb})
assert.NilError(t, err)
respBody, err := io.ReadAll(resp.Body)
assert.NilError(t, err)
fmt.Println(string(respBody))
assert.Equal(t, resp.StatusCode, 200)
}

func TestClientPutBlocksAuthorizationHeader(t *testing.T) {
tests := []struct {
name string
token string
wantAuth string
}{
{
name: "no auth token omits Authorization header",
token: "",
wantAuth: "",
},
{
name: "non-empty auth token sets Authorization header",
token: "my-secret-token",
wantAuth: "Bearer my-secret-token",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedReq *http.Request
rt := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
capturedReq = req

return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{}`)),
Header: make(http.Header),
Request: req,
}, nil
})

httpClient := http.Client{
Transport: rt,
}

client := NewClient("http://localhost:8787", tt.token)
client.c = httpClient

_, err := client.PutBlocks(PutBlocksReq{})
assert.NilError(t, err)

if capturedReq == nil {
t.Fatalf("expected request to be captured")
}

gotAuth := capturedReq.Header.Get("Authorization")
if tt.wantAuth == "" {
if gotAuth != "" {
t.Fatalf("expected no Authorization header, got %q", gotAuth)
}
} else {
if gotAuth != tt.wantAuth {
t.Fatalf("expected Authorization header %q, got %q", tt.wantAuth, gotAuth)
}
}
})
}
}
22 changes: 14 additions & 8 deletions packages/block-ingestor/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
import { type PutBlock, PutBlocksReq } from "./put-blocks";

export const RestPath = {
blocks: "/",
blocks: "/bitcoin/blocks",
};

export enum ContentType {
MSG_PACK = "application/vnd.msgpack",
}

const msgPackHeaders = {
"Content-Type": ContentType.MSG_PACK,
};

export class BtcIndexerClient {
#url: string;
constructor(url: string) {
#authToken?: string;

constructor(url: string, authToken?: string) {
this.#url = url.endsWith("/") ? url.slice(0, -1) : url;
this.#authToken = authToken;
}

async putBlocks(blocks: PutBlock[]): Promise<void> {
const req = PutBlocksReq.encode(blocks);
const headers: Record<string, string> = {
"Content-Type": ContentType.MSG_PACK,
};
if (this.#authToken) {
headers["Authorization"] = `Bearer ${this.#authToken}`;
}

const res = await fetch(this.#url + RestPath.blocks, {
method: "POST",
headers: msgPackHeaders,
method: "PUT",
headers,
body: req,
});
if (!res.ok) {
Expand Down
52 changes: 52 additions & 0 deletions packages/block-ingestor/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from "bun:test";
import { extractBearerToken, isAuthorized } from "@gonative-cc/lib/auth";

describe("block-ingestor auth helper", () => {
const env = {
AUTH_BEARER_TOKEN: "test-token",
BtcBlocks: {} as KVNamespace,
BlockQueue: {} as Queue,
BtcIndexer: {} as Fetcher,
} as unknown as Env;

it("should return false if no auth header", () => {
const request = new Request("http://localhost");
const token = extractBearerToken(request.headers.get("Authorization"));
expect(isAuthorized(token, env.AUTH_BEARER_TOKEN)).toBe(false);
});

it("should return false if token mismatch", () => {
const request = new Request("http://localhost", {
headers: {
Authorization: "Bearer wrong-token",
},
});
const token = extractBearerToken(request.headers.get("Authorization"));
expect(isAuthorized(token, env.AUTH_BEARER_TOKEN)).toBe(false);
});

it("should return true if token matches", () => {
const request = new Request("http://localhost", {
headers: {
Authorization: "Bearer test-token",
},
});
const token = extractBearerToken(request.headers.get("Authorization"));
expect(isAuthorized(token, env.AUTH_BEARER_TOKEN)).toBe(true);
});

it("should return false if AUTH_BEARER_TOKEN is missing in env", () => {
const envMissing = {
BtcBlocks: {} as KVNamespace,
BlockQueue: {} as Queue,
BtcIndexer: {} as Fetcher,
} as unknown as Env;
const request = new Request("http://localhost", {
headers: {
Authorization: "Bearer test-token",
},
});
const token = extractBearerToken(request.headers.get("Authorization"));
expect(isAuthorized(token, envMissing.AUTH_BEARER_TOKEN)).toBe(false);
});
});
12 changes: 9 additions & 3 deletions packages/block-ingestor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { Router } from "itty-router";
import { PutBlocksReq } from "./api/put-blocks";
import { extractBearerToken, isAuthorized } from "@gonative-cc/lib/auth";
import { handleIngestBlocks } from "./ingest";
import { type BtcIndexerRpc } from "@gonative-cc/btcindexer/rpc-interface";
import { logError } from "@gonative-cc/lib/logger";
import { btcNetFromString } from "@gonative-cc/lib/nbtc";
import { RestPath } from "./api/client";

const router = Router();
export const router = Router();

router.put(RestPath.blocks, async (request, env: Env) => {
const token = extractBearerToken(request.headers.get("Authorization"));
if (!isAuthorized(token, env.AUTH_BEARER_TOKEN)) {
return new Response("Unauthorized", { status: 401 });
}

router.put("/bitcoin/blocks", async (request, env: Env) => {
try {
const blocks = PutBlocksReq.decode(await request.arrayBuffer());
await handleIngestBlocks(blocks, env.BtcBlocks, env.BlockQueue);
Expand Down Expand Up @@ -45,7 +52,6 @@ function envBtcIndexer(env: Env): BtcIndexerRpc {

export default {
async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
// TODO: add authentication method here
return router.handle(request, env);
},
};
9 changes: 8 additions & 1 deletion packages/block-ingestor/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 0d3b4e2297618d67c6f58654954bb95b)
// Generated by Wrangler by running `wrangler types` (hash: 4133e559722b1cf89e98b02f5af5306c)
// Runtime types generated with workerd@1.20251109.0 2025-06-20 nodejs_compat
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/index");
}
interface Env {
BtcBlocks: KVNamespace;
AUTH_BEARER_TOKEN: "";
BtcIndexer: Service /* entrypoint BtcIndexerRpc from btcindexer */;
BlockQueue: Queue;
}
}
interface Env extends Cloudflare.Env {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "AUTH_BEARER_TOKEN">> {}
}

// Begin runtime types
/*! *****************************************************************************
Expand Down
3 changes: 3 additions & 0 deletions packages/block-ingestor/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"queues": {
"producers": [{ "queue": "block-queue", "binding": "BlockQueue" }],
},
"vars": {
"AUTH_BEARER_TOKEN": "",
},
// "vars": {
// },
"services": [
Expand Down
18 changes: 3 additions & 15 deletions packages/btcindexer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,19 @@ import { logError, logger } from "@gonative-cc/lib/logger";
import HttpRouter from "./router";
import { type BlockQueueRecord } from "@gonative-cc/lib/nbtc";
import { processBlockBatch } from "./queue-handler";
import { extractBearerToken, isAuthorized } from "@gonative-cc/lib/auth";

// Export RPC entrypoints for service bindings
export { RPC } from "./rpc";
export { RPCMock } from "./rpc-mock";

const router = new HttpRouter(undefined);

/**
* Validates the Authorization header against the AUTH_BEARER_TOKEN env var.
*/
function isAuthorized(req: Request, env: Env): boolean {
// If the token isn't set in the environment, we assume the endpoint is public
if (!env.AUTH_BEARER_TOKEN) return true;

const authHeader = req.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) return false;

const token = authHeader.substring(7);
return token === env.AUTH_BEARER_TOKEN;
}

export default {
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
try {
if (!isAuthorized(req, env)) {
const token = extractBearerToken(req.headers.get("Authorization"));
if (!isAuthorized(token, env.AUTH_BEARER_TOKEN)) {
return new Response("Unauthorized", { status: 401 });
}
const indexer = await indexerFromEnv(env);
Expand Down
33 changes: 33 additions & 0 deletions packages/lib/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { timingSafeEqual } from "node:crypto";

/**
* Validates a token against an expected secret using constant-time comparison.
* @param token The token extracted from the Authorization header
* @param expectedSecret The expected secret from environment variables
* @returns true if authorized, false otherwise
*/
export function isAuthorized(token: string | null, expectedSecret: string | undefined): boolean {
if (!expectedSecret || !token) {
return false;
}

if (token.length !== expectedSecret.length) {
return false;
}

// we do that to prevent timing attacks
const encoder = new TextEncoder();
return timingSafeEqual(encoder.encode(token), encoder.encode(expectedSecret));
}

/**
* Extracts the Bearer token from the Authorization header.
* @param authHeader The value of the Authorization header
* @returns The token if found and correctly formatted, null otherwise
*/
export function extractBearerToken(authHeader: string | null): string | null {
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return null;
}
return authHeader.substring(7);
}