# mautrix-signal Bridge Deployment Plan

## 1. Deployment Target

**Same Contabo host**, same Docker Compose stack at `/opt/matrix/docker-compose.yml`.

Rationale: Synapse runs as Docker on Contabo (ref: `synapse.md` — "Docker Compose at /opt/matrix/docker-compose.yml"). The bridge container joins the existing `matrix-net` network so it can reach both `matrix-synapse` and `matrix-postgres` by container name without exposing any new ports externally.

## 2. Bridge Version

- **Image:** `dock.mau.dev/mautrix/signal:v0.2603.0`
- **Released:** 2026-03-16 (latest stable as of 2026-04-09)
- **Type:** Go rewrite (NOT the deprecated Python mautrix-signal)

Pin to the exact tag `v0.2603.0`, not `:latest`, so upgrades are intentional.

## 3. Database Plan

Create a new Postgres database and role inside the existing `matrix-postgres` container. The role gets **minimal grants** — `LOGIN` only, no `SUPERUSER`, no `CREATEDB`, no `CREATEROLE`. Ownership of the new database is the sole privilege.

```sql
-- Connect as the synapse superuser to create the role and database
CREATE ROLE mautrix_signal WITH LOGIN PASSWORD '<generated-64-char-password>'
  NOSUPERUSER NOCREATEDB NOCREATEROLE;
CREATE DATABASE mautrix_signal
  OWNER mautrix_signal
  ENCODING 'UTF8'
  LC_COLLATE 'C'
  LC_CTYPE 'C';
-- No additional GRANT needed — OWNER on the database gives full DDL/DML
-- within mautrix_signal only. The role has zero access to synapse or mas databases.
```

Verification after creation:

```sql
-- Confirm no superuser, no createdb
SELECT rolname, rolsuper, rolcreatedb, rolcreaterole FROM pg_roles WHERE rolname = 'mautrix_signal';
-- Expected: rolsuper=f, rolcreatedb=f, rolcreaterole=f

-- Confirm cannot access synapse DB
SET ROLE mautrix_signal;
SELECT 1 FROM synapse.public.users LIMIT 1;  -- should fail with permission denied
RESET ROLE;
```

- Collation matches Synapse's DB settings (ref: `synapse.md` — POSTGRES_INITDB_ARGS uses `--lc-collate=C --lc-ctype=C`)
- No shared schema with Synapse or MAS
- The `synapse` user has Superuser privileges so can create the role/DB (ref: `synapse.md` — "synapse (Superuser, Create role, Create DB)")
- Bridge config URI: `postgres://mautrix_signal:<password>@matrix-postgres:5432/mautrix_signal?sslmode=disable`

## 4. Networking

- **Appservice port:** 29328 (mautrix-signal default)
- **Bind:** 0.0.0.0:29328 inside container (Docker internal only, NOT exposed to host)
- **Appservice address in config:** `http://mautrix-signal:29328` (container name on matrix-net)
- **Verified unused:** No ports in 29xxx range are in use (ref: `appservices.md` — "Full 29000-29999 range — AVAILABLE")
- **No Caddy changes needed** — bridge communicates with Synapse over the internal Docker network
- **No firewall changes needed** — no host port mapping

The bridge container joins `matrix-net` in docker-compose.yml:

```yaml
  mautrix-signal:
    image: dock.mau.dev/mautrix/signal:v0.2603.0
    container_name: mautrix-signal
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - ./mautrix-signal:/data
    networks:
      - matrix-net
```

No `ports:` section — the container is only reachable from within `matrix-net`.

## 5. Encryption Config

Enable end-to-bridge encryption with MSC4190 (required for MAS compatibility).

### 5a. Bridge config (`config.yaml`)

```yaml
encryption:
  allow: true
  default: true
  require: true
  appservice: false
  msc4190: true       # REQUIRED when MAS is in use
  allow_key_sharing: true
  pickle_key: generate
  self_sign: true
```

**MSC4190 requirement:** The mautrix docs state: "The `encryption` -> `msc4190` config option must be set to true for encryption to work if you use MAS."
Source: https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html

### 5b. Synapse `homeserver.yaml` diff

The exact flag names are confirmed from Synapse 1.147.1 source code at `synapse/config/experimental.py`:
- `msc3202_transaction_extensions` — parsed at line ~167, default `False`
- `msc2409_to_device_messages_enabled` — parsed at line ~163, default `False`

Source: `synapse.config.experimental.ExperimentalConfig` in Synapse v1.147.1 (`docker exec matrix-synapse python3 -c "import synapse.config.appservice"` — inspected live).

The current `homeserver.yaml` has no `experimental_features` block. The exact diff:

```diff
--- a/homeserver.yaml
+++ b/homeserver.yaml
@@ -end of file
+
+experimental_features:
+  msc3202_transaction_extensions: true
+  msc2409_to_device_messages_enabled: true
```

These flags enable:
- `msc3202_transaction_extensions`: Allows Synapse to send to-device messages, device list changes, and OTK counts in appservice transaction pushes (required for E2BE)
- `msc2409_to_device_messages_enabled`: Allows appservices to receive to-device messages (required for encryption key exchange)

**Note:** MSC4190 is NOT an `experimental_features` flag. It is parsed from the appservice registration YAML file as `io.element.msc4190: true` (confirmed from Synapse 1.147.1 source: `synapse/config/appservice.py` line 190: `msc4190_enabled = as_info.get("io.element.msc4190", False)`).

### 5c. Appservice registration (`registration.yaml`)

The bridge auto-generates `registration.yaml` when `encryption.msc4190: true` is set in `config.yaml`. The generated file will include these flags:

```yaml
io.element.msc4190: true
de.sorunome.msc2409.push_ephemeral: true
push_ephemeral: true
```

Synapse 1.147.1 supports MSC4190 (merged in Synapse 1.121.0 via PR #17705).
Source: https://github.com/element-hq/synapse/pull/17705

### 5d. Trust boundary

The bridge process holds plaintext message content in memory after decryption. The trust boundary extends from the Matrix client to the bridge container. Signal transport encryption remains intact on the Signal side.

## 6. MAS Interaction

### Claim: Appservice registration bypasses MAS

**Status: ASSUMPTION — verify during Phase 3 with stop-and-check.**

The evidence supporting this claim:
1. Appservices authenticate to Synapse via `as_token`/`hs_token` in the registration YAML, which is a Synapse-native mechanism predating MAS.
2. GitHub issue element-hq/matrix-authentication-service#3206 shows a user successfully registering and running a mautrix-signal appservice alongside MAS — basic appservice connectivity (GET `/versions`, GET `/account/whoami`, appservice ping) all returned 200 before encryption was attempted.
3. A commenter on that issue confirmed: "Can confirm it works with mautrix-signal and mautrix-whatsapp."
4. The Matrix Application Service spec defines its own auth mechanism independent of the C-S API auth layer.

Source: https://github.com/element-hq/matrix-authentication-service/issues/3206

**However**, no official MAS documentation explicitly states "appservices bypass MAS." The MAS docs (element-hq.github.io/matrix-authentication-service/) have no dedicated appservice compatibility page.

**Phase 3 stop-and-check procedure:**
1. After adding the registration to `homeserver.yaml` and restarting Synapse, check Synapse logs for appservice registration errors
2. Before starting the bridge container, run a manual appservice ping test:
   ```bash
   curl -sv http://127.0.0.1:8008/_matrix/client/v3/account/whoami \
     -H "Authorization: Bearer <as_token>" \
     -H "Content-Type: application/json" 2>&1
   ```

**PASS criteria (ALL must be true):**
- HTTP status code is `200`
- Response body contains `"user_id":"@signalbot:echo6.co"`
- Response body contains `"appservice_id":"signal"` (confirms Synapse recognized the as_token as appservice auth)

**FAIL criteria (ANY triggers STOP):**
- HTTP status `401` or `403` → MAS or Synapse rejected the as_token
- HTTP `3xx` redirect to MAS (`Location:` header pointing to `matrix-mas:8080` or `matrix.echo6.co` auth endpoints)
- Response contains MAS-specific indicators: HTML login page, `matrix-authentication-service` in headers/body, or `errcode: M_UNKNOWN_TOKEN` with MAS introspection trace in Synapse logs
- `user_id` in response does NOT match `@signalbot:echo6.co`

**On FAIL:** Do NOT start the bridge container. Do NOT proceed to step 16. Capture the full `curl -sv` output (headers + body) and the last 50 lines of Synapse logs (`docker logs matrix-synapse --tail 50`). Report both for triage.

### Registration steps

1. Generate the registration file by running the bridge container once with config in place
2. Copy `registration.yaml` into the Synapse data volume (`/opt/matrix/synapse/`)
3. Add to `homeserver.yaml`:
   ```yaml
   app_service_config_files:
     - /data/registration.yaml
     - /data/doublepuppet.yaml
   ```
   Both files are listed — Synapse reads all entries on startup. A single restart covers both registrations.
4. Restart Synapse (`docker compose restart synapse`) to pick up both appservice registrations

## 7. Permissions

```yaml
bridge:
  permissions:
    "*": relay
    "echo6.co": user
    "@matt:echo6.co": admin
```

- `*: relay` — external users can interact via relay (relay disabled by default, so effectively no access)
- `echo6.co: user` — all echo6.co users can use the bridge
- `@matt:echo6.co: admin` — full admin access for matt

Single-user deployment — only matt will link a Signal account.

## 8. Double Puppeting

Use the **appservice-based automatic double puppeting** method:

1. Generate a dedicated double-puppet appservice registration (`doublepuppet.yaml`) with a null URL and an `as_token`:
   ```yaml
   id: doublepuppet
   url:
   as_token: <generated-token>
   hs_token: <generated-token>
   sender_localpart: _doublepuppet
   rate_limited: false
   namespaces:
     users:
       - regex: '@.*:echo6\.co'
         exclusive: false
   ```
2. Register it with Synapse alongside the bridge registration in `app_service_config_files` (see section 6)
3. Configure the bridge:
   ```yaml
   double_puppet:
     secrets:
       echo6.co: "as_token:<the-as-token-from-doublepuppet.yaml>"
   ```

This ensures messages matt sends from Signal Desktop appear as `@matt:echo6.co` in Matrix rooms rather than as the Signal ghost user.

**MAS compatibility:** The appservice-based double puppeting method uses the appservice `as_token` to impersonate the user, which works independently of MAS. MAS handles human user auth; appservice tokens are Synapse-native. (Same assumption as section 6 — covered by the stop-and-check.)

## 9. Backup Impact

The existing backup script (`/opt/matrix/scripts/pg_backup.sh`) only backs up the `synapse` database (ref: `synapse.md` — "Backs up synapse DB only (NOT mas DB)").

**Action required — BEFORE bridge goes live:**

1. Update `pg_backup.sh` to dump `mautrix_signal` and `mas`:
   ```bash
   # Add to pg_backup.sh after the synapse dump:

   # mautrix-signal bridge database
   SIGNAL_BACKUP="${BACKUP_DIR}/mautrix_signal_${TIMESTAMP}.sql.gz"
   docker exec matrix-postgres pg_dump -U mautrix_signal -d mautrix_signal | gzip > "${SIGNAL_BACKUP}"
   if [ $? -eq 0 ] && [ -s "${SIGNAL_BACKUP}" ]; then
       echo "$(date): Backup created: ${SIGNAL_BACKUP} ($(du -h "${SIGNAL_BACKUP}" | cut -f1))"
   else
       echo "$(date): WARNING: mautrix_signal backup failed"
   fi

   # MAS database (was missing from backups)
   MAS_BACKUP="${BACKUP_DIR}/mas_${TIMESTAMP}.sql.gz"
   docker exec matrix-postgres pg_dump -U mas -d mas | gzip > "${MAS_BACKUP}"
   if [ $? -eq 0 ] && [ -s "${MAS_BACKUP}" ]; then
       echo "$(date): Backup created: ${MAS_BACKUP} ($(du -h "${MAS_BACKUP}" | cut -f1))"
   else
       echo "$(date): WARNING: mas backup failed"
   fi
   ```

2. Update the cleanup `find` to also cover `mautrix_signal_*.sql.gz` and `mas_*.sql.gz`

3. **Test the backup** by running `pg_backup.sh` manually after DB creation but before starting the bridge. Verify:
   - `mautrix_signal` dump succeeds (even if empty, it should produce a valid .sql.gz)
   - `mas` dump succeeds
   - Retention cleanup patterns match the new filenames

4. Apply the same 14-day retention policy.

## 10. Rollback Plan

If the bridge needs to be removed:

```bash
# 1. Stop and remove the bridge container
cd /opt/matrix
docker compose stop mautrix-signal
docker compose rm -f mautrix-signal

# 2. Remove mautrix-signal service block from docker-compose.yml

# 3. Remove appservice registrations from Synapse homeserver.yaml:
#    - Remove /data/registration.yaml from app_service_config_files
#    - Remove /data/doublepuppet.yaml from app_service_config_files
#    - If app_service_config_files is now empty, remove the key entirely

# 4. Revert experimental_features from homeserver.yaml:
#    - Remove the entire experimental_features block:
#        experimental_features:
#          msc3202_transaction_extensions: true
#          msc2409_to_device_messages_enabled: true
#    - Only safe to remove if no other bridges depend on these flags.
#      As of this plan, no other appservices exist (ref: appservices.md),
#      so removal is safe.

# 5. Remove registration files from Synapse volume
rm /opt/matrix/synapse/registration.yaml
rm /opt/matrix/synapse/doublepuppet.yaml

# 6. Restart Synapse to apply config changes
docker compose restart synapse

# 7. Drop the database and role
docker exec matrix-postgres psql -U synapse -c "DROP DATABASE mautrix_signal;"
docker exec matrix-postgres psql -U synapse -c "DROP ROLE mautrix_signal;"

# 8. Remove bridge data directory
rm -rf /opt/matrix/mautrix-signal

# 9. Revert backup script
# Edit pg_backup.sh: remove the mautrix_signal and mas dump sections
# (Keep mas dump if desired — it was missing before this plan anyway)

# 10. Clean up docker image
docker rmi dock.mau.dev/mautrix/signal:v0.2603.0
```

---

## Implementation Order (Phase 3 — requires approval)

1. Generate password for `mautrix_signal` DB role
2. Create DB role (with `NOSUPERUSER NOCREATEDB NOCREATEROLE`) and database in matrix-postgres
3. Verify role privileges are minimal (query `pg_roles`)
4. **Update backup script** to include `mautrix_signal` and `mas` dumps
5. **Test backup** — run `pg_backup.sh` manually, verify all three dumps succeed
6. Create `/opt/matrix/mautrix-signal/` directory
7. Generate initial config with `docker run --rm`
8. Edit `config.yaml` with all settings from this plan
9. Run container again to generate `registration.yaml`
10. Create `doublepuppet.yaml` registration
11. Copy both registration files to Synapse volume (`/opt/matrix/synapse/`)
12. Add `experimental_features` block to `homeserver.yaml`
13. Add `app_service_config_files` with both registration paths to `homeserver.yaml`
14. Restart Synapse
15. **STOP-AND-CHECK:** Verify appservice auth works alongside MAS (see section 6 procedure)
16. Add mautrix-signal service to `docker-compose.yml`
17. `docker compose up -d mautrix-signal`
18. Verify bridge bot appears in Matrix
19. Link Signal account via `!signal link` in bridge bot DM
20. Update docs (services.md, MEMORY.md)
