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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Secrets — copy to .env and never commit
# Project paths/categories belong in sourcedraft.config.json instead

# Preferred: scrypt hash from `pnpm exec tsx scripts/hash-admin-password.ts <password>`
# Format: scrypt$N$r$p$saltBase64$hashBase64
SOURCEDRAFT_ADMIN_PASSWORD_HASH=
# Legacy local-dev fallback only (omit when using SOURCEDRAFT_ADMIN_PASSWORD_HASH)
SOURCEDRAFT_ADMIN_PASSWORD=
# Set to true to run Studio in demo mode (no remote commits)
SOURCEDRAFT_DEMO_MODE=
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
build-and-test:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v4
Expand All @@ -29,6 +34,8 @@ jobs:
studio-e2e:
runs-on: ubuntu-latest
needs: build-and-test
permissions:
contents: read

steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"busboy": "^1.6.0",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"express-rate-limit": "^8.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
Expand Down
105 changes: 105 additions & 0 deletions apps/studio/server/adminPassword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";

const DEFAULT_SCRYPT_N = 16384;
const DEFAULT_SCRYPT_R = 8;
const DEFAULT_SCRYPT_P = 1;
const DEFAULT_SCRYPT_KEYLEN = 64;

export type ScryptHashParams = {
N: number;
r: number;
p: number;
salt: Buffer;
hash: Buffer;
};

export function parseScryptPasswordHash(
value: string,
): ScryptHashParams | null {
const parts = value.trim().split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") {
return null;
}

const N = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) {
return null;
}

try {
const salt = Buffer.from(parts[4] ?? "", "base64");
const hash = Buffer.from(parts[5] ?? "", "base64");
if (salt.length === 0 || hash.length === 0) {
return null;
}

return { N, r, p, salt, hash };
} catch {
return null;
}
}

export function formatScryptPasswordHash(
params: ScryptHashParams,
): string {
return [
"scrypt",
params.N,
params.r,
params.p,
params.salt.toString("base64"),
params.hash.toString("base64"),
].join("$");
}

export function hashAdminPassword(
password: string,
options?: { N?: number; r?: number; p?: number; keylen?: number },
): string {
const N = options?.N ?? DEFAULT_SCRYPT_N;
const r = options?.r ?? DEFAULT_SCRYPT_R;
const p = options?.p ?? DEFAULT_SCRYPT_P;
const keylen = options?.keylen ?? DEFAULT_SCRYPT_KEYLEN;
const salt = randomBytes(16);
const hash = scryptSync(password, salt, keylen, { N, r, p });

return formatScryptPasswordHash({ N, r, p, salt, hash });
}

export function verifyScryptPassword(
password: string,
storedHash: string,
): boolean {
const parsed = parseScryptPasswordHash(storedHash);
if (parsed === null) {
return false;
}

const derived = scryptSync(password, parsed.salt, parsed.hash.length, {
N: parsed.N,
r: parsed.r,
p: parsed.p,
});

if (derived.length !== parsed.hash.length) {
return false;
}

return timingSafeEqual(derived, parsed.hash);
}

export function verifyPlaintextPassword(
password: string,
expected: string,
): boolean {
const provided = Buffer.from(password);
const target = Buffer.from(expected);

if (provided.length !== target.length) {
return false;
}

return timingSafeEqual(provided, target);
}
83 changes: 83 additions & 0 deletions apps/studio/server/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import { hashAdminPassword } from "./adminPassword.js";
import { isAuthConfigured, verifyPassword } from "./auth.js";

const ENV_KEYS = [
"SOURCEDRAFT_ADMIN_PASSWORD_HASH",
"SOURCEDRAFT_ADMIN_PASSWORD",
] as const;

const originalEnv = new Map<string, string | undefined>();

function saveEnv(): void {
for (const key of ENV_KEYS) {
originalEnv.set(key, process.env[key]);
}
}

function restoreEnv(): void {
for (const key of ENV_KEYS) {
const value = originalEnv.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}

function clearAuthEnv(): void {
for (const key of ENV_KEYS) {
delete process.env[key];
}
}

describe("studio auth", () => {
afterEach(() => {
restoreEnv();
});

it("accepts valid scrypt hash login and prefers hash over plaintext", () => {
saveEnv();
clearAuthEnv();

const hash = hashAdminPassword("studio-secret");
process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH = hash;
process.env.SOURCEDRAFT_ADMIN_PASSWORD = "legacy-only";

assert.equal(isAuthConfigured(), true);
assert.equal(verifyPassword("studio-secret"), true);
assert.equal(verifyPassword("legacy-only"), false);
assert.equal(verifyPassword("wrong"), false);
});

it("rejects invalid scrypt hash login", () => {
saveEnv();
clearAuthEnv();

process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH = "scrypt$16384$8$1$invalid$invalid";

assert.equal(isAuthConfigured(), true);
assert.equal(verifyPassword("anything"), false);
});

it("falls back to legacy plaintext password when hash is absent", () => {
saveEnv();
clearAuthEnv();

process.env.SOURCEDRAFT_ADMIN_PASSWORD = "legacy-password";

assert.equal(isAuthConfigured(), true);
assert.equal(verifyPassword("legacy-password"), true);
assert.equal(verifyPassword("other"), false);
});

it("reports missing auth config when no password values are set", () => {
saveEnv();
clearAuthEnv();

assert.equal(isAuthConfigured(), false);
assert.equal(verifyPassword("anything"), false);
});
});
32 changes: 20 additions & 12 deletions apps/studio/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { randomBytes } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import {
isDemoModeAvailable,
isDemoModeForced,
isPublisherConfigured,
} from "./demoMode.js";
import {
verifyPlaintextPassword,
verifyScryptPassword,
} from "./adminPassword.js";

const SESSION_COOKIE = "sourcedraft_session";
/** 24 hours — in-memory MVP sessions, not durable account auth. */
Expand All @@ -17,7 +21,12 @@ type SessionRecord = {

const sessions = new Map<string, SessionRecord>();

function getAdminPassword(): string | null {
function getAdminPasswordHash(): string | null {
const hash = process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH?.trim();
return hash && hash.length > 0 ? hash : null;
}

function getLegacyAdminPassword(): string | null {
const password = process.env.SOURCEDRAFT_ADMIN_PASSWORD?.trim();
return password && password.length > 0 ? password : null;
}
Expand Down Expand Up @@ -103,23 +112,21 @@ function purgeExpiredSessions(): void {
}

export function isAuthConfigured(): boolean {
return getAdminPassword() !== null;
return getAdminPasswordHash() !== null || getLegacyAdminPassword() !== null;
}

export function verifyPassword(password: string): boolean {
const expected = getAdminPassword();
if (expected === null) {
return false;
const hash = getAdminPasswordHash();
if (hash !== null) {
return verifyScryptPassword(password, hash);
}

const provided = Buffer.from(password);
const target = Buffer.from(expected);

if (provided.length !== target.length) {
const legacyPassword = getLegacyAdminPassword();
if (legacyPassword === null) {
return false;
}

return timingSafeEqual(provided, target);
return verifyPlaintextPassword(password, legacyPassword);
}

export function createSession(options?: { demo?: boolean }): string {
Expand Down Expand Up @@ -198,7 +205,8 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
if (!isAuthConfigured() && !isDemoModeAvailable()) {
res.status(500).json({
ok: false,
error: "SOURCEDRAFT_ADMIN_PASSWORD is not configured.",
error:
"Studio auth is not configured. Set SOURCEDRAFT_ADMIN_PASSWORD_HASH or SOURCEDRAFT_ADMIN_PASSWORD.",
});
return;
}
Expand Down
Loading
Loading