feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -28,6 +28,9 @@ function createContext(overrides: Record<string, unknown> = {}) {
},
attachments: {},
debugging: {},
messageRevisions: {
verifyRevision: vi.fn(async () => true)
},
currentUser: null,
currentRoom: null,
savedRooms: [],

View File

@@ -20,7 +20,9 @@ import { Action } from '@ngrx/store';
import {
DELETED_MESSAGE_CONTENT,
type ChatEvent,
type ChatInventoryItem,
type Message,
type MessageRevision,
type Room,
type User
} from '../../shared-kernel';
@@ -40,8 +42,10 @@ import {
buildLocalInventoryMap,
findMissingIds,
hydrateMessage,
mergeIncomingMessage
mergeIncomingMessage,
mergeIncomingRevision
} from './messages.helpers';
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
type AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
@@ -61,7 +65,7 @@ type IncomingMessageType =
interface IncomingMessageEvent extends Omit<ChatEvent, 'type'> {
type: IncomingMessageType;
items?: InventoryItem[];
items?: ChatInventoryItem[];
ids?: string[];
messages?: Message[];
attachments?: AttachmentMetaMap;
@@ -94,6 +98,7 @@ export interface IncomingMessageContext {
webrtc: RealtimeSessionFacade;
attachments: AttachmentFacade;
debugging: DebuggingService;
messageRevisions: MessageRevisionService;
currentUser: User | null;
currentRoom: Room | null;
savedRooms?: Room[];
@@ -383,6 +388,54 @@ function handleChatMessage(
return of(MessagesActions.receiveMessage({ message: msg }));
}
function handleMessageRevision(
event: IncomingMessageEvent,
ctx: IncomingMessageContext
): Observable<Action> {
const revision = (event as { revision?: MessageRevision }).revision;
if (!revision) {
return EMPTY;
}
return from(
(async () => {
const isValid = await ctx.messageRevisions.verifyRevision(revision);
if (!isValid) {
return null;
}
const { message, changed } = await mergeIncomingRevision(revision, ctx.db);
if (!changed) {
return null;
}
if (message.isDeleted) {
await ctx.attachments.deleteForMessage(message.id);
return MessagesActions.deleteMessageSuccess({ messageId: message.id });
}
if (revision.type === 'create') {
return MessagesActions.receiveMessage({ message });
}
if (revision.type.endsWith('edit')) {
return MessagesActions.editMessageSuccess({
messageId: message.id,
content: message.content,
editedAt: message.editedAt ?? revision.editedAt
});
}
return null;
})()
).pipe(
mergeMap((action) => action ? of(action) : EMPTY)
);
}
/** Applies a remote message edit to the local DB and store. */
function handleMessageEdited(
event: IncomingMessageEvent,
@@ -664,6 +717,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
'chat-message': handleChatMessage,
'message-edited': handleMessageEdited,
'message-deleted': handleMessageDeleted,
'message-revision': handleMessageRevision,
// Reactions
'reaction-added': handleReactionAdded,

View File

@@ -55,6 +55,8 @@ import { hydrateMessages } from './messages.helpers';
import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules';
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
/** Cap on simultaneous browser-cache prefetches for apps with many saved rooms. */
@@ -73,6 +75,7 @@ export class MessagesEffects {
private readonly linkMetadata = inject(LinkMetadataService);
private readonly platform = inject(PlatformService);
private readonly i18n = inject(AppI18nService);
private readonly messageRevisions = inject(MessageRevisionService);
/** Loads messages for a room from the local database, hydrating reactions. */
loadMessages$ = createEffect(() =>
@@ -235,7 +238,7 @@ export class MessagesEffects {
return of(MessagesActions.sendMessageFailure({ error: this.i18n.instant('chat.effects.notConnectedToRoom') }));
}
const message: Message = {
const draftMessage: Message = {
id: uuidv4(),
roomId: currentRoom.id,
channelId: channelId || 'general',
@@ -245,27 +248,44 @@ export class MessagesEffects {
timestamp: this.timeSync.now(),
reactions: [],
isDeleted: false,
replyToId
replyToId,
revision: 0
};
this.attachments.rememberMessageRoom(message.id, message.roomId);
return from((async () => {
const revision = await this.messageRevisions.createSignedRevision({
message: draftMessage,
type: 'create',
actorId: currentUser.id,
editedAt: draftMessage.timestamp
});
const message = materializeMessageFromRevision(null, revision);
this.trackBackgroundOperation(
this.db.saveMessage(message),
'Failed to persist outgoing chat message',
{
channelId: message.channelId,
contentLength: message.content.length,
messageId: message.id,
roomId: message.roomId
}
);
this.attachments.rememberMessageRoom(message.id, message.roomId);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'chat-message',
message });
this.trackBackgroundOperation(
this.db.saveMessage(message),
'Failed to persist outgoing chat message',
{
channelId: message.channelId,
contentLength: message.content.length,
messageId: message.id,
roomId: message.roomId
}
);
return of(MessagesActions.sendMessageSuccess({ message }));
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist outgoing message revision',
{ messageId: message.id, revision: revision.revision }
);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'chat-message', message });
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.sendMessageSuccess({ message });
})());
}),
catchError((error) =>
of(MessagesActions.sendMessageFailure({ error: error.message }))
@@ -295,26 +315,38 @@ export class MessagesEffects {
const editedAt = this.timeSync.now();
this.trackBackgroundOperation(
this.db.updateMessage(messageId, { content,
editedAt }),
'Failed to persist edited chat message',
{
contentLength: content.length,
editedAt,
messageId
}
);
return from((async () => {
const revision = await this.messageRevisions.createSignedRevision({
message: existing,
type: 'author-edit',
actorId: currentUser.id,
content,
editedAt
});
const updatedMessage = materializeMessageFromRevision(existing, revision);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'message-edited',
messageId,
content,
editedAt });
this.trackBackgroundOperation(
this.db.saveMessage(updatedMessage),
'Failed to persist edited chat message',
{
contentLength: content.length,
editedAt,
messageId
}
);
return of(MessagesActions.editMessageSuccess({ messageId,
content,
editedAt }));
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist edited message revision',
{ messageId, revision: revision.revision }
);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.editMessageSuccess({ messageId, content, editedAt });
})());
}),
catchError((error) =>
of(MessagesActions.editMessageFailure({ error: error.message }))
@@ -346,30 +378,39 @@ export class MessagesEffects {
const deletedAt = this.timeSync.now();
this.trackBackgroundOperation(
this.db.updateMessage(messageId, {
content: DELETED_MESSAGE_CONTENT,
return from((async () => {
const revision = await this.messageRevisions.createSignedRevision({
message: existing,
type: 'author-delete',
actorId: currentUser.id,
editedAt: deletedAt,
isDeleted: true
}),
'Failed to persist message deletion',
{
deletedAt,
messageId
}
);
});
const deletedMessage = materializeMessageFromRevision(existing, revision);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete message attachments',
{ messageId }
);
this.trackBackgroundOperation(
this.db.saveMessage(deletedMessage),
'Failed to persist message deletion',
{ deletedAt, messageId }
);
this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId,
deletedAt });
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist deleted message revision',
{ messageId, revision: revision.revision }
);
return of(MessagesActions.deleteMessageSuccess({ messageId }));
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete message attachments',
{ messageId }
);
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedAt });
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.deleteMessageSuccess({ messageId });
})());
}),
catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message }))
@@ -399,37 +440,54 @@ export class MessagesEffects {
return of(MessagesActions.deleteMessageFailure({ error: this.i18n.instant('chat.effects.permissionDenied') }));
}
const deletedAt = this.timeSync.now();
return from(this.db.getMessageById(messageId)).pipe(
mergeMap((existing) => {
if (!existing) {
return of(MessagesActions.deleteMessageFailure({ error: this.i18n.instant('chat.effects.messageNotFound') }));
}
this.trackBackgroundOperation(
this.db.updateMessage(messageId, {
content: DELETED_MESSAGE_CONTENT,
editedAt: deletedAt,
isDeleted: true
}),
'Failed to persist admin message deletion',
{
deletedBy: currentUser.id,
deletedAt,
messageId
}
const deletedAt = this.timeSync.now();
return from((async () => {
const revision = await this.messageRevisions.createSignedRevision({
message: existing,
type: 'moderate-delete',
actorId: currentUser.id,
editedAt: deletedAt,
isDeleted: true
});
const deletedMessage = materializeMessageFromRevision(existing, revision);
this.trackBackgroundOperation(
this.db.saveMessage(deletedMessage),
'Failed to persist admin message deletion',
{ deletedBy: currentUser.id, deletedAt, messageId }
);
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist moderated delete revision',
{ messageId, revision: revision.revision }
);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete admin-deleted message attachments',
{ deletedBy: currentUser.id, messageId }
);
this.webrtc.broadcastMessage({
type: 'message-deleted',
messageId,
deletedBy: currentUser.id,
deletedAt
});
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.deleteMessageSuccess({ messageId });
})());
})
);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete admin-deleted message attachments',
{
deletedBy: currentUser.id,
messageId
}
);
this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId,
deletedBy: currentUser.id,
deletedAt });
return of(MessagesActions.deleteMessageSuccess({ messageId }));
}),
catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message }))
@@ -606,12 +664,13 @@ export class MessagesEffects {
webrtc: this.webrtc,
attachments: this.attachments,
debugging: this.debugging,
messageRevisions: this.messageRevisions,
currentUser: currentUser ?? null,
currentRoom,
savedRooms
};
return dispatchIncomingMessage(event, ctx).pipe(
return dispatchIncomingMessage(event as Parameters<typeof dispatchIncomingMessage>[0], ctx).pipe(
catchError((error) => {
const eventRecord = event as unknown as Record<string, unknown>;
const messageRecord = (eventRecord['message'] && typeof eventRecord['message'] === 'object' && !Array.isArray(eventRecord['message']))
@@ -658,6 +717,7 @@ export class MessagesEffects {
webrtc: this.webrtc,
attachments: this.attachments,
debugging: this.debugging,
messageRevisions: this.messageRevisions,
currentUser: currentUser ?? null,
currentRoom,
savedRooms

View File

@@ -2,10 +2,22 @@
* Message store helpers - delegates pure domain logic to `domains/chat/domain/`
* and provides DB-dependent hydration/merge operations at the application level.
*/
import { Message } from '../../shared-kernel';
import {
Message,
type MessageRevision
} from '../../shared-kernel';
import { DatabaseService } from '../../infrastructure/persistence';
import { getMessageTimestamp, normaliseDeletedMessage } from '../../domains/chat/domain/rules/message.rules';
import type { InventoryItem } from '../../domains/chat/domain/rules/message-sync.rules';
import {
computeMessageHeadHashFromMessage,
getMessageRevision,
shouldApplyIncomingRevision
} from '../../domains/chat/domain/rules/message-integrity.rules';
import {
materializeMessageFromRevision,
revisionBeatsMessage
} from '../../domains/chat/domain/rules/message-revision.builder.rules';
// Re-export domain logic so existing callers keep working
export {
@@ -58,26 +70,28 @@ export async function buildInventoryItem(
_db: DatabaseService,
attachmentCountOverride?: number
): Promise<InventoryItem> {
const revision = getMessageRevision(msg);
const headHash = msg.headHash ?? await computeMessageHeadHashFromMessage(msg, revision);
if (msg.isDeleted) {
return {
id: msg.id,
ts: getMessageTimestamp(msg),
rc: 0,
ac: 0
ac: 0,
revision,
headHash
};
}
const item: InventoryItem = {
return {
id: msg.id,
ts: getMessageTimestamp(msg),
rc: msg.reactions?.length ?? 0
rc: msg.reactions?.length ?? 0,
ac: attachmentCountOverride ?? 0,
revision,
headHash
};
if (attachmentCountOverride !== undefined) {
item.ac = attachmentCountOverride;
}
return item;
}
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID.
@@ -90,25 +104,17 @@ export async function buildLocalInventoryMap(
messages: Message[],
_db: DatabaseService,
attachmentCountOverrides?: ReadonlyMap<string, number>
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
const map = new Map<string, { ts: number; rc: number; ac: number }>();
): Promise<Map<string, InventoryItem>> {
const map = new Map<string, InventoryItem>();
for (const msg of messages) {
if (msg.isDeleted) {
map.set(msg.id, {
ts: getMessageTimestamp(msg),
rc: 0,
ac: 0
});
const item = await buildInventoryItem(
msg,
_db,
attachmentCountOverrides?.get(msg.id)
);
continue;
}
map.set(msg.id, {
ts: getMessageTimestamp(msg),
rc: msg.reactions?.length ?? 0,
ac: attachmentCountOverrides?.get(msg.id) ?? 0
});
map.set(msg.id, item);
}
return map;
@@ -125,11 +131,22 @@ export interface MergeResult {
* Handles message upsert and reaction deduplication, then returns
* the fully hydrated message alongside a `changed` flag.
*/
export async function mergeIncomingMessage(
incoming: Message,
db: DatabaseService
): Promise<MergeResult> {
const existing = await db.getMessageById(incoming.id);
function shouldApplyIncomingMessage(incoming: Message, existing: Message | null): boolean {
const incomingRevision = getMessageRevision(incoming);
const existingRevision = getMessageRevision(existing ?? undefined);
if (incoming.headHash) {
const existingHeadHash = existing?.headHash
?? '';
return shouldApplyIncomingRevision(
incomingRevision,
existingRevision,
incoming.headHash,
existingHeadHash
);
}
const existingTs = existing ? getMessageTimestamp(existing) : -1;
const incomingTs = getMessageTimestamp(incoming);
const isDeletedStateNewer =
@@ -137,10 +154,70 @@ export async function mergeIncomingMessage(
incomingTs === existingTs &&
incoming.isDeleted &&
!existing.isDeleted;
const isNewer = !existing || incomingTs > existingTs || isDeletedStateNewer;
return !existing || incomingTs > existingTs || isDeletedStateNewer;
}
export async function mergeIncomingRevision(
revision: MessageRevision,
db: DatabaseService
): Promise<MergeResult> {
const existing = await db.getMessageById(revision.messageId);
if (!revisionBeatsMessage(revision, existing)) {
if (!existing) {
return {
message: materializeMessageFromRevision(null, revision),
changed: false
};
}
return {
message: normaliseDeletedMessage(existing),
changed: false
};
}
const message = materializeMessageFromRevision(existing, revision);
await db.saveMessage(message);
await db.saveMessageRevision(revision);
if (message.isDeleted) {
return {
message: normaliseDeletedMessage(message),
changed: true
};
}
const reactions = await db.getReactionsForMessage(message.id);
return {
message: {
...message,
reactions
},
changed: true
};
}
export async function mergeIncomingMessage(
incoming: Message,
db: DatabaseService
): Promise<MergeResult> {
const existing = await db.getMessageById(incoming.id);
const isNewer = shouldApplyIncomingMessage(incoming, existing);
if (isNewer) {
await db.saveMessage(incoming);
const persisted = incoming.headHash
? incoming
: {
...incoming,
revision: getMessageRevision(incoming),
headHash: await computeMessageHeadHashFromMessage(incoming, getMessageRevision(incoming))
};
await db.saveMessage(persisted);
}
// Persist incoming reactions (deduped by the DB layer)

View File

@@ -119,7 +119,6 @@ export class RoomSettingsEffects {
if (canManageRoom) {
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
actingRole: currentUserRole ?? undefined,
name: updatedSettings.name,
description: updatedSettings.description,
@@ -175,7 +174,6 @@ export class RoomSettingsEffects {
});
this.serverDirectory.updateServer(currentRoom.id, {
currentOwnerId: currentUser.id,
actingRole: role ?? undefined,
channels
}, {
@@ -286,7 +284,6 @@ export class RoomSettingsEffects {
});
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
@@ -355,7 +352,6 @@ export class RoomSettingsEffects {
});
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
actingRole: isOwner ? 'host' : undefined,
icon,
iconUpdatedAt

View File

@@ -54,6 +54,7 @@ import {
getPersistedCurrentUserId
} from './rooms.helpers';
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
import { SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
1_500,
@@ -319,6 +320,10 @@ export class RoomStateSyncEffects {
);
}
case 'auth_required':
case 'auth_error':
return of(UsersActions.loadCurrentUserFailure({ error: SESSION_EXPIRED_ERROR_CODE }));
default:
return EMPTY;
}

View File

@@ -753,7 +753,6 @@ export class RoomsEffects {
});
this.serverDirectory.updateServer(roomId, {
currentOwnerId: currentUser.id,
actingRole: 'host',
ownerId: nextHostId,
ownerPublicKey: nextHostOderId

View File

@@ -3,6 +3,7 @@
*/
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
Actions,
createEffect,
@@ -20,7 +21,8 @@ import {
catchError,
withLatestFrom,
tap,
switchMap
switchMap,
filter
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import { MessagesActions } from '../messages/messages.actions';
@@ -47,9 +49,11 @@ import {
Room,
User
} from '../../shared-kernel';
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
import { clearStoredCurrentUserId, setStoredCurrentUserId } from '../../core/storage/current-user-storage';
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
import { AppI18nService } from '../../core/i18n';
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
import { hasValidPersistedSession, SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
type IncomingModerationExtraAction =
| ReturnType<typeof RoomsActions.forgetRoom>
@@ -68,6 +72,8 @@ export class UsersEffects {
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
private readonly i18n = inject(AppI18nService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly router = inject(Router);
/** Prepares persisted state for a successful login before exposing the user in-memory. */
authenticateUser$ = createEffect(() =>
@@ -106,6 +112,14 @@ export class UsersEffects {
const sanitizedUser = this.clearStartupVoiceConnection(user);
if (!this.hasPersistedSessionToken(sanitizedUser)) {
clearStoredCurrentUserId();
return of(UsersActions.loadCurrentUserFailure({
error: SESSION_EXPIRED_ERROR_CODE
}));
}
if (sanitizedUser === user) {
return of(UsersActions.loadCurrentUserSuccess({ user }));
}
@@ -205,8 +219,6 @@ export class UsersEffects {
return this.serverDirectory.kickServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId
},
this.toSourceSelector(room)
@@ -287,8 +299,6 @@ export class UsersEffects {
return this.serverDirectory.banServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId,
banId: ban.oderId,
displayName: ban.displayName,
@@ -358,8 +368,6 @@ export class UsersEffects {
return this.serverDirectory.unbanServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
banId: oderId
},
this.toSourceSelector(room)
@@ -477,6 +485,24 @@ export class UsersEffects {
{ dispatch: false }
);
/** Send users back to login when their persisted session token is missing or rejected. */
redirectOnSessionExpired$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.loadCurrentUserFailure),
filter(({ error }) => error === SESSION_EXPIRED_ERROR_CODE),
tap(() => {
clearStoredCurrentUserId();
void this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
}
});
})
),
{ dispatch: false }
);
/** Keep signaling identity aligned with the current profile to avoid stale fallback names. */
syncSignalingIdentity$ = createEffect(
() =>
@@ -511,6 +537,15 @@ export class UsersEffects {
return savedRooms.find((room) => room.id === roomId) ?? null;
}
private hasPersistedSessionToken(user: User): boolean {
return hasValidPersistedSession(
user,
this.serverDirectory.activeServer()?.url,
(serverUrl) => this.authTokenStore.getToken(serverUrl),
() => this.authTokenStore.hasAnyValidToken()
);
}
private resolveDisplayName(user: Pick<User, 'displayName' | 'username'>): string {
const displayName = user.displayName?.trim();