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

@@ -23,6 +23,8 @@ export interface MessageRow {
linkMetadata?: string | null;
kind?: string | null;
systemEvent?: string | null;
revision?: number | null;
headHash?: string | null;
}
export interface UserRow {
@@ -102,7 +104,9 @@ export function messageToRow(message: Message): MessageRow {
replyToId: message.replyToId ?? null,
linkMetadata: encodeJson(message.linkMetadata),
kind: message.kind ?? null,
systemEvent: message.systemEvent ?? null
systemEvent: message.systemEvent ?? null,
revision: message.revision ?? 0,
headHash: message.headHash ?? null
};
}
@@ -116,6 +120,8 @@ export function rowToMessage(row: MessageRow, reactions: Reaction[] = []): Messa
content: row.content,
timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined,
revision: row.revision ?? 0,
headHash: row.headHash ?? undefined,
isDeleted: row.isDeleted === 1,
replyToId: row.replyToId ?? undefined,
linkMetadata: decodeJson(row.linkMetadata),

View File

@@ -2,7 +2,7 @@
export const MOBILE_SQLITE_DATABASE_NAME = 'metoyou';
/** Bump when adding DDL statements; stored in meta table. */
export const MOBILE_SQLITE_SCHEMA_VERSION = 2;
export const MOBILE_SQLITE_SCHEMA_VERSION = 3;
const META_SCHEMA_VERSION_KEY = 'mobile_sqlite_schema_version';
@@ -23,7 +23,9 @@ export function buildMobileSqliteSchemaStatements(): string[] {
replyToId TEXT,
linkMetadata TEXT,
kind TEXT,
systemEvent TEXT
systemEvent TEXT,
revision INTEGER NOT NULL DEFAULT 0,
headHash TEXT
)`,
'CREATE INDEX IF NOT EXISTS idx_messages_room_id ON messages(roomId)',
'CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)',
@@ -136,6 +138,11 @@ const SCHEMA_V2_MESSAGE_COLUMNS = [
'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
];
const SCHEMA_V3_MESSAGE_COLUMNS = [
'ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE messages ADD COLUMN headHash TEXT'
];
/** Returns DDL statements that still need to run for the stored schema version. */
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {
if (storedVersion >= MOBILE_SQLITE_SCHEMA_VERSION) {
@@ -153,5 +160,10 @@ export function resolveMobileSqliteMigrationStatements(storedVersion: number): s
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '2')`);
}
if (storedVersion < 3) {
statements.push(...SCHEMA_V3_MESSAGE_COLUMNS);
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '3')`);
}
return statements;
}

View File

@@ -1,5 +1,5 @@
/** IndexedDB schema version - bump when adding/changing object stores. */
export const BROWSER_DATABASE_VERSION = 3;
export const BROWSER_DATABASE_VERSION = 4;
const STORE_MESSAGES = 'messages';
const STORE_USERS = 'users';
@@ -9,6 +9,7 @@ const STORE_BANS = 'bans';
const STORE_META = 'meta';
const STORE_ATTACHMENTS = 'attachments';
const STORE_CUSTOM_EMOJIS = 'customEmojis';
const STORE_MESSAGE_REVISIONS = 'messageRevisions';
export function ensureObjectStoreDuringUpgrade(
database: IDBDatabase,
@@ -64,4 +65,8 @@ export function applyBrowserDatabaseSchema(
ensureStoreIndex(customEmojisStore, 'updatedAt', 'updatedAt');
ensureStoreIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
const revisionsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_MESSAGE_REVISIONS, { keyPath: 'id' });
ensureStoreIndex(revisionsStore, 'messageId', 'messageId');
}

View File

@@ -6,7 +6,8 @@ import {
User,
Room,
Reaction,
BanEntry
BanEntry,
type MessageRevision
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
@@ -25,6 +26,7 @@ const STORE_BANS = 'bans';
const STORE_META = 'meta';
const STORE_ATTACHMENTS = 'attachments';
const STORE_CUSTOM_EMOJIS = 'customEmojis';
const STORE_MESSAGE_REVISIONS = 'messageRevisions';
/** All object store names, used when clearing the entire database. */
const ALL_STORE_NAMES: string[] = [
STORE_MESSAGES,
@@ -34,6 +36,7 @@ const ALL_STORE_NAMES: string[] = [
STORE_BANS,
STORE_ATTACHMENTS,
STORE_CUSTOM_EMOJIS,
STORE_MESSAGE_REVISIONS,
STORE_META
];
@@ -67,6 +70,13 @@ export class BrowserDatabaseService {
await this.put(STORE_MESSAGES, message);
}
async saveMessageRevision(revision: MessageRevision): Promise<void> {
await this.put(STORE_MESSAGE_REVISIONS, {
...revision,
id: `${revision.messageId}:${revision.revision}`
});
}
/**
* Retrieve the latest messages for a room, sorted oldest-first for display.
* @param roomId - Target room.

View File

@@ -7,7 +7,7 @@ import {
type Room,
type User
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji, MessageRevision } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import {
attachmentToValues,
@@ -42,6 +42,15 @@ export class CapacitorDatabaseService {
await this.connection.initialize();
}
async saveMessageRevision(revision: MessageRevision): Promise<void> {
const store = await this.connection.getStore();
await store.run(
'INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)',
[`message-revision:${revision.messageId}:${revision.revision}`, JSON.stringify(revision)]
);
}
async saveMessage(message: Message): Promise<void> {
const store = await this.connection.getStore();
const row = messageToRow(message);
@@ -49,8 +58,9 @@ export class CapacitorDatabaseService {
await store.run(
`INSERT OR REPLACE INTO messages (
id, roomId, ownerUserId, channelId, senderId, senderName, content,
timestamp, editedAt, isDeleted, replyToId, linkMetadata, kind, systemEvent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
timestamp, editedAt, isDeleted, replyToId, linkMetadata, kind, systemEvent,
revision, headHash
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.roomId,
@@ -65,7 +75,9 @@ export class CapacitorDatabaseService {
row.replyToId ?? null,
row.linkMetadata ?? null,
row.kind ?? null,
row.systemEvent ?? null
row.systemEvent ?? null,
row.revision ?? 0,
row.headHash ?? null
]
);
}

View File

@@ -9,7 +9,8 @@ import {
User,
Room,
Reaction,
BanEntry
BanEntry,
type MessageRevision
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { PlatformService } from '../../core/platform';
@@ -99,6 +100,11 @@ export class DatabaseService {
/** Persist a single chat message. */
saveMessage(message: Message) { return this.withReady(() => this.backend.saveMessage(message)); }
/** Persist an append-only message revision audit entry. */
saveMessageRevision(revision: MessageRevision) {
return this.withReady(() => this.backend.saveMessageRevision(revision));
}
/** Retrieve the latest messages for a room or channel with optional pagination.
*
* When `beforeTimestamp` is provided, only messages strictly older than that

View File

@@ -4,7 +4,8 @@ import {
User,
Room,
Reaction,
BanEntry
BanEntry,
type MessageRevision
} from '../../shared-kernel';
import type { CustomEmoji } from '../../shared-kernel';
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
@@ -38,6 +39,16 @@ export class ElectronDatabaseService {
return this.api.command({ type: 'save-message', payload: { message } });
}
saveMessageRevision(revision: MessageRevision): Promise<void> {
return this.api.command({
type: 'save-meta',
payload: {
key: `message-revision:${revision.messageId}:${revision.revision}`,
value: JSON.stringify(revision)
}
});
}
/**
* Retrieve the latest messages for a room, sorted oldest-first for display.
*

View File

@@ -73,7 +73,7 @@ function createContext(): PeerConnectionManagerContext {
} as unknown as PeerConnectionManagerContext['logger'],
callbacks: {
getIceServers: vi.fn(() => []),
getIdentifyCredentials: vi.fn(() => ({ oderId: 'local-user', displayName: 'Local User' })),
getIdentifyCredentials: vi.fn(() => ({ oderId: 'local-user', token: 'session-token', displayName: 'Local User' })),
getLocalMediaStream: vi.fn(() => null),
getLocalPeerId: vi.fn(() => 'local-peer'),
getVoiceStateSnapshot: vi.fn(() => ({

View File

@@ -133,7 +133,7 @@ function createContext(localOderId: string): PeerConnectionManagerContext {
} as unknown as PeerConnectionManagerContext['logger'],
callbacks: {
getIceServers: vi.fn(() => []),
getIdentifyCredentials: vi.fn(() => ({ oderId: localOderId, displayName: localOderId })),
getIdentifyCredentials: vi.fn(() => ({ oderId: localOderId, token: 'session-token', displayName: localOderId })),
getLocalMediaStream: vi.fn(() => null),
getLocalPeerId: vi.fn(() => localOderId),
getVoiceStateSnapshot: vi.fn(() => ({

View File

@@ -40,6 +40,7 @@ import { ServerSignalingCoordinator } from './signaling/server-signaling-coordin
import { SignalingManager } from './signaling/signaling.manager';
import { SignalingTransportHandler } from './signaling/signaling-transport-handler';
import { WebRtcStateController } from './state/webrtc-state-controller';
import { AuthTokenStoreService } from '../../domains/authentication';
@Injectable({
providedIn: 'root'
@@ -49,6 +50,7 @@ export class WebRTCService implements OnDestroy {
private readonly debugging = inject(DebuggingService);
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly iceServerSettings = inject(IceServerSettingsService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private readonly state = new WebRtcStateController();
@@ -144,7 +146,22 @@ export class WebRTCService implements OnDestroy {
this.signalingTransportHandler = new SignalingTransportHandler({
signalingCoordinator: this.signalingCoordinator,
logger: this.logger,
getLocalPeerId: () => this.state.getLocalPeerId()
getLocalPeerId: () => this.state.getLocalPeerId(),
resolveSessionToken: (signalUrl) => {
if (signalUrl) {
return this.authTokenStore.getToken(signalUrl.replace(/^ws/, 'http'));
}
for (const { signalUrl: connectedUrl } of this.signalingCoordinator.getConnectedSignalingManagers()) {
const token = this.authTokenStore.getToken(connectedUrl.replace(/^ws/, 'http'));
if (token) {
return token;
}
}
return null;
}
});
// Now wire up cross-references (all managers are instantiated)

View File

@@ -85,6 +85,8 @@ export const SIGNALING_TYPE_SERVER_USERS = 'server_users';
export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
export const SIGNALING_TYPE_ACCESS_DENIED = 'access_denied';
export const SIGNALING_TYPE_AUTH_REQUIRED = 'auth_required';
export const SIGNALING_TYPE_AUTH_ERROR = 'auth_error';
export const SIGNALING_TYPE_KEEPALIVE = 'keepalive';
export const SIGNALING_TYPE_KEEPALIVE_ACK = 'keepalive_ack';

View File

@@ -34,6 +34,8 @@ export interface PeerData {
export interface IdentifyCredentials {
/** The user's unique order / peer identifier. */
oderId: string;
/** Session token proving identity to the signaling server. */
token: string;
/** The user's display name shown to other peers. */
displayName: string;
/** Optional profile description advertised via signaling identity. */

View File

@@ -13,6 +13,7 @@ interface SignalingTransportHandlerDependencies<TMessage> {
signalingCoordinator: ServerSignalingCoordinator<TMessage>;
logger: WebRTCLogger;
getLocalPeerId(): string;
resolveSessionToken(signalUrl?: string): string | null;
}
export class SignalingTransportHandler<TMessage> {
@@ -193,9 +194,16 @@ export class SignalingTransportHandler<TMessage> {
const normalizedHomeSignalServerUrl = typeof profile?.homeSignalServerUrl === 'string'
? (profile.homeSignalServerUrl.trim().replace(/\/+$/, '') || undefined)
: undefined;
const token = this.dependencies.resolveSessionToken(signalUrl);
if (!token) {
this.dependencies.logger.warn('Skipping identify because no session token is available', { signalUrl, oderId });
return;
}
this.lastIdentifyCredentials = {
oderId,
token,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
@@ -205,6 +213,7 @@ export class SignalingTransportHandler<TMessage> {
if (signalUrl) {
this.sendRawMessageToSignalUrl(signalUrl, {
type: SIGNALING_TYPE_IDENTIFY,
token,
oderId,
displayName: normalizedDisplayName,
description: normalizedDescription,
@@ -225,6 +234,7 @@ export class SignalingTransportHandler<TMessage> {
for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) {
manager.sendRawMessage({
type: SIGNALING_TYPE_IDENTIFY,
token,
oderId,
displayName: normalizedDisplayName,
description: normalizedDescription,

View File

@@ -110,6 +110,7 @@ describe('SignalingManager reconnection', () => {
homeSignalServerUrl?: string;
} = {
oderId: 'peer-a',
token: 'session-token',
displayName: 'Peer A',
description: 'hello',
profileUpdatedAt: 42,
@@ -221,6 +222,7 @@ describe('SignalingManager reconnection', () => {
expect(identifyMessage).toMatchObject({
type: 'identify',
token: 'session-token',
oderId: 'peer-a',
displayName: 'Peer A',
description: 'hello',
@@ -274,7 +276,7 @@ describe('SignalingManager reconnection', () => {
const errorSpy = vi.spyOn(logger, 'error');
const manager = new SignalingManager(
logger,
() => ({ oderId: 'peer-a', displayName: 'Peer A' }),
() => ({ oderId: 'peer-a', token: 'session-token', displayName: 'Peer A' }),
() => ({ serverId: 'server-1', userId: 'peer-a' }),
() => new Set(['server-1'])
);

View File

@@ -373,6 +373,7 @@ export class SignalingManager {
if (credentials) {
this.sendRawMessage({
type: SIGNALING_TYPE_IDENTIFY,
token: credentials.token,
oderId: credentials.oderId,
displayName: credentials.displayName,
description: credentials.description,