================================================================================
TAG API — Space E2EE (End-to-End Encryption) Implementation Spec
================================================================================
Date:    2026-06-26
Status:  LIVE — all tasks implemented and migrations applied


────────────────────────────────────────────────────────────────────────────────
OVERVIEW
────────────────────────────────────────────────────────────────────────────────

Each TagSpace has a shared symmetric key (32-byte AES-256-GCM key).
Every member gets an encrypted copy of that key, wrapped with their own
X25519 public key. The frontend decrypts the blob using the user's private key
(stored only on device), then uses the raw space key to encrypt/decrypt chat.

The server never sees a plaintext private key. The raw space key is held
server-side only to re-encrypt for new members, protected by AES-256-GCM
under E2EE_SERVER_KEY (stored in .env).


────────────────────────────────────────────────────────────────────────────────
ENCRYPTION SCHEME
────────────────────────────────────────────────────────────────────────────────

Member-key encryption  (server → client):
  1.  Generate ephemeral X25519 keypair: ephemeral_sk, ephemeral_pk
  2.  ECDH:  shared = X25519(ephemeral_sk, recipient_pk)
  3.  HKDF:  aes_key = HKDF-SHA256(shared || ephemeral_pk || recipient_pk,
                                    length=32, info="space-e2ee-v1")
  4.  AES-256-GCM encrypt space_key with aes_key, random 12-byte IV, 16-byte tag
  5.  Wire:  base64( ephemeral_pk[32] | iv[12] | tag[16] | ciphertext[32] )
             Total decoded = 92 bytes → base64 = 124 chars

Member-key decryption  (client-side, Dart):
  1.  base64-decode the blob
  2.  ephemeral_pk = blob[0:32]
  3.  iv          = blob[32:44]
  4.  tag         = blob[44:60]
  5.  ciphertext  = blob[60:92]
  6.  shared      = X25519(my_private_key, ephemeral_pk)
  7.  aes_key     = HKDF-SHA256(shared || ephemeral_pk || my_public_key,
                                length=32, info="space-e2ee-v1")
  8.  space_key   = AES-256-GCM-decrypt(ciphertext, aes_key, iv, tag)

Server-storage encryption  (keeps raw space key re-encryptable):
  key_material = SHA-256(E2EE_SERVER_KEY)
  blob         = base64( iv[12] | tag[16] | ciphertext )
  Algorithm    : AES-256-GCM


────────────────────────────────────────────────────────────────────────────────
DATABASE TABLES  (Task 1)
────────────────────────────────────────────────────────────────────────────────

users
  + e2e_public_key  VARCHAR(64) NULL          -- base64 X25519 public key
                                               -- set via PUT /users/public-key

space_e2ee_keys
  id                   BIGINT UNSIGNED PK AI
  tag_space_id         BIGINT UNSIGNED UNIQUE  -- FK → tag_spaces.id
  key_version          INT UNSIGNED            -- increments on every rotation
  server_encrypted_key TEXT                    -- AES-256-GCM blob, keyed from .env
  created_at           DATETIME
  updated_at           DATETIME

space_e2ee_member_keys
  id            BIGINT UNSIGNED PK AI
  tag_space_id  BIGINT UNSIGNED                -- FK → tag_spaces.id
  user_id       BIGINT UNSIGNED                -- FK → users.id
  key_version   INT UNSIGNED                   -- matches space_e2ee_keys.key_version
  encrypted_key TEXT                           -- ECIES blob for this user
  created_at    DATETIME
  updated_at    DATETIME
  UNIQUE (tag_space_id, user_id)               -- one current key per member


────────────────────────────────────────────────────────────────────────────────
NEW FILES  (Tasks 2–3)
────────────────────────────────────────────────────────────────────────────────

app/Services/E2eeService.php
  encryptSpaceKeyForMember(rawKey, recipientPublicKeyB64) → string
      Encrypts a 32-byte space key for a user's X25519 public key.
      Returns base64 ECIES blob.

  initSpaceKey(spaceId, creatorUserId) → void
      Generates a random 32-byte space key, stores it server-encrypted,
      and distributes an encrypted copy to the creator if they have a
      registered public key. Called once on space creation.

  distributeKeyToMember(spaceId, userId) → void
      Encrypts the current space key for a newly joined member.
      Silently skips if the user has no registered public key.

  rotateSpaceKey(spaceId, leavingUserId) → void
      Generates a new space key, bumps key_version, and re-encrypts
      for all remaining active members. Called on leave and forced removal.

  distributeAllActiveSpaceKeys(userId) → void
      Backfills encrypted copies for every space a user already belongs to.
      Called when a user registers their public key for the first time.

  getMemberKey(spaceId, userId) → ?array
      Fetches { key_version, encrypted_key } for the GET endpoint.

  serverEncrypt(rawKey) → string
  serverDecrypt(encryptedB64) → string
      Internal AES-256-GCM envelope using E2EE_SERVER_KEY from .env.

app/Models/SpaceE2ee.php
  upsertSpaceKey(spaceId, version, serverEncryptedKey)
  getSpaceKey(spaceId) → ?array
  upsertMemberKey(spaceId, userId, version, encryptedKey)
  getMemberKey(spaceId, userId) → ?array


────────────────────────────────────────────────────────────────────────────────
MODIFIED FILES  (Tasks 4–6)
────────────────────────────────────────────────────────────────────────────────

app/Controllers/TagSpaceController.php         (Task 4)
  store() — after creator session is created:
    E2eeService::initSpaceKey($id, $authUser->user_id)

app/Controllers/TagSpaceSessionController.php  (Tasks 5–6)
  join() — after new session is created:
    E2eeService::distributeKeyToMember($space['id'], $authUser->user_id)

  leave() — after session is closed:
    E2eeService::rotateSpaceKey($space['id'], $authUser->user_id)

  removeMember() — after target session is closed:
    E2eeService::rotateSpaceKey($space['id'], $targetUserId)

app/Controllers/ProfileController.php
  registerPublicKey() — after public key is saved:
    E2eeService::distributeAllActiveSpaceKeys($authUser->user_id)
    (backfills all spaces the user is already in)

app/Models/User.php
  + getE2ePublicKey(int $id): ?string
    Fetches e2e_public_key for a given user; returns null if not set.

app/routes/api.php
  + GET /api/v1/tag-spaces/{uuid}/e2ee-key → TagSpaceE2eeController::getKey()

db.sql
  + ALTER TABLE users ADD COLUMN IF NOT EXISTS e2e_public_key ...
  + CREATE TABLE IF NOT EXISTS space_e2ee_keys ...
  + CREATE TABLE IF NOT EXISTS space_e2ee_member_keys ...

.env
  + E2EE_SERVER_KEY=<64-char hex>   -- used to protect server-stored space keys


────────────────────────────────────────────────────────────────────────────────
NEW ENDPOINT
────────────────────────────────────────────────────────────────────────────────

GET /api/v1/tag-spaces/{uuid}/e2ee-key
  Auth:     Bearer JWT (must be active member or creator)
  Response: 200
    {
      "status": "success",
      "message": "E2EE key fetched.",
      "data": {
        "key_version": 3,
        "encrypted_key": "<base64 ECIES blob, 124 chars>"
      }
    }
  Errors:
    403  Not a member
    404  Space not found
    404  No key available (user has not registered a public key yet)
    Hint in 404 body: PUT /api/v1/users/public-key


────────────────────────────────────────────────────────────────────────────────
EXISTING ENDPOINTS (unchanged, documented for reference)
────────────────────────────────────────────────────────────────────────────────

PUT /api/v1/users/public-key
  Body: { "public_key": "<base64 X25519 32-byte key>" }
  Saves the user's public key. After saving, immediately distributes
  encrypted space keys for all spaces the user is currently in.
  Idempotent — safe to call on every login.

GET /api/v1/users/{userId}/public-key
  Returns { "user_id": int, "public_key": "<base64>" }
  Returns 404 if the user has not registered a key.


────────────────────────────────────────────────────────────────────────────────
KEY LIFECYCLE
────────────────────────────────────────────────────────────────────────────────

Event                     Action
────────────────────────  ──────────────────────────────────────────────────────
Space created             initSpaceKey → key_version=1, creator gets copy
Member joins              distributeKeyToMember → member gets current key copy
Member registers pub key  distributeAllActiveSpaceKeys → backfills all spaces
Member leaves             rotateSpaceKey → new key, version++, re-encrypt all
Member removed by creator rotateSpaceKey → new key, version++, re-encrypt all
Member has no public key  silently skipped; messages show 🔒 on their device


────────────────────────────────────────────────────────────────────────────────
SECURITY NOTES
────────────────────────────────────────────────────────────────────────────────

- The user's X25519 private key NEVER leaves the device.
- The raw space key is held server-side only to enable key distribution
  to new members. It is wrapped under E2EE_SERVER_KEY (AES-256-GCM).
  Compromise of E2EE_SERVER_KEY would expose space keys — rotate it if
  the server is breached and re-run distributeAllActiveSpaceKeys for all users.
- key_version increments on every rotation; the frontend should evict its
  cached space key whenever it receives a version higher than what it cached.
- Members who join without a registered public key receive no encrypted copy.
  When they later register a key, the backfill runs automatically.
- All E2EE failures are caught and swallowed (try/catch \Throwable) so a
  crypto error never blocks a user from joining, leaving, or being removed.
  Messages fall back to plaintext or 🔒 on the client side.


────────────────────────────────────────────────────────────────────────────────
TASK CHECKLIST
────────────────────────────────────────────────────────────────────────────────

[x] Task 1  — DB migrations (users.e2e_public_key, space_e2ee_keys,
                             space_e2ee_member_keys) — applied to live DB
[x] Task 2  — E2eeService with ECIES encrypt/decrypt + server envelope
[x] Task 3  — SpaceE2ee model (DB layer)
[x] Task 4  — Hook: space create → initSpaceKey
[x] Task 5  — Hook: member join  → distributeKeyToMember
[x] Task 6  — Hook: leave/remove → rotateSpaceKey
[x] Bonus   — GET /tag-spaces/{uuid}/e2ee-key endpoint
[x] Bonus   — Backfill on public key registration


================================================================================
