Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import { createHash } from "node:crypto";
/**
* SEC2 — Rate-Limiting für sicherheitskritische Endpunkte.
*
* Zweck: Reset-Anfrage, Reset-Einlösung und Alt-Passwort-Prüfung dürfen nicht
* beliebig oft versucht werden — je **IP** und je **Konto** getrennt gezählt,
* damit weder ein einzelner Angreifer viele Konten noch viele Quellen ein Konto
* durchprobieren können.
*
* Ergänzt den bestehenden Konto-Lockout (F-05) am Login; SEC5 bündelt später die
* globalen Limits. Die Schnittstelle ist bewusst so geschnitten, dass SEC5 sie
* übernehmen kann, ohne die Aufrufer anzufassen.
*
* **Speicher:** Fenster-Zähler im Prozessspeicher. Das ist bei mehreren
* App-Instanzen pro Instanz gezählt — bewusst in Kauf genommen, weil der Limiter
* hier nur eine erste Bremse ist und die eigentlichen Sicherheitsgarantien
* (single-use-Tokens, Enumeration-Neutralität, Konto-Lockout) nicht davon
* abhängen. Ein geteilter Redis-Zähler gehört zu SEC5.
*
* Schlüssel werden gehasht abgelegt: der Speicher enthält damit weder
* Klartext-Adressen noch IPs.
*/
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
/** Aufräumen abgelaufener Fenster — verhindert unbegrenztes Wachstum. */
function sweep(now: number): void {
if (buckets.size < 5_000) return;
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(key);
}
}
function keyOf(scope: string, identifier: string): string {
return `${scope}:${createHash("sha256").update(identifier.toLowerCase()).digest("hex").slice(0, 32)}`;
}
export type RateLimitRule = { limit: number; windowMs: number };
/** Voreinstellungen der sicherheitskritischen Aktionen (SEC2 §6). */
export const RATE_LIMITS = {
/** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */
passwordResetRequest: { limit: 5, windowMs: 60 * 60_000 },
/** Reset-Einlösung: begrenzt das Durchprobieren manipulierter Links. */
passwordResetRedeem: { limit: 10, windowMs: 15 * 60_000 },
/** Alt-Passwort-Prüfung bei Selbständerung. */
passwordVerify: { limit: 10, windowMs: 15 * 60_000 },
/** Anforderung einer E-Mail-Änderung. */
emailChangeRequest: { limit: 5, windowMs: 60 * 60_000 },
} as const satisfies Record<string, RateLimitRule>;
export type RateLimitScope = keyof typeof RATE_LIMITS;
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
/**
* Zählt einen Versuch und meldet, ob er noch erlaubt ist.
* `identifier` ist die IP **oder** die Konto-Kennung — Aufrufer prüfen beide.
*/
export function consumeRateLimit(scope: RateLimitScope, identifier: string): RateLimitResult {
const rule = RATE_LIMITS[scope];
const now = Date.now();
sweep(now);
const key = keyOf(scope, identifier);
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + rule.windowMs });
return { allowed: true, retryAfterSeconds: 0 };
}
bucket.count++;
if (bucket.count > rule.limit) {
return { allowed: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
}
return { allowed: true, retryAfterSeconds: 0 };
}
/**
* Prüft IP **und** Konto in einem Aufruf. Beide Zähler werden erhöht, damit ein
* Angreifer weder über wechselnde Konten noch über wechselnde IPs entkommt.
*/
export function checkRateLimit(
scope: RateLimitScope,
parts: { ip?: string | null; account?: string | null },
): RateLimitResult {
const results: RateLimitResult[] = [];
if (parts.ip) results.push(consumeRateLimit(scope, `ip:${parts.ip}`));
if (parts.account) results.push(consumeRateLimit(scope, `acct:${parts.account}`));
const blocked = results.find((r) => !r.allowed);
return blocked ?? { allowed: true, retryAfterSeconds: 0 };
}
/** Nur für Tests. */
export function resetRateLimits(): void {
buckets.clear();
}