This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package helpers
|
package helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -9,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"forge.redroom.link/yves/meowlib"
|
"forge.redroom.link/yves/meowlib"
|
||||||
"forge.redroom.link/yves/meowlib/client"
|
"forge.redroom.link/yves/meowlib/client"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func PackMessageForServer(packedMsg *meowlib.PackedUserMessage, srvuid string) ([]byte, string, error) {
|
func PackMessageForServer(packedMsg *meowlib.PackedUserMessage, srvuid string) ([]byte, string, error) {
|
||||||
@@ -137,6 +141,78 @@ func ReadAckMessageResponse() {
|
|||||||
//! update the status in message store
|
//! update the status in message store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkMessageProcessed stamps the stored message with a processed timestamp of
|
||||||
|
// now(), persists the updated record, and — if the peer has SendProcessingAck
|
||||||
|
// enabled and the message carries a UUID — enqueues a processed acknowledgment
|
||||||
|
// to the peer's contact pull servers.
|
||||||
|
func MarkMessageProcessed(peerUid string, dbFile string, dbId int64) error {
|
||||||
|
password, _ := client.GetConfig().GetMemPass()
|
||||||
|
processedAt := time.Now().UTC().Unix()
|
||||||
|
|
||||||
|
dbm, err := client.GetDbMessage(dbFile, dbId, password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MarkMessageProcessed: GetDbMessage: %w", err)
|
||||||
|
}
|
||||||
|
if dbm.Status == nil {
|
||||||
|
dbm.Status = &meowlib.ConversationStatus{}
|
||||||
|
}
|
||||||
|
dbm.Status.Processed = uint64(processedAt)
|
||||||
|
if err := client.UpdateDbMessage(dbm, dbFile, dbId, password); err != nil {
|
||||||
|
return fmt.Errorf("MarkMessageProcessed: UpdateDbMessage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
peer := client.GetConfig().GetIdentity().Peers.GetFromUid(peerUid)
|
||||||
|
if peer == nil || !peer.SendProcessingAck || dbm.Status.Uuid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
identity := client.GetConfig().GetIdentity()
|
||||||
|
storagePath := filepath.Join(client.GetConfig().StoragePath, identity.Uuid)
|
||||||
|
return sendProcessingAck(storagePath, peer, dbm.Status.Uuid, processedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendProcessingAck builds a processing acknowledgment for messageUuid and
|
||||||
|
// enqueues it for sending to the peer's contact pull servers.
|
||||||
|
func sendProcessingAck(storagePath string, peer *client.Peer, messageUuid string, processedAt int64) error {
|
||||||
|
packedMsg, _, err := BuildProcessedMessage(messageUuid, peer.Uid, processedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sendProcessingAck: BuildProcessedMessage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := proto.Marshal(packedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sendProcessingAck: proto.Marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboxDir := filepath.Join(storagePath, "outbox")
|
||||||
|
if err := os.MkdirAll(outboxDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("sendProcessingAck: MkdirAll: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboxFile := filepath.Join(outboxDir, "ack_"+uuid.New().String())
|
||||||
|
if err := os.WriteFile(outboxFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("sendProcessingAck: WriteFile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var servers []client.Server
|
||||||
|
for _, srvUid := range peer.ContactPullServers {
|
||||||
|
srv, loadErr := client.GetConfig().GetIdentity().MessageServers.LoadServer(srvUid)
|
||||||
|
if loadErr == nil && srv != nil {
|
||||||
|
servers = append(servers, *srv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(servers) == 0 {
|
||||||
|
os.Remove(outboxFile)
|
||||||
|
return errors.New("sendProcessingAck: no contact servers found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.PushSendJob(storagePath, &client.SendJob{
|
||||||
|
Queue: peer.Uid,
|
||||||
|
File: outboxFile,
|
||||||
|
Servers: servers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessSentMessages scans every send queue under storagePath/queues/, updates
|
// ProcessSentMessages scans every send queue under storagePath/queues/, updates
|
||||||
// the message storage entry with server delivery info for each sent job, then
|
// the message storage entry with server delivery info for each sent job, then
|
||||||
// removes the job from the queue. Returns the number of messages updated.
|
// removes the job from the queue. Returns the number of messages updated.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Each message event (received, sent, status change) is forwarded immediately to s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## New Protobuf Message
|
## New Protobuf Messages
|
||||||
|
|
||||||
Add to `pb/messages.proto` before re-generating:
|
Add to `pb/messages.proto` before re-generating:
|
||||||
|
|
||||||
@@ -51,10 +51,14 @@ Add to `pb/messages.proto` before re-generating:
|
|||||||
// Payload carried inside UserMessage.appdata for device-to-device sync.
|
// Payload carried inside UserMessage.appdata for device-to-device sync.
|
||||||
// The enclosing UserMessage.type MUST be "device_sync".
|
// The enclosing UserMessage.type MUST be "device_sync".
|
||||||
message DeviceSyncPayload {
|
message DeviceSyncPayload {
|
||||||
string sync_type = 1; // "msg" | "status" | "peer_event"
|
string sync_type = 1; // "msg" | "status" | "peer_update" | "identity_update" | "server_add" | "forward"
|
||||||
string peer_uid = 2; // local UID of the peer conversation on the sending device
|
string peer_uid = 2; // local UID of the peer conversation on the sending device
|
||||||
DbMessage db_message = 3; // the DbMessage to replicate
|
DbMessage db_message = 3; // the DbMessage to replicate (sync_type "msg" / "status")
|
||||||
string dedup_id = 4; // globally unique ID (= DbMessage.status.uuid or generated)
|
string dedup_id = 4; // globally unique ID (= DbMessage.status.uuid or generated)
|
||||||
|
bytes peer_data = 5; // JSON-encoded Peer snapshot (sync_type "peer_update")
|
||||||
|
bytes identity_data = 6; // JSON-encoded identity profile snapshot (sync_type "identity_update")
|
||||||
|
bytes forward_payload = 7; // serialized UserMessage for primary to send on behalf of sibling (sync_type "forward")
|
||||||
|
string forward_peer_uid = 8; // primary-side peer UID to forward to (sync_type "forward")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -74,6 +78,7 @@ Run `cd pb && ./protogen.sh` after adding this.
|
|||||||
- Identical to `InvitePeer` but sets `peer.Type = "device"`.
|
- Identical to `InvitePeer` but sets `peer.Type = "device"`.
|
||||||
- Stores the resulting peer in `Identity.OwnedDevices` (not `Peers`).
|
- Stores the resulting peer in `Identity.OwnedDevices` (not `Peers`).
|
||||||
- Returns the peer so the caller can produce a `ContactCard` QR/file.
|
- Returns the peer so the caller can produce a `ContactCard` QR/file.
|
||||||
|
- **Sym + DR inherited automatically**: because the implementation mirrors `InvitePeer`, the device peer will have `MySymKey`, `DrKpPublic`, `DrKpPrivate`, `DrRootKey`, and `DrInitiator = true` populated automatically. The resulting `ContactCard` will carry `dr_root_key` and `dr_public_key` so the answering device can initialise its own DR session via `AnswerDevicePairing`.
|
||||||
|
|
||||||
#### 1.2 `Identity.AnswerDevicePairing(myDeviceName string, receivedContact *meowlib.ContactCard) (*Peer, error)`
|
#### 1.2 `Identity.AnswerDevicePairing(myDeviceName string, receivedContact *meowlib.ContactCard) (*Peer, error)`
|
||||||
- Mirrors `AnswerInvitation`, stores in `OwnedDevices`.
|
- Mirrors `AnswerInvitation`, stores in `OwnedDevices`.
|
||||||
@@ -92,7 +97,7 @@ func DevicePairingAnswerMessage(peer *client.Peer, serverUid string) ([]byte, st
|
|||||||
These reuse `invitationCreateHelper.go`/`invitationAnswerHelper.go` logic.
|
These reuse `invitationCreateHelper.go`/`invitationAnswerHelper.go` logic.
|
||||||
|
|
||||||
#### 1.5 Extend `PeerStorage` operations for OwnedDevices
|
#### 1.5 Extend `PeerStorage` operations for OwnedDevices
|
||||||
`OwnedDevices` is currently a `PeerList` (in-memory slice). For scalability it should use the same Badger-backed `PeerStorage` mechanism as `Peers`. Consider adding a second `PeerStorage` field `DeviceStorage` to `Identity` with its own `DbFile`.
|
`OwnedDevices` is currently a `PeerList` (in-memory slice). This **must** be migrated to the same Badger-backed `PeerStorage` mechanism as `Peers` — it is no longer optional. Device peers carry a Double Ratchet session state (`DrStateJson`) that advances with every message sent or received. Without persistent storage the DR state is lost on restart, breaking the decryption of all subsequent messages. Add a `DeviceStorage PeerStorage` field to `Identity` with its own `DbFile`, and ensure `StorePeer` is called on the device peer after every outbound dispatch (in `DispatchSyncToDevices`) and after every inbound consume (in `ConsumeDeviceSyncMessage`), mirroring the pattern used in `messageHelper.go` and `bgPollHelper.go` for regular peers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -136,6 +141,8 @@ func DispatchSyncToDevices(
|
|||||||
|
|
||||||
Iterates `identity.OwnedDevices`, builds and queues one `SendJob` per device (just like `CreateUserMessageAndSendJob` but using device peer keys and putting the message in `outbox/` with a recognisable prefix, e.g. `dev_{devPeerUid}_{dedupId}`).
|
Iterates `identity.OwnedDevices`, builds and queues one `SendJob` per device (just like `CreateUserMessageAndSendJob` but using device peer keys and putting the message in `outbox/` with a recognisable prefix, e.g. `dev_{devPeerUid}_{dedupId}`).
|
||||||
|
|
||||||
|
After calling `peer.ProcessOutboundUserMessage` for each device peer, persist the updated DR state: `identity.DeviceStorage.StorePeer(devPeer)` if `devPeer.DrRootKey != ""`.
|
||||||
|
|
||||||
The message is packed into `ToServerMessage.Messages` (same field as regular chat). No server changes needed.
|
The message is packed into `ToServerMessage.Messages` (same field as regular chat). No server changes needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -214,15 +221,17 @@ func ConsumeDeviceSyncMessage(
|
|||||||
```
|
```
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Decrypt with `devPeer.ProcessInboundUserMessage(packed.Payload, packed.Signature)`.
|
1. Decrypt with `devPeer.ProcessInboundUserMessage(packed)` (takes the full `*PackedUserMessage` — **not** `payload, signature` separately; that API was updated when the sym-encryption + double-ratchet layer was added).
|
||||||
2. Check `usermsg.Type == "device_sync"`.
|
2. Check `usermsg.Type == "device_sync"`.
|
||||||
3. Deserialise `DeviceSyncPayload` from `usermsg.Appdata`.
|
3. Deserialise `DeviceSyncPayload` from `usermsg.Appdata`.
|
||||||
4. Dedup check: call `IsDeviceSyncSeen(payload.DedupId)`. If yes, skip.
|
4. Dedup check: call `IsDeviceSyncSeen(payload.DedupId)`. If yes, skip.
|
||||||
5. Mark seen: `MarkDeviceSyncSeen(payload.DedupId)`.
|
5. Mark seen: `MarkDeviceSyncSeen(payload.DedupId)`.
|
||||||
6. Dispatch by `payload.SyncType`:
|
6. **Persist DR state** — after decryption, if `devPeer.DrRootKey != ""`, call `identity.OwnedDevices.StorePeer(devPeer)` (or the equivalent Badger-backed store) to persist the updated `DrStateJson`. This mirrors what `ConsumeInboxFile` does for regular peers.
|
||||||
|
7. Dispatch by `payload.SyncType`:
|
||||||
- `"msg"`: find the local peer by `payload.PeerUid`, call `client.StoreDeviceSyncedMessage(peer, payload.DbMessage)`.
|
- `"msg"`: find the local peer by `payload.PeerUid`, call `client.StoreDeviceSyncedMessage(peer, payload.DbMessage)`.
|
||||||
- `"status"`: update the status fields in the existing DB row matched by `payload.DbMessage.Status.Uuid`.
|
- `"status"`: update the status fields in the existing DB row matched by `payload.DbMessage.Status.Uuid`.
|
||||||
- `"peer_event"`: (future) update peer metadata.
|
- `"peer_update"`: apply `payload.PeerData` to the local peer record (see Phase 6).
|
||||||
|
- `"identity_update"`: apply `payload.IdentityData` to the local identity profile (see Phase 6).
|
||||||
|
|
||||||
#### 4.4 `StoreDeviceSyncedMessage` in `client/messagestorage.go`
|
#### 4.4 `StoreDeviceSyncedMessage` in `client/messagestorage.go`
|
||||||
|
|
||||||
@@ -233,6 +242,179 @@ A thin wrapper around `storeMessage` that:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Phase 6 — Peer Metadata and Identity Profile Sync
|
||||||
|
|
||||||
|
**Files to touch:** `client/helpers/deviceHelper.go`, `client/helpers/deviceSyncHelper.go`, `client/identity.go`
|
||||||
|
|
||||||
|
The goal is to propagate non-message data across sibling devices: peer names/avatars/settings and the identity profile. This is **one-directional fan-out** (whichever device makes the change dispatches to all siblings) — no merge protocol is needed because conflicts are resolved by last-write-wins (the dedupId carries a timestamp or UUID sufficient for dedup; ordering is not guaranteed but is acceptable for profile data).
|
||||||
|
|
||||||
|
#### 6.1 Peer metadata sync (`sync_type = "peer_update"`)
|
||||||
|
|
||||||
|
Dispatch a `"peer_update"` payload whenever a peer record is meaningfully mutated (name, avatar, notification settings, visibility, blocked state, etc.).
|
||||||
|
|
||||||
|
**Payload**: `DeviceSyncPayload.PeerData` is a JSON-encoded **full `Peer` struct**, including all private key material and DR state. This is safe because:
|
||||||
|
- The device sync channel is E2E-encrypted with the same X25519 + sym + DR stack as peer messages.
|
||||||
|
- The target server is user-owned; the operator is the user themselves.
|
||||||
|
- The recipient is the same person on a different device.
|
||||||
|
|
||||||
|
Fields included in `PeerData`:
|
||||||
|
- All keypairs in full: `MyIdentity`, `MyEncryptionKp`, `MyLookupKp` (private + public)
|
||||||
|
- `MySymKey` — shared symmetric key for that peer's channel
|
||||||
|
- `DrKpPrivate`, `DrKpPublic`, `DrRootKey`, `DrInitiator`, `ContactDrPublicKey`
|
||||||
|
- **`DrStateJson`** — current live DR session state (see DR note below)
|
||||||
|
- All contact keys: `ContactPublicKey`, `ContactEncryption`, `ContactLookupKey`, `ContactPullServers`
|
||||||
|
- All metadata: `Name`, `Avatar`, `Avatars`, `MyName`, `Visible`, `Blocked`, `MessageNotification`, `SendDeliveryAck`, `SendProcessingAck`, `CallsAllowed`, server lists, etc.
|
||||||
|
|
||||||
|
Fields excluded from `PeerData`:
|
||||||
|
- `dbPassword` — transient in-memory field, never serialised; the receiving device uses its own memory password.
|
||||||
|
|
||||||
|
The receiving device upserts the peer into its local `Peers` store. After applying the sync, the sibling device is a full participant in the conversation: it can send and receive messages using the replicated keypairs, has the same DR session state, and monitors the same lookup key queues.
|
||||||
|
|
||||||
|
**DR state sync (Phase 6 only)**: Syncing `DrStateJson` as part of `"peer_update"` gives sibling devices a working DR session at the point of pairing and keeps them in sync during normal single-active-device use. Phase 7 supersedes this with independent per-device DR sessions, eliminating all shared-state concerns. If Phase 7 is implemented, the `DrStateJson` field in `PeerData` can be omitted from the sync payload (each device initialises its own fresh session via the device introduction flow).
|
||||||
|
|
||||||
|
**New peers**: When Device A completes an invitation with a new contact, it dispatches `"peer_update"` to all siblings with the full peer record. Device B immediately becomes a full participant — same keypairs, same lookup key, same DR session start state — and can transparently send and receive messages with that contact without any secondary invitation.
|
||||||
|
|
||||||
|
#### 6.2 Identity profile sync (`sync_type = "identity_update"`)
|
||||||
|
|
||||||
|
Dispatch an `"identity_update"` whenever `Identity.Nickname`, `Identity.DefaultAvatar`, `Identity.Avatars`, or `Identity.Status` changes.
|
||||||
|
|
||||||
|
**Payload**: `DeviceSyncPayload.IdentityData` is a JSON-encoded subset of `Identity`:
|
||||||
|
```go
|
||||||
|
type IdentityProfileSnapshot struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
DefaultAvatar string `json:"default_avatar"`
|
||||||
|
Avatars []Avatar `json:"avatars"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The receiving device deserialises this and updates only the listed fields on its local `Identity`, then calls `identity.Save()`.
|
||||||
|
|
||||||
|
**Explicitly NOT synced** in `IdentityData`:
|
||||||
|
- `RootKp` — the user's root signing keypair is the trust anchor; it should be established once per identity creation and never transmitted, even over a secure channel. Compromise of the root key invalidates the entire identity.
|
||||||
|
- `Device` — device-specific keypair for server auth; each device has its own.
|
||||||
|
- `OwnedDevices` — the device mesh itself; managed separately by the pairing flow.
|
||||||
|
- `HiddenPeers` — sensitive by design; out of scope.
|
||||||
|
- `DefaultDbPassword`, `DbPasswordStore` — local security preferences.
|
||||||
|
- `MessageServers` / `Peers` — covered by their own sync types (`"server_add"`, `"peer_update"`).
|
||||||
|
|
||||||
|
#### 6.3 Server list sync (future — `sync_type = "server_add"`)
|
||||||
|
|
||||||
|
When a new `MessageServer` is added to one device's `MessageServers`, dispatch `"server_add"` so all siblings discover it. Implementation deferred; placeholder `sync_type` reserved.
|
||||||
|
|
||||||
|
#### 6.4 Dispatch hooks
|
||||||
|
|
||||||
|
- After `Identity.InvitePeer` / `FinalizeInvitation` / any peer metadata update: call `DispatchSyncToDevices(..., "peer_update", peer.Uid, nil, uuid.New().String())`.
|
||||||
|
- After `Identity.Save()` when profile fields changed: call `DispatchSyncToDevices(..., "identity_update", "", nil, uuid.New().String())`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7 — Per-Device DR Sessions (Bullet-proof Forward Secrecy)
|
||||||
|
|
||||||
|
**Goal**: Eliminate the concurrent-send DR race without shared ratchet state and without leaking device count to contacts.
|
||||||
|
|
||||||
|
#### 7.0 Privacy constraint
|
||||||
|
|
||||||
|
The naive per-device DR approach (introduce all devices to all contacts) has a fundamental privacy problem: every contact learns how many devices you own and receives session material for each. This leaks metadata — device count, device rotation events, possibly device fingerprints. This is unacceptable for a privacy-first library.
|
||||||
|
|
||||||
|
Two architecturally sound options are described below. **Option B (primary device relay) is recommended** because it preserves complete contact-side opacity and requires no protocol extension on the contact side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Option A — Contact-aware per-device sessions (not recommended)
|
||||||
|
|
||||||
|
Each device is introduced to all contacts via a `"device_introduce"` message. The contact maintains one independent DR session per device and sends a separate encrypted copy per device on every message.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| DR race | ❌ Eliminated |
|
||||||
|
| Contact privacy | ❌ Contacts learn device count and session keys |
|
||||||
|
| Contact protocol change | ✅ Required (handle `DeviceInfo` list, multi-destination send) |
|
||||||
|
| Backward compatibility | ❌ Old clients can't participate |
|
||||||
|
| Server changes | ✅ None |
|
||||||
|
|
||||||
|
This is Signal's model. It is appropriate when contacts are expected to be aware of device multiplicity (e.g. a closed ecosystem). It is **not** appropriate for meowlib's open, privacy-first design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Option B — Primary device relay (recommended)
|
||||||
|
|
||||||
|
The device that owns the peer relationship (the one whose keypairs are in the `Peer` record — call it the **primary**) is the only device that ever communicates directly with a contact. Its DR session with the contact is singular, unshared, and advances normally.
|
||||||
|
|
||||||
|
Sibling devices that want to send a message do so by dispatching a `"forward"` device sync payload to the primary. The primary re-encrypts with the contact's keys and forwards. From the contact's perspective: one sender, one DR session, zero device awareness.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| DR race | ❌ Eliminated (only primary drives the DR session) |
|
||||||
|
| Contact privacy | ✅ Contact is completely unaware of sibling devices |
|
||||||
|
| Contact protocol change | ✅ None required |
|
||||||
|
| Backward compatibility | ✅ Full |
|
||||||
|
| Server changes | ✅ None |
|
||||||
|
| Trade-off | If primary is offline, sibling outbound messages queue until it returns |
|
||||||
|
|
||||||
|
##### 7.1 Primary device designation
|
||||||
|
|
||||||
|
The device that completes the invitation flow for a peer (calls `InvitePeer` or `FinalizeInvitation`) is the primary for that peer. The `Peer` record synced to sibling devices carries a `PrimaryDeviceUid string` field (the UID of the device peer that "owns" this peer relationship):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Add to Peer struct:
|
||||||
|
PrimaryDeviceUid string `json:"primary_device_uid,omitempty"`
|
||||||
|
// empty = this device IS the primary for this peer
|
||||||
|
```
|
||||||
|
|
||||||
|
When a sibling device receives a `"peer_update"` sync, it sets `PrimaryDeviceUid` to the sender's device UID. When the primary device sends a peer update, it leaves `PrimaryDeviceUid` empty (it is the primary).
|
||||||
|
|
||||||
|
##### 7.2 New sync type: `"forward"`
|
||||||
|
|
||||||
|
Add to `DeviceSyncPayload.sync_type`:
|
||||||
|
|
||||||
|
```
|
||||||
|
"forward" — sibling device requests primary to send a message to a peer on its behalf
|
||||||
|
```
|
||||||
|
|
||||||
|
New fields needed in `DeviceSyncPayload`:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
bytes forward_payload = 7; // serialized UserMessage (plaintext, will be encrypted by primary)
|
||||||
|
string forward_peer_uid = 8; // local peer UID on the primary device to forward to
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 7.3 Send path on a sibling device
|
||||||
|
|
||||||
|
When a sibling device (one where `peer.PrimaryDeviceUid != ""`) sends a message to peer P:
|
||||||
|
|
||||||
|
1. Build the `UserMessage` normally.
|
||||||
|
2. **Do not** call `peer.ProcessOutboundUserMessage` — the sibling does not have a valid DR state for the contact.
|
||||||
|
3. Serialize the `UserMessage` (plaintext proto bytes).
|
||||||
|
4. Build a `DeviceSyncPayload{SyncType: "forward", ForwardPayload: serialized, ForwardPeerUid: peer.Uid}`.
|
||||||
|
5. Dispatch it to the primary device via the normal device sync send path.
|
||||||
|
6. Store the message locally with a `"pending_forward"` status so the UI reflects it immediately.
|
||||||
|
|
||||||
|
##### 7.4 Receive and forward path on the primary device
|
||||||
|
|
||||||
|
When `ConsumeDeviceSyncMessage` on the primary sees `sync_type == "forward"`:
|
||||||
|
|
||||||
|
1. Deserialize `ForwardPayload` into a `UserMessage`.
|
||||||
|
2. Locate the local peer by `ForwardPeerUid`.
|
||||||
|
3. Call `peer.ProcessOutboundUserMessage(userMessage)` — primary uses its DR session normally.
|
||||||
|
4. Enqueue a `SendJob` to deliver to the contact's server (same path as any outbound message).
|
||||||
|
5. Dispatch a `"msg"` sync back to all siblings with the now-stored `DbMessage` so they update the message status from `"pending_forward"` to sent.
|
||||||
|
|
||||||
|
##### 7.5 Offline queuing
|
||||||
|
|
||||||
|
If the primary device is offline when the sibling dispatches a `"forward"` sync, the sync message sits in the device sync queue on the server (same Redis sorted-set as all device messages). When the primary comes back online and polls, it picks up the forwarded message and delivers it. No message is lost; latency equals the primary's offline window.
|
||||||
|
|
||||||
|
##### 7.6 Result
|
||||||
|
|
||||||
|
- Zero contact protocol changes. Contacts cannot distinguish a primary-only device from a multi-device user.
|
||||||
|
- No device count leakage. Device topology is fully opaque to the outside world.
|
||||||
|
- No DR race. The primary drives a single ratchet per contact.
|
||||||
|
- No server changes.
|
||||||
|
- `ProcessOutboundUserMessage` signature stays `(*PackedUserMessage, error)` — no ripple through callers.
|
||||||
|
- Trade-off is well-bounded: forward latency ≤ primary polling interval, which is already the existing long-poll timeout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Phase 5 — Dedup Store
|
### Phase 5 — Dedup Store
|
||||||
|
|
||||||
**Files to touch:** new `client/devicesyncdedup.go`
|
**Files to touch:** new `client/devicesyncdedup.go`
|
||||||
@@ -262,16 +444,18 @@ func PruneDeviceSyncSeen(storagePath, identityUuid string, olderThan time.Durati
|
|||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pb/messages.proto` | Add `DeviceSyncPayload` message |
|
| `pb/messages.proto` | Add `DeviceSyncPayload` message (with `peer_data` and `identity_data` fields) |
|
||||||
| `pb/protogen.sh` → re-run | Regenerate `.pb.go` |
|
| `pb/protogen.sh` → re-run | Regenerate `.pb.go` |
|
||||||
| `client/identity.go` | Add `InitDevicePairing`, `AnswerDevicePairing`, `FinalizeDevicePairing`; extend `GetRequestJobs()` |
|
| `client/identity.go` | Add `InitDevicePairing`, `AnswerDevicePairing`, `FinalizeDevicePairing`; add `DeviceStorage PeerStorage` field; extend `GetRequestJobs()`; add profile-change dispatch hooks |
|
||||||
| `client/peer.go` | No changes needed (Type field already exists) |
|
| `client/peer.go` | No changes needed (Type field already exists) |
|
||||||
| `client/messagestorage.go` | Add `StoreDeviceSyncedMessage` |
|
| `client/messagestorage.go` | Add `StoreDeviceSyncedMessage` |
|
||||||
| `client/devicesyncdedup.go` | **New** — dedup SQLite helpers |
|
| `client/devicesyncdedup.go` | **New** — dedup SQLite helpers |
|
||||||
| `client/helpers/deviceHelper.go` | **New** — `BuildDeviceSyncMessage`, `DispatchSyncToDevices`, pairing message helpers |
|
| `client/helpers/deviceHelper.go` | **New** — `BuildDeviceSyncMessage`, `DispatchSyncToDevices` (msg + peer_update + identity_update), pairing message helpers |
|
||||||
| `client/helpers/deviceSyncHelper.go` | **New** — `ConsumeDeviceSyncMessage` |
|
| `client/helpers/deviceSyncHelper.go` | **New** — `ConsumeDeviceSyncMessage` (handles all sync types) |
|
||||||
| `client/helpers/messageHelper.go` | Add `DispatchSyncToDevices` call after outbound store |
|
| `client/helpers/messageHelper.go` | Add `DispatchSyncToDevices` call after outbound store; detect primary vs sibling role on send |
|
||||||
| `client/helpers/bgPollHelper.go` | Add device message detection in `ConsumeInboxFile` |
|
| `client/helpers/bgPollHelper.go` | Add device message detection in `ConsumeInboxFile` |
|
||||||
|
| `client/peer.go` | Add `PrimaryDeviceUid string` field; sibling send path dispatches `"forward"` instead of direct send |
|
||||||
|
| `client/helpers/deviceSyncHelper.go` | Handle `"forward"` sync type: deserialize, re-encrypt, enqueue SendJob, dispatch `"msg"` sync back |
|
||||||
|
|
||||||
Server package: **no changes required**.
|
Server package: **no changes required**.
|
||||||
|
|
||||||
@@ -286,17 +470,27 @@ Server package: **no changes required**.
|
|||||||
| Message UUID | ✅ | Via `ConversationStatus.Uuid` |
|
| Message UUID | ✅ | Via `ConversationStatus.Uuid` |
|
||||||
| Sent/received timestamps | ✅ | In `ConversationStatus` |
|
| Sent/received timestamps | ✅ | In `ConversationStatus` |
|
||||||
| File content | ❌ | Not synced; only `FilePaths` metadata synced |
|
| File content | ❌ | Not synced; only `FilePaths` metadata synced |
|
||||||
| Peer metadata (keys, servers) | ❌ | Phase 2+ scope |
|
| Peer full keypairs (private + public) | ✅ | Phase 6 — included in `"peer_update"` `PeerData`; channel is E2E-encrypted on user-owned server |
|
||||||
| Identity blob | ❌ | Out of scope; handled by manual export |
|
| Peer symmetric key | ✅ | Phase 6 — included in `"peer_update"` `PeerData` |
|
||||||
|
| Peer DR session state (`DrStateJson`) | ✅ | Phase 6 — synced on peer_update; Phase 7 (Option B) eliminates the need: primary drives one DR session, siblings never touch it |
|
||||||
|
| Peer metadata (name, avatar, settings) | ✅ | Phase 6 — `"peer_update"` sync type |
|
||||||
|
| New peer (unknown to sibling) | ✅ | Full peer record synced; sibling becomes immediate full participant |
|
||||||
|
| Identity profile (nickname, avatar, status) | ✅ | Phase 6 — `"identity_update"` sync type |
|
||||||
|
| Identity root keypair (`RootKp`) | ❌ | Trust anchor; never transmitted even over secure channel |
|
||||||
|
| Known/message server list | ⚠️ | Future — `"server_add"` placeholder reserved |
|
||||||
|
| Hidden peers | ❌ | Hidden by design; out of scope |
|
||||||
|
| Device keypair | ❌ | Per-device; each device authenticates to servers with its own key |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Privacy Properties
|
## Privacy Properties
|
||||||
|
|
||||||
- Device sync messages are end-to-end encrypted (same X25519 + PGP as peer messages).
|
- Device sync messages are end-to-end encrypted (same X25519 + sym + DR stack as peer messages).
|
||||||
- The server sees only the device lookup key as destination; it has no knowledge this is a sync vs a peer message.
|
- The server sees only the device lookup key as destination; it cannot distinguish sync messages from peer messages.
|
||||||
- Including device lookup keys in batch pull requests does not leak which other device belongs to you (same privacy model as multiple peer lookup keys per request).
|
- Including device lookup keys in batch pull requests does not leak which device belongs to you (same privacy model as multiple peer lookup keys per request).
|
||||||
- `OwnedDevices` peers should be considered "hidden" (not shown in contact lists) and can optionally be stored in the hidden peer store.
|
- `OwnedDevices` peers should be treated as "hidden" (not shown in contact lists) and stored in the device storage, separate from regular peers.
|
||||||
|
- **Contacts are never made aware of device count or device identity** (Phase 7 Option B). The primary device relay model means the outside world observes exactly one sender per user identity, regardless of how many devices are active.
|
||||||
|
- The device mesh topology (which devices exist, how many) is known only to the user's own devices, and is carried exclusively over the E2E-encrypted device sync channel on the user-owned server.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user