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
10 changes: 7 additions & 3 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

125 changes: 125 additions & 0 deletions src/core/service/auth/dashboard-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { assertEquals, assertRejects } from "jsr:@std/assert";
import { Keypair } from "stellar-sdk";
import {
createDashboardChallenge,
verifyDashboardChallenge,
type DashboardAuthConfig,
} from "./dashboard-auth.ts";

const TEST_KEYPAIR = Keypair.random();
const TEST_PUBLIC_KEY = TEST_KEYPAIR.publicKey();

const mockGenerateToken = async (sub: string, sid: string) => `mock-jwt-${sub.slice(0, 8)}`;

// Config where the signer IS the provider (direct match, no Horizon needed)
const SELF_SIGNER_CONFIG: DashboardAuthConfig = {
providerPublicKey: TEST_PUBLIC_KEY,
generateToken: mockGenerateToken,
};

// Config where the signer is NOT the provider (and no Horizon to check multisig)
const DIFFERENT_PROVIDER_CONFIG: DashboardAuthConfig = {
providerPublicKey: Keypair.random().publicKey(),
generateToken: mockGenerateToken,
};

function signNonce(keypair: typeof TEST_KEYPAIR, nonce: string): string {
const nonceBuffer = Buffer.from(nonce, "base64");
const sigBuffer = keypair.sign(nonceBuffer);
return sigBuffer.toString("base64");
}

Deno.test("createDashboardChallenge - returns a nonce", () => {
const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY);
assertEquals(typeof nonce, "string");
assertEquals(nonce.length > 0, true);
});

Deno.test("createDashboardChallenge - returns unique nonces", () => {
const { nonce: nonce1 } = createDashboardChallenge(TEST_PUBLIC_KEY);
const { nonce: nonce2 } = createDashboardChallenge(TEST_PUBLIC_KEY);
assertEquals(nonce1 !== nonce2, true);
});

Deno.test("verifyDashboardChallenge - rejects unknown nonce", async () => {
await assertRejects(
() => verifyDashboardChallenge("unknown-nonce", "sig", TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG),
Error,
"Challenge not found",
);
});

Deno.test("verifyDashboardChallenge - rejects wrong public key", async () => {
const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY);
const otherKey = Keypair.random().publicKey();

await assertRejects(
() => verifyDashboardChallenge(nonce, "sig", otherKey, SELF_SIGNER_CONFIG),
Error,
"Public key mismatch",
);
});

Deno.test("verifyDashboardChallenge - rejects short invalid signature", async () => {
const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY);
const badSig = btoa("too-short");

await assertRejects(
() => verifyDashboardChallenge(nonce, badSig, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG),
Error,
"Invalid signature",
);
});

Deno.test("verifyDashboardChallenge - rejects valid-length but wrong signature", async () => {
const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY);
// 64-byte signature that's properly sized but wrong
const wrongSig = signNonce(Keypair.random(), nonce);

await assertRejects(
() => verifyDashboardChallenge(nonce, wrongSig, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG),
Error,
"Invalid signature",
);
});

Deno.test("verifyDashboardChallenge - valid signature + self signer = success", async () => {
const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY);
const signature = signNonce(TEST_KEYPAIR, nonce);

const { token } = await verifyDashboardChallenge(
nonce,
signature,
TEST_PUBLIC_KEY,
SELF_SIGNER_CONFIG,
);

assertEquals(typeof token, "string");
assertEquals(token.length > 0, true);
});

Deno.test("verifyDashboardChallenge - valid signature + different provider (no Horizon) = rejected", async () => {
const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY);
const signature = signNonce(TEST_KEYPAIR, nonce);

await assertRejects(
() => verifyDashboardChallenge(nonce, signature, TEST_PUBLIC_KEY, DIFFERENT_PROVIDER_CONFIG),
Error,
"Signer is not authorized",
);
});

Deno.test("verifyDashboardChallenge - nonce is consumed after use", async () => {
const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY);
const signature = signNonce(TEST_KEYPAIR, nonce);

// First use succeeds
await verifyDashboardChallenge(nonce, signature, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG);

// Second use fails
await assertRejects(
() => verifyDashboardChallenge(nonce, signature, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG),
Error,
"Challenge not found",
);
});
192 changes: 192 additions & 0 deletions src/core/service/auth/dashboard-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { Keypair } from "stellar-sdk";
import { LOG } from "@/config/logger.ts";
import { withSpan } from "@/core/tracing.ts";

/**
* In-memory challenge store for dashboard auth.
* Max 1000 pending challenges.
*/
const MAX_PENDING_CHALLENGES = 1000;

let challengeTtlMs = 5 * 60 * 1000; // default 5 minutes

/**
* Configure the challenge TTL. Call at startup with the value from env.
*/
export function setChallengeTtlMs(ttlMs: number): void {
challengeTtlMs = ttlMs;
}

interface PendingChallenge {
nonce: string;
publicKey: string;
createdAt: number;
}

const pendingChallenges = new Map<string, PendingChallenge>();

/**
* Creates a challenge for dashboard authentication.
*
* @param publicKey - The Ed25519 public key of the operator requesting auth
* @returns The nonce to be signed
*/
export function createDashboardChallenge(publicKey: string): { nonce: string } {
cleanupExpiredChallenges();

if (pendingChallenges.size >= MAX_PENDING_CHALLENGES) {
throw new Error("Too many pending challenges. Try again later.");
}

const nonceBytes = crypto.getRandomValues(new Uint8Array(32));
const nonce = btoa(String.fromCharCode(...nonceBytes));

pendingChallenges.set(nonce, {
nonce,
publicKey,
createdAt: Date.now(),
});

LOG.debug("Dashboard challenge created", { publicKey });

return { nonce };
}

/**
* Configuration for verifying a dashboard challenge.
* Injected so the service is testable without loading env.
*/
export interface DashboardAuthConfig {
providerPublicKey: string;
horizonUrl?: string;
/** JWT generator function — injected so the module doesn't depend on env.ts */
generateToken: (subject: string, sessionId: string) => Promise<string>;
}

/**
* Verifies a signed dashboard challenge.
*
* 1. Checks the nonce exists and hasn't expired
* 2. Verifies the Ed25519 signature over the nonce
* 3. Checks the signer is authorized on the PP's Stellar account
* 4. Returns a JWT session token
*/
export async function verifyDashboardChallenge(
nonce: string,
signature: string,
publicKey: string,
config: DashboardAuthConfig,
): Promise<{ token: string }> {
return withSpan("DashboardAuth.verify", async (span) => {
span.addEvent("verifying_challenge", { "signer.publicKey": publicKey });

// 1. Check nonce exists
const challenge = pendingChallenges.get(nonce);
if (!challenge) {
throw new Error("Challenge not found or expired");
}

// Check expiry
if (Date.now() - challenge.createdAt > challengeTtlMs) {
pendingChallenges.delete(nonce);
throw new Error("Challenge expired");
}

// Check public key matches
if (challenge.publicKey !== publicKey) {
throw new Error("Public key mismatch");
}

// Consume the challenge (one-time use)
pendingChallenges.delete(nonce);

// 2. Verify Ed25519 signature
span.addEvent("verifying_signature");
try {
const keypair = Keypair.fromPublicKey(publicKey);
const nonceBuffer = Buffer.from(nonce, "base64");
const sigBuffer = Buffer.from(signature, "base64");
if (!keypair.verify(nonceBuffer, sigBuffer)) {
throw new Error("Invalid signature");
}
} catch (e) {
throw e instanceof Error && e.message === "Invalid signature"
? e
: new Error("Invalid signature");
}

// 3. Check signer is authorized on the PP's Stellar account
span.addEvent("checking_signer_authorization");
const isAuth = await isAuthorizedSigner(
publicKey,
config.providerPublicKey,
config.horizonUrl,
);
if (!isAuth) {
throw new Error("Signer is not authorized on the provider account");
}

// 4. Issue JWT — hash nonce so raw challenge material isn't in the token
span.addEvent("issuing_jwt");
const hashBytes = new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(nonce))
);
const hashedSessionId = Array.from(hashBytes.slice(0, 16))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const token = await config.generateToken(publicKey, hashedSessionId);

LOG.info("Dashboard auth successful", { publicKey });
return { token };
});
}

/**
* Checks if `signerKey` is an authorized signer on the `accountId` Stellar account.
*/
async function isAuthorizedSigner(
signerKey: string,
accountId: string,
horizonUrl?: string,
): Promise<boolean> {
// Direct match — the signer is the account itself
if (signerKey === accountId) {
return true;
}

if (!horizonUrl) {
LOG.warn("No Horizon URL configured, falling back to direct key match only");
return false;
}

try {
const baseUrl = horizonUrl.replace(/\/+$/, "");
const response = await fetch(`${baseUrl}/accounts/${accountId}`);
if (!response.ok) {
LOG.error("Failed to fetch account from Horizon", {
status: response.status,
accountId,
});
return false;
}

const accountData = await response.json();
const signers = accountData.signers as Array<{ key: string; weight: number }>;

return signers.some((s) => s.key === signerKey && s.weight > 0);
} catch (error) {
LOG.error("Failed to verify signer authorization", {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}

function cleanupExpiredChallenges(): void {
const now = Date.now();
for (const [nonce, challenge] of pendingChallenges) {
if (now - challenge.createdAt > challengeTtlMs) {
pendingChallenges.delete(nonce);
}
}
}
4 changes: 2 additions & 2 deletions src/core/service/auth/generate-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export default async function (clientAccount: string, challengeHash: string) {
const payload = {
iss: "https://" + SERVICE_DOMAIN,
sub: clientAccount,
iat: getNumericDate(Date.now() / 1000),
exp: getNumericDate(Date.now() / 1000 + SESSION_TTL),
iat: getNumericDate(0),
exp: getNumericDate(SESSION_TTL),
sessionId: challengeHash,
};

Expand Down
7 changes: 6 additions & 1 deletion src/core/service/auth/service/service-auth-secret.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SERVICE_AUTH_SECRET } from "@/config/env.ts";
import { SERVICE_AUTH_SECRET, MODE } from "@/config/env.ts";

function generateSecret() {
// Generate 32 random bytes
Expand All @@ -9,6 +9,11 @@ function generateSecret() {
}

if (!SERVICE_AUTH_SECRET) {
if (MODE === "production") {
throw new Error(
"SERVICE_AUTH_SECRET must be set in production. A random secret would invalidate all JWTs on restart."
);
}
console.warn(
"WARNING: SERVICE_AUTH_SECRET is not set. Generating a random secret. This is NOT recommended for production environments."
);
Expand Down
Loading
Loading