Skip to content
Merged
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
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
48 changes: 48 additions & 0 deletions packages/block-ingestor/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from "bun:test";
import { 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");
expect(isAuthorized(request.headers, env.AUTH_BEARER_TOKEN)).toBe(false);
});

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

it("should return true if token matches", () => {
const request = new Request("http://localhost", {
headers: {
Authorization: "Bearer test-token",
},
});
expect(isAuthorized(request.headers, 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",
},
});
expect(isAuthorized(request.headers, envMissing.AUTH_BEARER_TOKEN)).toBe(false);
});
});
11 changes: 8 additions & 3 deletions packages/block-ingestor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { Router } from "itty-router";
import { PutBlocksReq } from "./api/put-blocks";
import { 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) => {
if (!isAuthorized(request.headers, 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 +51,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
17 changes: 2 additions & 15 deletions packages/btcindexer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,18 @@ 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 { 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)) {
if (!isAuthorized(req.headers, env.AUTH_BEARER_TOKEN)) {
return new Response("Unauthorized", { status: 401 });
}
const indexer = await indexerFromEnv(env);
Expand Down
28 changes: 28 additions & 0 deletions packages/lib/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { timingSafeEqual } from "node:crypto";

/**
* Validates the Authorization header from request headers against an expected secret.
* @param headers The request Headers object
* @param expectedSecret The expected secret from environment variables
* @returns true if authorized, false otherwise
*/
export function isAuthorized(headers: Headers, expectedSecret: string | undefined): boolean {
if (!expectedSecret) {
return false;
}

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

const token = authHeader.substring(7);

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));
}