feat: Security
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user