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
Binary file removed engine-bridge/event-queue.db-shm
Binary file not shown.
Binary file removed engine-bridge/event-queue.db-wal
Binary file not shown.
12 changes: 10 additions & 2 deletions engine-bridge/src/__tests__/relayer-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,17 @@ describe("ZkStateSyncer with auth", () => {
ws.close();
});

it("works without auth when no auth config is provided", async () => {
it("accepts any client when unauthenticated mode is explicitly opted into", async () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
prop = makePropagator();
syncer = new ZkStateSyncer(prop, { port: 0, pingIntervalMs: 60_000 });
// Previously this passed no auth options at all and the server silently
// came up wide open. That is now an explicit opt-in.
syncer = new ZkStateSyncer(prop, {
port: 0,
pingIntervalMs: 60_000,
allowUnauthenticated: true,
});
warn.mockRestore();
await syncer.ready;

const ws = await new Promise<WebSocket>((resolve, reject) => {
Expand Down
115 changes: 83 additions & 32 deletions engine-bridge/src/__tests__/wallet-connector.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import { Keypair, Networks, WebAuth } from "@stellar/stellar-sdk";
import { WalletConnector } from "../wallet-connector";
import { WalletConnector, type AccountLoader } from "../wallet-connector";

describe("WalletConnector", () => {
const networkPassphrase = Networks.TESTNET;
const domain = "test.vero.io";
const serverKeypair = Keypair.random();
const clientKeypair = Keypair.random();

it("creates and verifies a valid challenge-response", () => {
/** A single master key at full weight, med_threshold 1 — the common case. */
const masterKeyLoader = (accountId: string): AccountLoader =>
async () => ({
signers: [{ key: accountId, weight: 1, type: "ed25519_public_key" }],
thresholds: { med_threshold: 1 },
});

function signedChallenge(signer: Keypair): string {
const xdr = WalletConnector.createChallenge({
serverKeypair,
clientAddress: clientKeypair.publicKey(),
networkPassphrase,
domain,
});

expect(xdr).toBeDefined();

// Sign the challenge as the client
const { tx: transaction } = WebAuth.readChallengeTx(
xdr,
serverKeypair.publicKey(),
Expand All @@ -26,47 +30,94 @@ describe("WalletConnector", () => {
domain
);

transaction.sign(clientKeypair);
const signedXdr = transaction.toEnvelope().toXDR("base64").toString();
transaction.sign(signer);
return transaction.toEnvelope().toXDR("base64").toString();
}

const verifiedAddress = WalletConnector.verifyResponse(
signedXdr,
it("creates and verifies a valid challenge-response", async () => {
const verifiedAddress = await WalletConnector.verifyResponse(
signedChallenge(clientKeypair),
serverKeypair.publicKey(),
networkPassphrase,
domain
domain,
masterKeyLoader(clientKeypair.publicKey())
);

expect(verifiedAddress).toBe(clientKeypair.publicKey());
});

it("throws error for invalid signature", () => {
const xdr = WalletConnector.createChallenge({
serverKeypair,
clientAddress: clientKeypair.publicKey(),
networkPassphrase,
domain,
it("throws error for invalid signature", async () => {
const otherKeypair = Keypair.random();

await expect(
WalletConnector.verifyResponse(
signedChallenge(otherKeypair),
serverKeypair.publicKey(),
networkPassphrase,
domain,
masterKeyLoader(clientKeypair.publicKey())
)
).rejects.toThrow("Invalid signature: client signature missing or incorrect");
});

// Verification used to pass a fabricated signer set — the account ID read out
// of the submitted XDR, at weight 1, threshold 1 — and never fetched the real
// account. A revoked master key (weight 0) therefore still authenticated.
it("rejects a master key whose weight has been revoked to zero", async () => {
const revokedLoader: AccountLoader = async () => ({
signers: [
{ key: clientKeypair.publicKey(), weight: 0, type: "ed25519_public_key" },
],
thresholds: { med_threshold: 1 },
});

// Don't sign as the client (or sign with wrong key)
const otherKeypair = Keypair.random();
const { tx: transaction } = WebAuth.readChallengeTx(
xdr,
serverKeypair.publicKey(),
networkPassphrase,
domain,
domain
);
await expect(
WalletConnector.verifyResponse(
signedChallenge(clientKeypair),
serverKeypair.publicKey(),
networkPassphrase,
domain,
revokedLoader
)
).rejects.toThrow("Invalid signature: client signature missing or incorrect");
});

// A single signature must not satisfy a multi-sig account: the old fabricated
// set hardcoded threshold 1 regardless of the account's real med_threshold.
it("rejects a single signature on a multi-sig account below threshold", async () => {
const coSigner = Keypair.random();
const multisigLoader: AccountLoader = async () => ({
signers: [
{ key: clientKeypair.publicKey(), weight: 1, type: "ed25519_public_key" },
{ key: coSigner.publicKey(), weight: 1, type: "ed25519_public_key" },
],
thresholds: { med_threshold: 2 },
});

await expect(
WalletConnector.verifyResponse(
signedChallenge(clientKeypair),
serverKeypair.publicKey(),
networkPassphrase,
domain,
multisigLoader
)
).rejects.toThrow("Invalid signature: client signature missing or incorrect");
});

transaction.sign(otherKeypair);
const signedXdr = transaction.toEnvelope().toXDR("base64").toString();
it("fails closed when the account cannot be loaded", async () => {
const failingLoader: AccountLoader = async () => {
throw new Error("404 Not Found");
};

expect(() => {
await expect(
WalletConnector.verifyResponse(
signedXdr,
signedChallenge(clientKeypair),
serverKeypair.publicKey(),
networkPassphrase,
domain
);
}).toThrow("Invalid signature: client signature missing or incorrect");
domain,
failingLoader
)
).rejects.toThrow(/unable to load account/i);
});
});
63 changes: 61 additions & 2 deletions engine-bridge/src/__tests__/zk-state-syncer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,23 @@ async function connectClient(port: number): Promise<WebSocket> {

// ── ZkStateSyncer ─────────────────────────────────────────────────────────────

// These suites deliberately run the syncer unauthenticated, which now emits a
// warning by design. Silence it so it doesn't read as an unexpected log.
let warnSpy: jest.SpyInstance;
beforeAll(() => {
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
});
afterAll(() => {
warnSpy.mockRestore();
});

describe("ZkStateSyncer", () => {
let syncer: ZkStateSyncer;
let prop: ReturnType<typeof makePropagator>;

beforeEach(async () => {
prop = makePropagator();
syncer = new ZkStateSyncer(prop, { port: 0, pingIntervalMs: 60_000 });
syncer = new ZkStateSyncer(prop, { port: 0, pingIntervalMs: 60_000, allowUnauthenticated: true });
await syncer.ready;
});

Expand Down Expand Up @@ -120,7 +130,7 @@ describe("ZkStateSyncer", () => {
it("respects a custom zkTopic filter", async () => {
await syncer.close();
prop = makePropagator();
syncer = new ZkStateSyncer(prop, { port: 0, zkTopic: "breaker_open", pingIntervalMs: 60_000 });
syncer = new ZkStateSyncer(prop, { port: 0, zkTopic: "breaker_open", pingIntervalMs: 60_000, allowUnauthenticated: true });
await syncer.ready;

const ws = await connectClient(syncer.getPort());
Expand Down Expand Up @@ -152,3 +162,52 @@ describe("ZkStateSyncer", () => {
ws.close();
});
});

describe("ZkStateSyncer authentication guard", () => {
const prop = { onEvent: () => {} };

// main.ts constructed the syncer as `new ZkStateSyncer(propagator, { port })`.
// Both auth options are optional, so verifyClient was never installed and the
// broadcast guard short-circuited: every socket connected and received all ZK
// state commitments, with RelayerAuth unreachable in production.
it("refuses to start with no authentication configured", () => {
expect(() => new ZkStateSyncer(prop, { port: 0 })).toThrow(
/refusing to start without authentication/i,
);
});

it("starts when auth is configured", async () => {
const s = new ZkStateSyncer(prop, {
port: 0,
pingIntervalMs: 60_000,
auth: { apiKeys: ["test-key"] },
});
await s.ready;
await s.close();
});

it("starts when a server signing key is configured", async () => {
const s = new ZkStateSyncer(prop, {
port: 0,
pingIntervalMs: 60_000,
serverSigningKey: "SIGNING_KEY",
});
await s.ready;
await s.close();
});

it("starts unauthenticated only when explicitly opted in, and warns", async () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const s = new ZkStateSyncer(prop, {
port: 0,
pingIntervalMs: 60_000,
allowUnauthenticated: true,
});
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("running without authentication"),
);
warn.mockRestore();
await s.ready;
await s.close();
});
});
31 changes: 30 additions & 1 deletion engine-bridge/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import { RpcClient } from "./rpc-client";
import { EventPropagator } from "./event-propagator";
import { ZkStateSyncer } from "./zk-state-syncer";
import { horizonAccountLoader } from "./wallet-connector";
import { Horizon } from "@stellar/stellar-sdk";
import { HeartbeatMonitor } from "./heartbeat-monitor";
import { AlertChannelService, WebhookAlertChannel, ConsoleAlertChannel } from "./alert-channel";

Expand All @@ -18,6 +20,24 @@ async function main() {
const cursor = process.env.EVENT_CURSOR;
const webhookUrl = process.env.ALERT_WEBHOOK_URL || "";

// Relayer authentication. Previously none of this was passed to the syncer,
// so `verifyClient` was never installed and the broadcast guard short-
// circuited — the server accepted every connection and sent all ZK state
// commitments to it, with RelayerAuth unreachable in the only production
// entrypoint.
const apiKeys = (process.env.RELAYER_API_KEYS || "")
.split(",")
.map((k) => k.trim())
.filter(Boolean);
const jwtSecret = process.env.RELAYER_JWT_SECRET || undefined;
const serverSigningKey = process.env.SERVER_SIGNING_KEY || undefined;
const allowUnauthenticated = process.env.ALLOW_UNAUTHENTICATED_SYNCER === "true";
// SEP-10 verification needs the client account's real signer set.
const horizonUrl = process.env.HORIZON_URL || "https://horizon-testnet.stellar.org";
const loadAccount = serverSigningKey
? horizonAccountLoader(new Horizon.Server(horizonUrl))
: undefined;

console.log("[Bridge] Starting service...");
console.log(`[Bridge] RPC URLs: ${rpcUrls.join(", ")}`);
console.log(`[Bridge] Contract: ${contractId}`);
Expand All @@ -33,7 +53,16 @@ async function main() {
}
const alertService = new AlertChannelService({ channels: alertChannels });

const syncer = new ZkStateSyncer(propagator, { port });
const hasAuth = apiKeys.length > 0 || Boolean(jwtSecret);
const syncer = new ZkStateSyncer(propagator, {
port,
...(hasAuth && { auth: { apiKeys: apiKeys.length ? apiKeys : undefined, jwtSecret } }),
...(serverSigningKey && { serverSigningKey }),
...(loadAccount && { loadAccount }),
...(process.env.NETWORK_PASSPHRASE && { networkPassphrase: process.env.NETWORK_PASSPHRASE }),
...(process.env.AUTH_DOMAIN && { domain: process.env.AUTH_DOMAIN }),
allowUnauthenticated,
});
const heartbeat = new HeartbeatMonitor(rpc, propagator, { alertService });

heartbeat.start();
Expand Down
Loading
Loading