security: await audit writes, add per-turn AssistantPrompt audit (#55)

- Auth.js authorize/signOut: await createAuditEntry on every branch so auth
  events land in the audit store before the JWT is minted / session closes.
  Previously these were fire-and-forget and would be dropped under DB load.
- Assistant chat: make appendPromptInjectionGuard async and await its own
  SecurityAlert audit; add auditUserPromptTurn() that records every user
  message turn as an AssistantPrompt entry containing conversationId, length,
  SHA-256 fingerprint, pageContext and whether the injection guard fired.
  Raw prompt text is intentionally not stored — the hash lets a responder
  correlate a chat transcript with a forensic request without the audit
  store accumulating a plain-text corpus of everything users typed.
- Replace bare crypto.* with explicit node:crypto imports.
- Document the retention posture in docs/security-architecture.md §6.

Fixes gitea #55.
This commit is contained in:
2026-04-17 15:06:17 +02:00
parent 01c45d0344
commit 3392297791
3 changed files with 85 additions and 18 deletions
+11 -9
View File
@@ -85,7 +85,7 @@ const config = {
: await authRateLimiter(rateLimitKeys);
if (!rateLimitResult.allowed) {
// Audit failed login (rate limited)
void createAuditEntry({
await createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: email.toLowerCase(),
@@ -109,7 +109,7 @@ const config = {
if (!user?.passwordHash) {
await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
logger.warn({ email, reason: "user_not_found" }, "Failed login attempt");
void createAuditEntry({
await createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: email.toLowerCase(),
@@ -127,7 +127,7 @@ const config = {
{ email, userId: user.id, reason: "account_deactivated" },
"Login blocked — account deactivated",
);
void createAuditEntry({
await createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: user.id,
@@ -143,7 +143,7 @@ const config = {
const isValid = await verify(user.passwordHash, password);
if (!isValid) {
logger.warn({ email, reason: "invalid_password" }, "Failed login attempt");
void createAuditEntry({
await createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: user.id,
@@ -176,7 +176,7 @@ const config = {
const delta = totpInstance.validate({ token: totp, window: 1 });
if (delta === null) {
logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification");
void createAuditEntry({
await createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: user.id,
@@ -196,7 +196,7 @@ const config = {
const accepted = await consumeTotpWindow(prisma, user.id);
if (!accepted) {
logger.warn({ email, reason: "totp_replay" }, "TOTP replay attack blocked");
void createAuditEntry({
await createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: user.id,
@@ -230,8 +230,10 @@ const config = {
});
logger.info({ email, userId: user.id }, "Successful login");
// Audit successful login
void createAuditEntry({
// Audit successful login. Awaited (not fire-and-forget) so the entry
// is durable before we return a session — forensic completeness
// matters even if it adds a few ms to the login path.
await createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: user.id,
@@ -338,7 +340,7 @@ const config = {
});
}
void createAuditEntry({
await createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: userId ?? email,