security: align client password policy with server, enforce AUTH_SECRET length + entropy (#56)
Client-side validators (reset-password, invite-accept, first-admin setup, user-create modal) previously checked password.length < 8 while every server-side Zod schema required .min(12). External API consumers (or a confused browser UI) could get past the client check but fail at the tRPC boundary — or worse, quietly under-enforce policy compared to what admins expect. Fix: introduce PASSWORD_MIN_LENGTH (12) and PASSWORD_MAX_LENGTH (128) in @capakraken/shared and import them from every pre-submit client validator and every server Zod schema. Single source of truth; drift becomes a compile error rather than a security finding. Also hardens the AUTH_SECRET runtime check: in addition to the existing placeholder-blacklist, production startup now rejects secrets shorter than 32 chars OR with Shannon entropy below 3.5 bits/char. That covers low-entropy-but-long values like "aaaa..." (38 chars, entropy 0) which would have passed the previous checks. Documented the rotation process for AUTH_SECRET + POSTGRES_PASSWORD in docs/security-architecture.md §3. Verified: - pnpm test:unit — 396 files / 1922 tests passed - pnpm --filter @capakraken/web exec tsc --noEmit — clean - pnpm --filter @capakraken/api exec tsc --noEmit — clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { use, useState } from "react";
|
import { use, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
|
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
@@ -21,8 +22,8 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
|
|||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
if (password.length < 8) {
|
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||||
setFormError("Password must be at least 8 characters.");
|
setFormError(PASSWORD_POLICY_MESSAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password !== confirm) {
|
if (password !== confirm) {
|
||||||
@@ -40,9 +41,7 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
|
|||||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
Password updated
|
Password updated
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
<p className="text-sm text-gray-500 mb-6">Your password has been changed successfully.</p>
|
||||||
Your password has been changed successfully.
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/auth/signin")}
|
onClick={() => router.push("/auth/signin")}
|
||||||
@@ -59,12 +58,8 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
|
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Set a new password</h1>
|
||||||
Set a new password
|
<p className="mt-1 text-sm text-gray-500">Choose a new password for your account.</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Choose a new password for your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -87,8 +82,8 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={PASSWORD_MIN_LENGTH}
|
||||||
placeholder="At least 8 characters"
|
placeholder={`At least ${PASSWORD_MIN_LENGTH} characters`}
|
||||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, use } from "react";
|
import { useState, use } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
@@ -13,10 +14,11 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
const { data: invite, isLoading, error: inviteError } = trpc.invite.getInvite.useQuery(
|
const {
|
||||||
{ token },
|
data: invite,
|
||||||
{ retry: false },
|
isLoading,
|
||||||
);
|
error: inviteError,
|
||||||
|
} = trpc.invite.getInvite.useQuery({ token }, { retry: false });
|
||||||
|
|
||||||
const acceptMutation = trpc.invite.acceptInvite.useMutation({
|
const acceptMutation = trpc.invite.acceptInvite.useMutation({
|
||||||
onSuccess: () => setDone(true),
|
onSuccess: () => setDone(true),
|
||||||
@@ -26,8 +28,14 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
|||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
if (password.length < 8) { setFormError("Password must be at least 8 characters."); return; }
|
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||||
if (password !== confirm) { setFormError("Passwords do not match."); return; }
|
setFormError(PASSWORD_POLICY_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
setFormError("Passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
await acceptMutation.mutateAsync({ token, password });
|
await acceptMutation.mutateAsync({ token, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +56,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
|||||||
Invite link invalid or expired
|
Invite link invalid or expired
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{inviteError?.message ?? "This invite link is no longer valid. Please request a new invitation from your administrator."}
|
{inviteError?.message ??
|
||||||
|
"This invite link is no longer valid. Please request a new invitation from your administrator."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,8 +91,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
You have been invited as <strong>{invite.role}</strong> to CapaKraken.
|
You have been invited as <strong>{invite.role}</strong> to CapaKraken. Set a password to
|
||||||
Set a password to activate your account (<span className="font-medium">{invite.email}</span>).
|
activate your account (<span className="font-medium">{invite.email}</span>).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,8 +112,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={PASSWORD_MIN_LENGTH}
|
||||||
placeholder="At least 8 characters"
|
placeholder={`At least ${PASSWORD_MIN_LENGTH} characters`}
|
||||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||||
import { createFirstAdmin } from "./actions.js";
|
import { createFirstAdmin } from "./actions.js";
|
||||||
|
|
||||||
export function SetupClient() {
|
export function SetupClient() {
|
||||||
@@ -20,8 +21,8 @@ export function SetupClient() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
if (password.length < 8) {
|
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||||
setFormError("Password must be at least 8 characters.");
|
setFormError(PASSWORD_POLICY_MESSAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
@@ -73,9 +74,7 @@ export function SetupClient() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
|
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
|
||||||
First-run setup
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Create the initial administrator account for CapaKraken.
|
Create the initial administrator account for CapaKraken.
|
||||||
</p>
|
</p>
|
||||||
@@ -125,8 +124,8 @@ export function SetupClient() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={PASSWORD_MIN_LENGTH}
|
||||||
placeholder="At least 8 characters"
|
placeholder={`At least ${PASSWORD_MIN_LENGTH} characters`}
|
||||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { SystemRole } from "@capakraken/db";
|
import { SystemRole } from "@capakraken/db";
|
||||||
|
import {
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_MIN_LENGTH,
|
||||||
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
} from "@capakraken/shared";
|
||||||
|
|
||||||
export type SetupResult =
|
export type SetupResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
@@ -13,8 +18,14 @@ export async function createFirstAdmin(formData: {
|
|||||||
}): Promise<SetupResult> {
|
}): Promise<SetupResult> {
|
||||||
// Validate
|
// Validate
|
||||||
if (!formData.name.trim()) return { error: "validation", message: "Name is required." };
|
if (!formData.name.trim()) return { error: "validation", message: "Name is required." };
|
||||||
if (!formData.email.includes("@")) return { error: "validation", message: "Valid email required." };
|
if (!formData.email.includes("@"))
|
||||||
if (formData.password.length < 8) return { error: "validation", message: "Password must be at least 8 characters." };
|
return { error: "validation", message: "Valid email required." };
|
||||||
|
if (
|
||||||
|
formData.password.length < PASSWORD_MIN_LENGTH ||
|
||||||
|
formData.password.length > PASSWORD_MAX_LENGTH
|
||||||
|
) {
|
||||||
|
return { error: "validation", message: PASSWORD_POLICY_MESSAGE };
|
||||||
|
}
|
||||||
|
|
||||||
// TOCTOU guard — check again inside the action
|
// TOCTOU guard — check again inside the action
|
||||||
const count = await prisma.user.count();
|
const count = await prisma.user.count();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SystemRole } from "@capakraken/shared";
|
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
||||||
@@ -129,7 +129,10 @@ export function UserCreateModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={
|
disabled={
|
||||||
isPending || !state.name.trim() || !state.email.trim() || state.password.length < 8
|
isPending ||
|
||||||
|
!state.name.trim() ||
|
||||||
|
!state.email.trim() ||
|
||||||
|
state.password.length < PASSWORD_MIN_LENGTH
|
||||||
}
|
}
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe("runtime env validation", () => {
|
|||||||
expect(
|
expect(
|
||||||
getRuntimeEnvViolations({
|
getRuntimeEnvViolations({
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
NEXTAUTH_SECRET: "super-long-random-secret",
|
NEXTAUTH_SECRET: "super-long-random-secret-with-enough-entropy-abc123",
|
||||||
NEXTAUTH_URL: "https://capakraken.example.com",
|
NEXTAUTH_URL: "https://capakraken.example.com",
|
||||||
}),
|
}),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
@@ -49,11 +49,33 @@ describe("runtime env validation", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an auth secret shorter than the minimum length in production", () => {
|
||||||
|
expect(
|
||||||
|
getRuntimeEnvViolations({
|
||||||
|
NODE_ENV: "production",
|
||||||
|
NEXTAUTH_SECRET: "short-but-random-xyz", // 20 chars
|
||||||
|
NEXTAUTH_URL: "https://capakraken.example.com",
|
||||||
|
}),
|
||||||
|
).toContain("AUTH_SECRET or NEXTAUTH_SECRET must be at least 32 characters in production.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a long-but-low-entropy auth secret in production", () => {
|
||||||
|
expect(
|
||||||
|
getRuntimeEnvViolations({
|
||||||
|
NODE_ENV: "production",
|
||||||
|
NEXTAUTH_SECRET: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 38 a's
|
||||||
|
NEXTAUTH_URL: "https://capakraken.example.com",
|
||||||
|
}),
|
||||||
|
).toContain(
|
||||||
|
"AUTH_SECRET or NEXTAUTH_SECRET entropy is too low; generate with `openssl rand -base64 32`.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects non-https auth urls in production", () => {
|
it("rejects non-https auth urls in production", () => {
|
||||||
expect(
|
expect(
|
||||||
getRuntimeEnvViolations({
|
getRuntimeEnvViolations({
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
NEXTAUTH_SECRET: "super-long-random-secret",
|
NEXTAUTH_SECRET: "super-long-random-secret-with-enough-entropy-abc123",
|
||||||
NEXTAUTH_URL: "http://capakraken.example.com",
|
NEXTAUTH_URL: "http://capakraken.example.com",
|
||||||
}),
|
}),
|
||||||
).toContain("AUTH_URL or NEXTAUTH_URL must use https in production.");
|
).toContain("AUTH_URL or NEXTAUTH_URL must use https in production.");
|
||||||
|
|||||||
@@ -10,6 +10,29 @@ const DISALLOWED_PRODUCTION_SECRETS = new Set([
|
|||||||
"ci-test-secret-minimum-32-chars-xx",
|
"ci-test-secret-minimum-32-chars-xx",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// A cryptographically generated secret (openssl rand -base64 32 / -hex 32)
|
||||||
|
// has ≥ 32 ASCII characters and high Shannon entropy (≥ 4 bits per char
|
||||||
|
// for base64, ≥ 4 for hex). Values below these thresholds are either
|
||||||
|
// too short to resist offline brute force of the JWT signature, or are
|
||||||
|
// low-entropy strings like "password1234567890123456789012345678" that
|
||||||
|
// pass a simple length check but are trivially guessable.
|
||||||
|
const MIN_AUTH_SECRET_LENGTH = 32;
|
||||||
|
const MIN_AUTH_SECRET_SHANNON_ENTROPY = 3.5;
|
||||||
|
|
||||||
|
function shannonEntropy(value: string): number {
|
||||||
|
if (value.length === 0) return 0;
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const ch of value) {
|
||||||
|
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
let entropy = 0;
|
||||||
|
for (const count of counts.values()) {
|
||||||
|
const p = count / value.length;
|
||||||
|
entropy -= p * Math.log2(p);
|
||||||
|
}
|
||||||
|
return entropy;
|
||||||
|
}
|
||||||
|
|
||||||
type RuntimeEnv = Partial<Record<string, string | undefined>>;
|
type RuntimeEnv = Partial<Record<string, string | undefined>>;
|
||||||
|
|
||||||
function readEnvValue(env: RuntimeEnv, ...names: string[]): string | null {
|
function readEnvValue(env: RuntimeEnv, ...names: string[]): string | null {
|
||||||
@@ -46,6 +69,17 @@ export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[]
|
|||||||
violations.push(
|
violations.push(
|
||||||
"AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.",
|
"AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.",
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
if (authSecret.length < MIN_AUTH_SECRET_LENGTH) {
|
||||||
|
violations.push(
|
||||||
|
`AUTH_SECRET or NEXTAUTH_SECRET must be at least ${MIN_AUTH_SECRET_LENGTH} characters in production.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shannonEntropy(authSecret) < MIN_AUTH_SECRET_SHANNON_ENTROPY) {
|
||||||
|
violations.push(
|
||||||
|
"AUTH_SECRET or NEXTAUTH_SECRET entropy is too low; generate with `openssl rand -base64 32`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
violations.push(...getDevBypassViolations(env));
|
violations.push(...getDevBypassViolations(env));
|
||||||
|
|||||||
@@ -67,7 +67,19 @@ publicProcedure
|
|||||||
- Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides
|
- Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides
|
||||||
- The admin settings mutation no longer persists new secret values into `SystemSettings`; secret inputs must be provisioned through environment or a deployment-time secret manager, and legacy database copies can be cleared explicitly
|
- The admin settings mutation no longer persists new secret values into `SystemSettings`; secret inputs must be provisioned through environment or a deployment-time secret manager, and legacy database copies can be cleared explicitly
|
||||||
- The admin UI now exposes runtime secret source/status plus an explicit "clear legacy DB secrets" cleanup path so operators can complete the migration without direct database writes
|
- The admin UI now exposes runtime secret source/status plus an explicit "clear legacy DB secrets" cleanup path so operators can complete the migration without direct database writes
|
||||||
- Production startup now validates Auth.js runtime configuration and refuses to boot if `AUTH_SECRET`/`NEXTAUTH_SECRET` is missing, left on a known development placeholder, or paired with a non-HTTPS public auth URL
|
- Production startup now validates Auth.js runtime configuration and refuses to boot if `AUTH_SECRET`/`NEXTAUTH_SECRET` is missing, left on a known development placeholder, paired with a non-HTTPS public auth URL, shorter than 32 characters, or failing a Shannon-entropy check (≥ 3.5 bits/char)
|
||||||
|
- User passwords: minimum 12 characters, maximum 128 characters; single `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH` constant (`@capakraken/shared/constants`) is imported by every client-side pre-submit validator and server-side Zod schema — prevents client/server policy drift
|
||||||
|
|
||||||
|
#### Secret rotation
|
||||||
|
|
||||||
|
- **`AUTH_SECRET` / `NEXTAUTH_SECRET`** is the signing key for all JWT session cookies. Rotation forces every user to re-authenticate on their next request.
|
||||||
|
- Generate replacement: `openssl rand -base64 32`
|
||||||
|
- Deploy path:
|
||||||
|
1. Update the secret in the deployment secret store (not in repo).
|
||||||
|
2. Roll all application containers — existing JWTs signed under the old key fail verification and the user is redirected to sign-in.
|
||||||
|
3. There is no multi-key transition window: this is a hard cut on purpose, because a compromised signing key must be retired immediately.
|
||||||
|
- Recommended cadence: quarterly, or immediately on suspected compromise.
|
||||||
|
- **`POSTGRES_PASSWORD`** rotation is coordinated across postgres container init, the app container's `DATABASE_URL`, and any external replication consumers — follow the deployment runbook.
|
||||||
|
|
||||||
### Anonymization
|
### Anonymization
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import {
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_MIN_LENGTH,
|
||||||
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
} from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc.js";
|
import { createTRPCRouter, publicProcedure } from "../trpc.js";
|
||||||
@@ -78,7 +83,10 @@ export const authRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
password: z.string().min(12, "Password must be at least 12 characters.").max(128),
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE)
|
||||||
|
.max(PASSWORD_MAX_LENGTH),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { SystemRole } from "@capakraken/db";
|
import { SystemRole } from "@capakraken/db";
|
||||||
|
import {
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_MIN_LENGTH,
|
||||||
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
} from "@capakraken/shared";
|
||||||
import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js";
|
import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js";
|
||||||
import { getAppBaseUrl } from "../lib/app-base-url.js";
|
import { getAppBaseUrl } from "../lib/app-base-url.js";
|
||||||
import { sendEmail } from "../lib/email.js";
|
import { sendEmail } from "../lib/email.js";
|
||||||
@@ -114,7 +119,10 @@ export const inviteRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
password: z.string().min(12, "Password must be at least 12 characters.").max(128),
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE)
|
||||||
|
.max(PASSWORD_MAX_LENGTH),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Prisma } from "@capakraken/db";
|
import { Prisma } from "@capakraken/db";
|
||||||
|
import {
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_MIN_LENGTH,
|
||||||
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
} from "@capakraken/shared";
|
||||||
import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types";
|
import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -11,12 +16,12 @@ export const CreateUserInputSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
|
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
|
||||||
password: z.string().min(12).max(128),
|
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SetUserPasswordInputSchema = z.object({
|
export const SetUserPasswordInputSchema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
password: z.string().min(12, "Password must be at least 12 characters").max(128),
|
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateUserRoleInputSchema = z.object({
|
export const UpdateUserRoleInputSchema = z.object({
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ export function averagePerWorkingDay(totalHours: number, workingDays: number): n
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DAY_KEYS: readonly (keyof WeekdayAvailability)[] = [
|
export const DAY_KEYS: readonly (keyof WeekdayAvailability)[] = [
|
||||||
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
|
"sunday",
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function normalizeCityName(cityName?: string | null): string | null {
|
export function normalizeCityName(cityName?: string | null): string | null {
|
||||||
@@ -51,6 +57,13 @@ export const BUDGET_WARNING_THRESHOLDS = {
|
|||||||
export const DEFAULT_WORKING_HOURS_PER_DAY = 8;
|
export const DEFAULT_WORKING_HOURS_PER_DAY = 8;
|
||||||
export const DEFAULT_OPENAI_MODEL = "gpt-5.4";
|
export const DEFAULT_OPENAI_MODEL = "gpt-5.4";
|
||||||
|
|
||||||
|
// Single source of truth for password policy. Server-side Zod schemas and
|
||||||
|
// client-side pre-submit validators must both import these so divergence
|
||||||
|
// (e.g. client allowing 8 chars when server requires 12) cannot recur.
|
||||||
|
export const PASSWORD_MIN_LENGTH = 12;
|
||||||
|
export const PASSWORD_MAX_LENGTH = 128;
|
||||||
|
export const PASSWORD_POLICY_MESSAGE = `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`;
|
||||||
|
|
||||||
export const DEFAULT_AVAILABILITY = {
|
export const DEFAULT_AVAILABILITY = {
|
||||||
monday: 8,
|
monday: 8,
|
||||||
tuesday: 8,
|
tuesday: 8,
|
||||||
@@ -60,7 +73,7 @@ export const DEFAULT_AVAILABILITY = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const VALUE_SCORE_WEIGHTS = {
|
export const VALUE_SCORE_WEIGHTS = {
|
||||||
SKILL_DEPTH: 0.30,
|
SKILL_DEPTH: 0.3,
|
||||||
SKILL_BREADTH: 0.15,
|
SKILL_BREADTH: 0.15,
|
||||||
COST_EFFICIENCY: 0.25,
|
COST_EFFICIENCY: 0.25,
|
||||||
CHARGEABILITY: 0.15,
|
CHARGEABILITY: 0.15,
|
||||||
|
|||||||
Reference in New Issue
Block a user