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
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/server/auth";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { buildRegistrationOptions, buildAuthenticationOptions, verifyReg, toB64Url, LOGIN_CHALLENGE_COOKIE } from "@/server/webauthn";
import type { RegistrationResponseJSON } from "@simplewebauthn/types";
/**
* SEC3-b: Passkey-Login vorbereiten (öffentlich, pre-session). Erzeugt Authentifizierungs-
* Optionen für discoverable Credentials (leere allowCredentials → der Browser wählt einen
* resident Passkey) und legt die Challenge in einem kurzlebigen httpOnly-Cookie ab.
*/
export async function beginPasskeyLogin() {
const options = await buildAuthenticationOptions([]);
const jar = await cookies();
jar.set(LOGIN_CHALLENGE_COOKIE, options.challenge, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 120,
});
return options;
}
/**
* SEC3-b: Passkey-Registrierung & -Verwaltung des angemeldeten Mandanten-Nutzers
* (EXEMPT vom Modul-Gating — eigene Auth über requireSession). Die Challenge wird
* zwischen Options- und Verify-Schritt in einem kurzlebigen httpOnly-Cookie gehalten.
*/
const REG_COOKIE = "wa_reg_challenge";
export async function beginPasskeyRegistration() {
const session = await requireSession();
// WS4b: Passkeys gehören der GLOBALEN Identity (stabile userID über Mandanten hinweg).
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const creds = await prisma.webAuthnCredential.findMany({ where: { identityId }, select: { credentialId: true, transports: true } });
const options = await buildRegistrationOptions({
userId: identityId,
userName: session.user.email ?? identityId,
userDisplayName: session.user.name ?? session.user.email ?? "Nutzer",
existing: creds,
});
const jar = await cookies();
jar.set(REG_COOKIE, options.challenge, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 300,
});
return options;
}
export async function finishPasskeyRegistration(response: RegistrationResponseJSON, deviceName: string) {
const session = await requireSession();
const jar = await cookies();
const expectedChallenge = jar.get(REG_COOKIE)?.value;
if (!expectedChallenge) throw new Error("Registrierung abgelaufen — bitte erneut starten.");
const verification = await verifyReg(response, expectedChallenge);
if (!verification.verified || !verification.registrationInfo) throw new Error("Passkey konnte nicht verifiziert werden.");
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
await prisma.webAuthnCredential.create({
data: {
identityId,
credentialId: toB64Url(credentialID),
publicKey: toB64Url(credentialPublicKey),
counter: BigInt(counter),
transports: response.response.transports ?? [],
deviceName: deviceName.trim().slice(0, 60) || null,
},
});
jar.delete(REG_COOKIE);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "webauthn_credential", entityId: session.user.id, after: { deviceName: deviceName.trim() || null } });
revalidatePath("/account");
}
export async function removePasskey(credentialDbId: string) {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const cred = await prisma.webAuthnCredential.findFirst({ where: { id: credentialDbId, identityId }, select: { id: true } });
if (!cred) throw new Error("Passkey nicht gefunden.");
await prisma.webAuthnCredential.delete({ where: { id: cred.id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "webauthn_credential", entityId: cred.id });
revalidatePath("/account");
}