mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: add /meta command for channel metadata retrieval
- Implemented the `/meta` command to fetch and display channel metadata including room ID, topic, message count, unique user count, estimated size, and protection level. - Added `ChannelMetaDto` to encapsulate channel metadata. - Updated `ChannelsController` to handle the new `/meta` endpoint. - Introduced `UploadLimits` configuration for admin-defined upload size limits for files, images, audio, and avatars. - Enhanced error handling and user feedback for metadata retrieval. - Updated documentation to reflect changes in encryption and room metadata. - Added tests for the new functionality and upload limits.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# Encrypted Rooms (Password-Protected Channels)
|
||||
|
||||
An **encrypted room** is a channel whose entire content — every message and every file — is
|
||||
end-to-end encrypted with a key derived from a shared passphrase. Only people who know the
|
||||
passphrase can read the room. **Not even the server owner can read the content**, yet the server
|
||||
can still gate who joins, and it can count and measure what's stored (message count, file sizes,
|
||||
timestamps) without ever seeing the plaintext.
|
||||
|
||||
This is a stronger guarantee than the [transport and at-rest encryption](encryption.md) described
|
||||
elsewhere, where the server decrypts every message to process it. Here the server is treated as
|
||||
*untrusted* for content: it holds only ciphertext and wrapped keys.
|
||||
|
||||
> **The passphrase is the only key.** There is no recovery. If everyone who knows a room's
|
||||
> passphrase forgets it, that room's history is permanently unreadable — by design.
|
||||
|
||||
## What the server can and cannot see
|
||||
|
||||
| The server **can** see | The server **cannot** see |
|
||||
| --- | --- |
|
||||
| That the channel is encrypted | Message text |
|
||||
| Message count and timestamps | File contents |
|
||||
| Who sent each message (sender identity) | Image previews (ASCII art) |
|
||||
| Each attachment's **file name** and byte size | The passphrase, the room key, or the key-encryption key |
|
||||
| The estimated total size (via `/meta`) | Anything that would let it decrypt the above |
|
||||
|
||||
File **names are stored in plaintext** so the file list stays usable — treat a file name itself as
|
||||
non-secret. Everything *inside* the file is encrypted.
|
||||
|
||||
## Key hierarchy
|
||||
|
||||
Three keys are derived from one passphrase. The passphrase, the key-encryption key, and the room
|
||||
content key **never leave the client**.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
P[Passphrase] -->|PBKDF2-SHA256, 210k iterations, per-room salt| OKM[64-byte output]
|
||||
OKM --> AK["Auth key (first 32 bytes)"]
|
||||
OKM --> KEK["Key-encryption key / KEK (last 32 bytes)"]
|
||||
AK -->|sent as lowercase hex| SRV1["Server: BCrypt-hash as the join gate"]
|
||||
RCK["Room Content Key (random 256-bit)"] -->|encrypts all content| CONTENT[Messages + files + previews]
|
||||
RCK -->|AES-256-GCM wrap under KEK| WRAP["Wrapped room key"]
|
||||
WRAP -->|stored| SRV2["Server: stores wrapped key + salt only"]
|
||||
```
|
||||
|
||||
- **Auth key** — the join credential. Derived from the passphrase, sent to the server as hex, and
|
||||
stored only as a **BCrypt hash**. Proving knowledge of it is what lets you join; it reveals
|
||||
nothing about the content key.
|
||||
- **Key-encryption key (KEK)** — never sent. Used locally to *wrap* (encrypt) and *unwrap* the room
|
||||
content key.
|
||||
- **Room Content Key (RCK)** — a random 256-bit key generated once, at room creation. It encrypts
|
||||
every message and file. The server stores it only in wrapped form, so it can hand the wrapped key
|
||||
to a joiner but can never unwrap it itself.
|
||||
|
||||
All content encryption is **AES-256-GCM** with a random 12-byte nonce and a 16-byte authentication
|
||||
tag per item, so identical inputs never produce identical ciphertext, and any tampering is detected.
|
||||
|
||||
Room-encrypted text carries a self-describing prefix so clients and the server can tell it apart
|
||||
from plaintext:
|
||||
|
||||
```text
|
||||
$RC1$base64(nonce || tag || ciphertext)
|
||||
```
|
||||
|
||||
## Creating a room
|
||||
|
||||
The client does all the cryptography locally, then hands the server only what it needs to gate joins
|
||||
and store (but not read) the content.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Server
|
||||
|
||||
Client->>Client: generate salt + random Room Content Key (RCK)
|
||||
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
|
||||
Client->>Client: wrap RCK under KEK
|
||||
Client->>Server: create channel { authKey(hex), salt, wrappedRoomKey }
|
||||
Server->>Server: BCrypt-hash auth key, store salt + wrapped key
|
||||
Note over Server: Server never receives passphrase, KEK, or RCK
|
||||
```
|
||||
|
||||
## Joining a room
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Server
|
||||
|
||||
Client->>Server: GET /crypto → { isEncrypted, salt }
|
||||
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
|
||||
Client->>Server: join { authKey(hex) }
|
||||
Server->>Server: BCrypt-verify against stored hash
|
||||
alt correct passphrase
|
||||
Server->>Client: history (ciphertext) + wrapped room key
|
||||
Client->>Client: unwrap RCK with KEK, then decrypt everything locally
|
||||
else wrong passphrase
|
||||
Server->>Client: rejected (join gate fails)
|
||||
end
|
||||
```
|
||||
|
||||
A wrong passphrase fails the BCrypt gate, so the server never even hands out the wrapped key. Even if
|
||||
it did, an attacker without the KEK cannot unwrap it.
|
||||
|
||||
## What gets encrypted
|
||||
|
||||
When you send a message or attach files to an encrypted room, the client encrypts each part with the
|
||||
room content key **before** uploading:
|
||||
|
||||
- **Message text** → `$RC1$…` ciphertext.
|
||||
- **Files** (any kind) → the whole blob is AES-256-GCM encrypted client-side; the server stores an
|
||||
opaque ciphertext blob.
|
||||
- **Image ASCII previews** → rendered on the client, then room-encrypted. The server never sees the
|
||||
rendered art.
|
||||
|
||||
The server records each attachment's **kind**, **file name**, and **byte size** (of the ciphertext
|
||||
blob) as metadata, and broadcasts the ciphertext to other members, who decrypt locally.
|
||||
|
||||
## Changing the passphrase
|
||||
|
||||
`/passwd <old> <new>` rotates the passphrase. Because only the *wrapping* of the room content key
|
||||
changes — not the RCK itself — **all existing history stays readable**:
|
||||
|
||||
1. The client proves knowledge of the old passphrase (old auth key).
|
||||
2. It unwraps the RCK with the old KEK, then re-wraps it under the new KEK (new salt).
|
||||
3. It uploads the new auth key + salt + re-wrapped key. The content is never re-encrypted.
|
||||
|
||||
## Inspecting a room
|
||||
|
||||
Use `/meta` in any channel to see what the server knows about it, including encrypted rooms:
|
||||
|
||||
```text
|
||||
Room info for #private-room:
|
||||
Room ID 3f2a…-…-…
|
||||
Created 7/16/2026 2:31 PM
|
||||
Messages 128
|
||||
Unique users 4
|
||||
Est. size 42.5 MB
|
||||
Protection end-to-end encrypted
|
||||
```
|
||||
|
||||
`Est. size` is the sum of stored attachment blob sizes plus message text length — an estimate of the
|
||||
room's footprint, computed entirely from metadata the server holds without reading any content.
|
||||
|
||||
## Limitations & security notes
|
||||
|
||||
- **No recovery.** A lost passphrase means unrecoverable history. Keep it safe; there is no reset.
|
||||
- **File names are plaintext.** They stay readable so the file list works — don't put secrets in a
|
||||
file name.
|
||||
- **IRC is disabled for encrypted rooms.** The IRC gateway forwards plaintext and cannot participate
|
||||
in the room's key scheme, so encrypted channels are not bridged to IRC.
|
||||
- **Metadata is visible.** Message counts, timestamps, sender identities, file names, and sizes are
|
||||
intentionally readable so the server can moderate at the metadata level and report `/meta`.
|
||||
- **Endpoint trust.** End-to-end encryption protects content from the server and the network, not
|
||||
from a compromised client device that already holds the passphrase.
|
||||
|
||||
## Related
|
||||
|
||||
- [Message Encryption](encryption.md) — transport (`$ENC$v1$`) and optional at-rest database
|
||||
encryption, where the server *does* decrypt content for processing. Encrypted rooms are a separate,
|
||||
stronger layer that sits on top.
|
||||
@@ -118,7 +118,7 @@ If you need to recover old encrypted messages, restore the original key from a b
|
||||
### Limitations
|
||||
|
||||
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
|
||||
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server.
|
||||
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server. For true end-to-end encryption where the server cannot read content, use [encrypted rooms](encrypted-rooms.md).
|
||||
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
href: configuration.md
|
||||
- name: Encryption
|
||||
href: encryption.md
|
||||
- name: Encrypted Rooms
|
||||
href: encrypted-rooms.md
|
||||
- name: Notification Sounds
|
||||
href: notification-sounds.md
|
||||
- name: Flows
|
||||
|
||||
@@ -28,7 +28,10 @@ Private channels are now genuinely private: password-protected channels are end-
|
||||
- Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux
|
||||
- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size <s|m|l>` sets it directly, and `/send <file> -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray
|
||||
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
|
||||
- Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages.
|
||||
- **`/meta` command** — shows a summary of the current room: room id, created date, message count, unique participant count, estimated storage size, and protection level (open / password-protected / end-to-end encrypted). Works for encrypted rooms too, since these are all metadata the server tracks without reading content. (`/info` is an alias.)
|
||||
- **Configurable upload limits** — a new `Uploads` section in the server settings sets the maximum size per file, image, audio clip, and avatar, plus the maximum attachments per message. Absent values fall back to the previous built-in limits, so existing servers are unaffected until they opt in.
|
||||
- **Updater progress bar** — the self-updater now draws a real progress bar on the console (`Downloading [████████░░] 62%`) for each step, replacing the plain status lines, so a large download shows visible progress.
|
||||
- Timestamps in messages are culture-aware and now **always include the date**: today's messages show a compact date + short time, and older messages show the general short date/time.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
@@ -36,12 +39,16 @@ Private channels are now genuinely private: password-protected channels are end-
|
||||
- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database.
|
||||
- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing
|
||||
- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement
|
||||
- Fixed new messages showing no date — today's messages rendered time-only, and the "is it today?" check compared the server's UTC date against the local date, so the classification could also be wrong near midnight. Timestamps are now converted to local time first and always include the date.
|
||||
- Fixed the self-updater hanging at "extracting" — the update ran while the Terminal.Gui main loop still owned the console, so the old and new processes deadlocked over it. The update now runs after the TUI shuts down, on a clean console
|
||||
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
|
||||
|
||||
## API Changes
|
||||
|
||||
- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR)
|
||||
- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change)
|
||||
- New endpoint `GET /api/channels/{channel}/meta` returns a `ChannelMetaDto` (room id, name, topic, encrypted/protected flags, message count, unique user count, estimated size, created date) backing the `/meta` command
|
||||
- New server `Uploads` configuration section (`MaxFileSizeMB`, `MaxImageSizeMB`, `MaxAudioSizeMB`, `MaxAvatarSizeMB`, `MaxAttachmentsPerMessage`); the message-upload endpoint's request-body ceiling is now derived from these values at runtime rather than from compile-time constants
|
||||
- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content
|
||||
- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads
|
||||
- **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message`
|
||||
|
||||
Reference in New Issue
Block a user