PrivMsg Technical Transparency Document

Last updated: April 13, 2025

Purpose:

This document provides an in-depth technical explanation of the PrivMsg application's architecture, security model, data handling practices, and core cryptographic operations. It aims to leave no ambiguity regarding the system's inner workings, targeting security professionals, developers, and users requiring deep technical insight.

1. System Architecture

1.1 Core Components & Technology Stack

  • Backend Framework: Laravel 12 (PHP 8.4)
  • Database: MySQL
  • Web Server: Nginx
  • Frontend Framework: Vue.js 3 (Composition API)
  • UI Styling: Tailwind CSS v4
  • Templating: Laravel Blade (primarily for layout scaffolding)
  • JavaScript Runtime: Browser (utilizing modern Web APIs)
  • Client-Side Crypto:
    • Web Crypto API (`window.crypto.subtle`) for core cryptographic primitives (RSA-OAEP, AES-GCM, SHA-256, RSASSA-PKCS1-v1_5).
    • `js-crypto-rsa` and `js-crypto-utils` (potentially for helper functions or broader compatibility, check usage in `CryptoService.js`).

1.2 Database Schema (MySQL)

The following tables constitute the core data model. Column types reflect MySQL interpretations unless specified otherwise.

`users` Table

Stores user account and authentication information.

  • `id` (BIGINT UNSIGNED, PK, AI)
  • `username` (VARCHAR(255), Unique)
  • `password` (VARCHAR(255)) - Stores Bcrypt hash (see Authentication section).
  • `is_hidden` (BOOLEAN, Default: 0) - Flag for user search visibility. Added in migration `2025_03_31_230333`.
  • `remember_token` (VARCHAR(100), Nullable) - Used for "Remember Me" functionality.
  • `created_at` (TIMESTAMP, Nullable)
  • `updated_at` (TIMESTAMP, Nullable)

`key_pairs` Table

Stores user public keys for enabling message encryption.

  • `id` (BIGINT UNSIGNED, PK, AI)
  • `user_id` (BIGINT UNSIGNED, FK -> users.id, On Delete Cascade, Indexed)
  • `public_key` (TEXT) - Stores the user's RSA public key in PEM format (SPKI).
  • `created_at` (TIMESTAMP, Nullable)
  • `updated_at` (TIMESTAMP, Nullable)

`messages` Table

Stores end-to-end encrypted message content and metadata.

  • `id` (BIGINT UNSIGNED, PK, AI)
  • `sender_id` (BIGINT UNSIGNED, FK -> users.id, On Delete Cascade, Indexed)
  • `recipient_id` (BIGINT UNSIGNED, FK -> users.id, On Delete Cascade, Indexed)
  • `encrypted_content` (TEXT) - Stores Base64 encoded JSON containing encrypted message, encrypted AES key, and IV.
  • `signature` (TEXT, Nullable) - Stores Base64 encoded RSASSA-PKCS1-v1_5 signature of the encrypted message content.
  • `is_read` (BOOLEAN, Default: 0, Indexed) - Read status flag.
  • `is_ephemeral` (BOOLEAN, Default: 0) - Auto-delete flag. Added in migration `2025_04_06_004504`.
  • `created_at` (TIMESTAMP, Nullable)
  • `updated_at` (TIMESTAMP, Nullable)

`sessions` Table

Managed by Laravel's file session driver (`file`), but schema exists for database driver compatibility.

  • `id` (VARCHAR(255), PK) - Session ID.
  • `user_id` (BIGINT UNSIGNED, Nullable, Indexed) - Associated authenticated user.
  • `ip_address` (VARCHAR(45), Nullable)
  • `user_agent` (TEXT, Nullable)
  • `payload` (LONGTEXT) - Serialized session data (Base64 encoded).
  • `last_activity` (INT, Indexed) - Timestamp of last activity.

`typing_statuses` Table

Tracks real-time typing indicators between users.

  • `id` (BIGINT UNSIGNED, PK, AI)
  • `sender_id` (BIGINT UNSIGNED, FK -> users.id, On Delete Cascade, Indexed)
  • `recipient_id` (BIGINT UNSIGNED, FK -> users.id, On Delete Cascade, Indexed)
  • `is_typing` (BOOLEAN, Default: 0)
  • `updated_at` (TIMESTAMP, Nullable)

1.3 Model Relationships (Eloquent ORM)

Defined in `app/Models/*.php`:

  • `App\Models\User`:
    • `keyPair(): HasOne` -> `App\Models\KeyPair` (via `user_id`)
    • `sentMessages(): HasMany` -> `App\Models\Message` (via `sender_id`)
    • `receivedMessages(): HasMany` -> `App\Models\Message` (via `recipient_id`)
    • `typingStatuses(): HasMany` -> `App\Models\TypingStatus` (via `sender_id`)
  • `App\Models\KeyPair`:
    • `user(): BelongsTo` -> `App\Models\User` (via `user_id`)
  • `App\Models\Message`:
    • `sender(): BelongsTo` -> `App\Models\User` (via `sender_id`)
    • `recipient(): BelongsTo` -> `App\Models\User` (via `recipient_id`)
  • `App\Models\TypingStatus`:
    • `sender(): BelongsTo` -> `App\Models\User` (via `sender_id`)
    • `recipient(): BelongsTo` -> `App\Models\User` (via `recipient_id`)

Note: Model logic also includes cascading deletes defined in `User::booted()` method.

2. Security Architecture & Cryptography

2.1 End-to-End Encryption (E2EE) Implementation

E2EE is handled entirely client-side via JavaScript (`resources/js/services/CryptoService.js`) utilizing the Web Crypto API. The server only stores opaque encrypted blobs and public keys.

2.1.1 Key Generation (`CryptoService.generateKeyPair`)

  • Uses `window.crypto.subtle.generateKey`.
  • Algorithm: `RSA-OAEP` (RSAES-OAEP).
  • Modulus Length: 2048 bits.
  • Public Exponent: 65537 (`0x010001`).
  • Hash Function for OAEP: `SHA-256`.
  • Key Usages: `['encrypt', 'decrypt']`.
  • Keys generated as `CryptoKey` objects (extractable).
  • Public key exported to PEM (SPKI format, Base64 encoded) for server storage (`/api/keypair` POST).
  • Private key exported to PEM (PKCS#8 format, Base64 encoded) for storage.

2.1.2 Key Storage (Client-Side)

  • Utilizes IndexedDB (`PrivMsgEncryptionKeys` database, `keys` object store).
  • `CryptoService.storeKeyPair` stores PEM-encoded private and public keys, indexed by `username`.
  • `CryptoService.retrieveKeyPair` fetches PEM keys and imports them back into non-extractable `CryptoKey` objects using `importPrivateKey` and `importPublicKey`.
  • Security Note: IndexedDB storage is local to the browser profile and subject to its security constraints (e.g., clearing site data removes keys).

2.1.3 Message Encryption (`CryptoService.encryptMessage`) - Hybrid Encryption

Combines asymmetric (RSA) and symmetric (AES) encryption for efficiency and security.

  1. Recipient's public key (PEM) is fetched from the server (`/api/keypair/{userId}`).
  2. Recipient's public key is imported into a `CryptoKey` object (`RSA-OAEP`, usage: `['encrypt']`).
  3. A unique symmetric key is generated for *this specific message*:
    • Uses `window.crypto.subtle.generateKey`.
    • Algorithm: `AES-GCM` (AES in Galois/Counter Mode).
    • Key Length: 256 bits.
    • Key Usages: `['encrypt', 'decrypt']`.
  4. A random 12-byte (96-bit) Initialization Vector (IV) is generated using `window.crypto.getRandomValues`.
  5. The plaintext message (UTF-8 string) is encoded to `ArrayBuffer`.
  6. Message `ArrayBuffer` is encrypted using the generated AES-GCM key and IV:
    • Uses `window.crypto.subtle.encrypt`.
    • Algorithm: `{ name: 'AES-GCM', iv: iv }`.
  7. The generated AES key (`CryptoKey`) is exported to its raw `ArrayBuffer` format (`exportKey('raw', ...)`).
  8. The raw AES key `ArrayBuffer` is encrypted using the recipient's RSA public key:
    • Uses `window.crypto.subtle.encrypt`.
    • Algorithm: `{ name: 'RSA-OAEP' }`.
  9. The *encrypted message* `ArrayBuffer` (output of step 6) is signed using the *sender's* private key:
    • Sender's private key PEM is re-imported specifically for signing (`importPrivateKeyForSigning`) using algorithm `RSASSA-PKCS1-v1_5`, hash `SHA-256`, usage `['sign']`.
    • Uses `window.crypto.subtle.sign`.
    • Algorithm: `{ name: 'RSASSA-PKCS1-v1_5' }`.
  10. The encrypted message, encrypted AES key, IV, and signature are Base64 encoded.
  11. A JSON object `{ message, key, iv }` is created with the Base64 strings.
  12. This JSON string is stored in the `encrypted_content` column of the `messages` table.
  13. The Base64 signature is stored in the `signature` column.

Data sent to `/api/messages` (POST): `{ recipient_id, encrypted_content (JSON string), signature (Base64 string), is_ephemeral }`.

2.1.4 Message Decryption (`CryptoService.decryptMessage`)

  1. Encrypted data package (JSON string) and signature (Base64) are retrieved from the `messages` table.
  2. JSON package is parsed. Base64 components (`message`, `key`, `iv`, `signature`) are decoded into `ArrayBuffer`s.
  3. Sender's public key (PEM) is fetched from the server (`/api/keypair/{senderId}`).
  4. Sender's public key is imported for verification (`importPublicKeyForVerification`, algorithm `RSASSA-PKCS1-v1_5`, hash `SHA-256`, usage `['verify']`).
  5. Signature Verification:
    • Uses `window.crypto.subtle.verify`.
    • Verifies the signature (`signature` ArrayBuffer) against the *encrypted message* `ArrayBuffer` using the sender's verification key.
    • If verification fails, decryption stops.
  6. The encrypted AES key `ArrayBuffer` is decrypted using the *recipient's* private key (`CryptoKey` retrieved from IndexedDB):
    • Uses `window.crypto.subtle.decrypt`.
    • Algorithm: `{ name: 'RSA-OAEP' }`.
  7. The decrypted raw AES key `ArrayBuffer` is imported into a `CryptoKey` object:
    • Uses `window.crypto.subtle.importKey`.
    • Format: `'raw'`.
    • Algorithm: `{ name: 'AES-GCM', length: 256 }`.
    • Key Usages: `['decrypt']`.
  8. The encrypted message `ArrayBuffer` is decrypted using the imported AES key and the received IV `ArrayBuffer`:
    • Uses `window.crypto.subtle.decrypt`.
    • Algorithm: `{ name: 'AES-GCM', iv: iv }`.
  9. The resulting plaintext `ArrayBuffer` is decoded into a UTF-8 string using `TextDecoder`.
  10. The plaintext message is displayed in the UI.

2.1.5 Cryptographic Primitives Summary

  • Asymmetric Encryption: RSA-OAEP with SHA-256.
  • Symmetric Encryption: AES-GCM with 256-bit keys and 96-bit IVs (Provides Authenticated Encryption - AEAD).
  • Digital Signatures: RSASSA-PKCS1-v1_5 with SHA-256.
  • Key Derivation: Not used directly; AES keys generated randomly per message.
  • Hashing (for crypto): SHA-256 (used within RSA-OAEP and RSASSA-PKCS1-v1_5).

Security Implication: Lack of Forward Secrecy

As the recipient's long-term RSA private key is used to decrypt the per-message AES key, compromise of this single RSA key allows decryption of *all past messages* encrypted to the corresponding public key. Implementing a key exchange protocol like Signal Protocol (using Diffie-Hellman) would be required to achieve Forward Secrecy.

2.2 Authentication and Authorization

  • Password Hashing: Laravel's default Bcrypt implementation (`Hash::make`, `Hash::check`). Default cost factor is typically 10 or 12 (verify in `config/hashing.php` if non-default). Stored in `users.password`.
  • Session Management: Laravel's standard session handling (`file` driver). Session ID stored in encrypted cookie (`laravel_session`). Session data stored server-side in `storage/framework/sessions/`.
  • CSRF Protection: Laravel's built-in CSRF middleware is active (verify in `app/Http/Kernel.php`). `XSRF-TOKEN` cookie and `_token` form field/header required for non-GET requests.
  • Authentication Guard: Standard `web` guard using the `session` driver and `eloquent` user provider (`config/auth.php`).
  • Authorization: Primarily handled via Route Model Binding and explicit checks within controllers (e.g., checking `Auth::id()` against message sender/recipient). No dedicated Policy or Gate classes seem to be in use based on current structure.
  • Rate Limiting: Default Laravel rate limiters may apply (check `app/Providers/RouteServiceProvider.php`). Specific rate limiting for login (`LoginRateLimiter`) likely exists in `AuthController`.

2.3 Ephemeral Messages

  • Frontend sets `is_ephemeral` flag during message send API call.
  • Server stores this flag in `messages.is_ephemeral`.
  • Deletion Logic: Requires examining `MessageController` or a scheduled job/command. Deletion likely occurs either after the `/api/messages/{id}/read` endpoint is hit for the *first time* by the recipient, or potentially via a background cleanup task comparing `updated_at` timestamps for read, ephemeral messages. The exact trigger mechanism needs verification in the codebase.

3. API Endpoint Details

API routes are defined in `routes/web.php` under the `/api` prefix and require authentication (`auth` middleware). Controllers are located in `app/Http/Controllers`.

`GET /api/messages` (MessageController@index)

Fetches messages where the authenticated user is the recipient. Likely paginated. Returns message objects including `id`, `sender_id`, `encrypted_content`, `signature`, `is_read`, `created_at`.

`POST /api/messages` (MessageController@send)

Accepts: `{ recipient_id, encrypted_content, signature, is_ephemeral }`. Validates input, creates a new `Message` record. Returns the created message object.

`GET /api/messages/poll` (MessageController@poll)

Checks for new messages since the last poll (likely based on `lastMessageId` query parameter). Returns new messages. Uses long polling or simple polling (verify implementation).

`PATCH /api/messages/{messageId}/read` (MessageController@markAsRead)

Updates the `is_read` flag for the specified message ID. Requires the authenticated user to be the recipient.

`DELETE /api/messages/{messageId}` (MessageController@deleteMessage)

Deletes a specific message. Requires the authenticated user to be either the sender or recipient (verify logic).

`GET /api/conversations` (MessageController@getConversations)

Returns a list of users the authenticated user has conversations with, likely including the latest message preview (encrypted) and timestamp.

`DELETE /api/conversations/{userId}` (MessageController@deleteConversation)

Deletes all messages between the authenticated user and the specified `userId` (both sent and received).

`POST /api/typing-status` (MessageController@updateTypingStatus)

Accepts `{ recipient_id, is_typing (boolean) }`. Creates or updates a record in `typing_statuses`.

`GET /api/typing-status` (MessageController@getTypingStatus)

Returns a list of user IDs currently typing to the authenticated user (based on recent `updated_at` in `typing_statuses`).

`GET /api/keypair` (KeyPairController@showOwn)

Returns the authenticated user's public key (PEM) from the `key_pairs` table.

`POST /api/keypair` (KeyPairController@store)

Accepts `{ public_key (PEM) }`. Creates or updates the `KeyPair` record for the authenticated user.

`GET /api/keypair/{userId}` (KeyPairController@show)

Returns the public key (PEM) for the specified `userId`.

`GET /api/users/search` (UserController@search)

Accepts `query` parameter. Searches for users by username, excluding users where `is_hidden` is true.

`GET /api/users/{userId}` (UserController@show)

Returns public profile information for the specified `userId` (e.g., `id`, `username`).

4. Frontend Implementation Details (Vue.js)

4.1 State Management

Likely uses Vue's built-in reactivity system (ref, reactive) or potentially Vuex (check `package.json` dependencies and `resources/js/store`). Global state might include authenticated user info, current conversation, messages, crypto keys.

4.2 Core Components

Key components in `resources/js/components/` likely include:

  • `Messaging.vue`: Main chat interface, handles message display, input, sending, polling.
  • `ConversationList.vue`: Displays list of conversations.
  • `KeyStatus.vue` / `Register.vue`: Handle key generation, storage, and backup prompts.
  • `Settings.vue`: User settings interface.

4.3 Client-Side Services

  • `CryptoService.js`: Encapsulates all cryptographic operations and IndexedDB key storage (detailed in section 2.1).
  • `ApiService.js` (or similar): Wraps `axios` or `fetch` calls to interact with the backend API endpoints defined in section 3. Handles request/response formatting and error handling.
  • `NotificationService.js` / `ToastService.js`: UI feedback mechanisms.

4.4 Data Flow Example (Sending Message)

  1. User types message in `Messaging.vue` input.
  2. On submit, `sendMessage` method is called.
  3. `ApiService` fetches recipient's public key PEM.
  4. `CryptoService.importPublicKey` converts PEM to `CryptoKey`.
  5. `CryptoService.encryptMessage` performs hybrid encryption and signing using recipient's public key and sender's private key (from `window.cryptoContext` or retrieved via `CryptoService.retrieveKeyPair`).
  6. `ApiService.sendMessage` POSTs the encrypted JSON package and signature to `/api/messages`.
  7. Response (new message object) is received from server.
  8. Frontend state (messages array) is updated, UI re-renders.
  9. Original plaintext message stored locally (e.g., in `decryptedMessages` object) for immediate display to sender.

4.5 Data Flow Example (Receiving Message)

  1. Background polling via `ApiService.pollMessages` (`/api/messages/poll`).
  2. Server returns new encrypted message objects if any.
  3. For each message:
    • `ApiService` fetches sender's public key PEM.
    • `CryptoService.importPublicKeyForVerification` converts PEM.
    • `CryptoService.verifySignature` checks signature against encrypted content using sender's key.
    • If signature valid: `CryptoService.decryptMessage` performs hybrid decryption using recipient's private key (from `window.cryptoContext` or IndexedDB).
    • Decrypted plaintext is stored (e.g., `decryptedMessages[message.id]`).
  4. Frontend state (messages array) updated, UI re-renders to show decrypted message.
  5. `ApiService.markAsRead` PATCHes `/api/messages/{id}/read` after display (or user interaction).

5. Privacy & Data Handling

5.1 Data Minimization

  • Account: `username` only required identifier.
  • Content: Message plaintext never stored/logged server-side.
  • Metadata: Minimal required metadata stored (`sender_id`, `recipient_id`, `timestamps`, `is_read`).
  • Keys: Private keys never leave the client device during normal operation.

5.2 Data Retention & Deletion

  • Messages: Retained until manually deleted by sender/recipient (`DELETE /api/messages/{id}`) or conversation deleted (`DELETE /api/conversations/{userId}`). Database cascade ensures related records removed.
  • Ephemeral Messages: Server-side deletion logic needs verification, but intended to delete after first read confirmation.
  • Account Deletion (`POST /account/delete`): Triggers `User::delete()`. Eloquent model's `deleting` event handler (`User::booted`) ensures cascading deletion of associated `KeyPair`, `Message` (sent/received), and potentially `TypingStatus` records. Session data is invalidated.
  • Key Pairs: Deleted upon account deletion via cascade. Can be regenerated by user via settings.
  • Typing Statuses: Likely ephemeral, cleaned periodically or deleted via cascade. Check `MessageController@getTypingStatus` for timeout logic.

6. Security Considerations & Limitations

  • Lack of Forward Secrecy: As detailed in 2.1.5, compromise of long-term RSA private key compromises past messages.
  • Client-Side Key Management:
    • Keys tied to browser profile. Loss of profile/device = loss of access.
    • No built-in multi-device support (requires manual key export/import).
    • Vulnerable to Cross-Site Scripting (XSS) if input sanitization fails elsewhere, potentially allowing attackers to exfiltrate keys from IndexedDB or intercept operations.
    • Vulnerable to malware/compromise of the user's device operating system.
  • Metadata Protection: Communication patterns (who talks to whom, when, how often) are visible server-side.
  • Message Authenticity (Pre-computation):** While signatures prevent tampering *in transit*, an attacker who compromises the sender's private key *before* a message is sent can forge messages and valid signatures.
  • Key Authenticity: Initial key exchange relies on trusting the server (`/api/keypair/{userId}`) to provide the correct public key. No out-of-band verification mechanism (like scanning QR codes or safety numbers) is implemented. A compromised server could potentially perform a Man-in-the-Middle (MitM) attack during key lookup.
  • Replay Attacks: Need to verify if nonce/timestamp mechanisms prevent replaying old messages, although AES-GCM inherently provides some protection.
  • Dependency Security: Relies on the security of Laravel, Vue, Tailwind, js-crypto libraries, and browser Web Crypto implementations. Vulnerabilities in dependencies could impact the application.

7. Server Configuration & Deployment

  • Requires standard PHP/Laravel environment (PHP 8.2+, Composer, relevant PHP extensions like `pdo_mysql`, `openssl`, `mbstring`, `tokenizer`, `xml`).
  • MySQL database server.
  • Node.js/npm for frontend asset compilation (`npm run build`).
  • Web server (Nginx/Apache) configured to serve the `public` directory and handle PHP requests via FPM. Standard Laravel security configurations (directory permissions, disabling `.env` access) should be followed.
  • HTTPS is essential for protecting authentication tokens and preventing MitM during API calls/key fetches. Configuration handled at the web server level (e.g., Let's Encrypt).

8. Auditing & Contact

  • Client-side JavaScript (`resources/js/**/*.js`, compiled output in `public/build/assets/`) is fully inspectable in the browser's developer tools.
  • Backend PHP code (`app/`, `routes/`, `config/`) defines server logic.
  • Responsible disclosure of security vulnerabilities is encouraged via email to [email protected].