deso-protocol / deso-protocol/core
Removed access-group members can still decrypt all future group messages
- Dominant language
- Go
- Stars
- 327
- Forks
- 106
- PR merge metrics
- No merged PRs in 30d
Description
**Repos affected:** `deso-protocol/core`, `deso-protocol/backend`, `deso-protocol/identity`
**Severity:** Medium — silent privacy expectation gap. No funds at risk; no protocol invariant violated. Affects every app built on access-group messaging (deso-chat, ChatOn, any third-party messenger).
**Reporter context:** Surfaced while documenting group-chat behavior in a third-party DeSo messenger. Filing here because the root cause sits below the application layer — no app-side patch can fix it.
## Summary
When the owner of an access group calls `removeAccessGroupMembers`, the removed member loses their on-chain `AccessGroupMember` entry and disappears from the members list. However, they retain the cryptographic ability to decrypt **every future message** posted to that group. This is the combined effect of three independent design choices, each reasonable in isolation:
1. Access-group keypairs are deterministically derived from the group's `AccessGroupKeyName`, so the key cannot be rotated without changing the group's identity.
2. `removeAccessGroupMembers` deletes the member's encrypted-key entry but does not regenerate the underlying access-group keypair or re-key the remaining members.
3. `GetPaginatedMessagesForGroupChatThread` (and the equivalent UtxoView lookup for self-hosted nodes) does not check whether the caller is a current group member — it returns ciphertext for any access group ID the caller can name.
Most users — and most apps building on this — assume "remove member" implies "remove access." It does not. It only revokes UI-level discoverability.
## Steps to reproduce
1. User A creates an access group `myGroup` and adds users B, C as members.
2. B opens any DeSo messenger that exposes the access-group key (or simply re-derives it via `identity.accessGroupStandardDerivation('myGroup')` — the derivation is deterministic).
3. A removes B from the group via `removeAccessGroupMembers`.
4. A and C continue posting `NewMessage` transactions to the group.
5. B (now removed) calls `GetPaginatedMessagesForGroupChatThread` against any DeSo node:
```ts
await getPaginatedGroupChatThread({
UserPublicKeyBase58Check: A_OWNER_PUBKEY, // public
AccessGroupKeyName: 'myGroup', // public
MaxMessagesToFetch: 1000,
});
```
6. B decrypts the returned ciphertext using the same shared key they had as a member. **All messages sent after the removal are readable.**
No special tools needed. No custom node. Standard `deso-protocol` SDK calls against a public node.
## Root cause: code references
**1. Deterministic key derivation** — `deso-protocol/identity`
`accessGroupStandardDerivation(groupKeyName)` returns the same keypair for the same name across all calls. There is no nonce, no epoch, no per-instantiation salt. Two consequences:
- A removed member who knows the group name can re-derive the access-group private key without ever having exfiltrated it.
- An owner who wanted to "rotate" the key has no way to do so within the same group identity.
**2. No re-keying on member removal** — `deso-protocol/backend` (and on-chain in `core`)
`removeAccessGroupMembers` accepts the list of `AccessGroupMember` entries to delete. It removes those entries from state. It does not:
- Generate a new access-group keypair
- Re-encrypt and re-publish the new key for remaining members
- Mark the old key as superseded for downstream consumers
This is consistent with how the transaction is documented (it's billed as a member-management operation, not a key-rotation operation), so I'm flagging it as a design gap rather than a bug in the handler.
**3. Public read access to group ciphertext** — `deso-protocol/backend/routes/new_message.go:689`
```go
func (fes *APIServer) GetPaginatedMessagesForGroupChatThread(ww, req) {
// Decode JSON body
// Validate UserPublicKeyBase58Check + AccessGroupKeyName parse
// Build accessGroupId
// Return utxoView.GetPaginatedMessageEntriesForGroupChatThread(...)
}
```
No signature verification. No membership check against the current `AccessGroupMember` set for the requested `AccessGroupId`. The `UserPublicKeyBase58Check` parameter is the **group owner's** key, not a credential identifying the caller. Self-hosted nodes inherit the same property at the UtxoView level.
This is reasonable as a default — it matches the rest of DeSo's "everything is public, encryption is the gate" philosophy — but it leaves no defense-in-depth when the encryption gate is itself stuck open.
## Why this matters
End-user expectation, drawn from every centralized messenger: "kicking" someone from a group prevents them from reading future messages. Apps built on access groups inherit DeSo's behavior whether they realize it or not. The mismatch shows up in the worst possible way: the user trusts the kick, the app shows the kick succeeded, and the removed party silently keeps reading.
A few specific impact paths:
- **Personal**: ex-partner / former friend removed from a family or friend group continues reading
- **Workplace**: terminated employee removed from a project group continues reading post-termination discussions
- **Community/moderation**: banned user continues reading mod-only or appeals discussions
- **Compliance**: any "right to remove access" guarantee an app makes is unenforceable at the protocol level
## Possible mitigations (sketch — happy to discuss)
These are not equally good; listing in increasing protocol-invasiveness:
**1. Documentation-only.** Add a clear note to the access-group section of the docs and the deso-js SDK: "Removing a member does not prevent them from decrypting future messages. To revoke decryption ability, create a new access group with a different `AccessGroupKeyName` and migrate remaining members." Apps can surface this in their UI.
**2. Convention for app-level rotation.** Establish a convention where `AccessGroupKeyName` includes a versioned suffix (e.g., `myGroup-v2`), and removal triggers app-side migration to the next version. Painful UX but possible without protocol changes. Would benefit from a helper in `deso-js`.
**3. Optional access control on `GetPaginatedMessagesForGroupChatThread`.** Allow an opt-in mode where the endpoint requires a signed request from a current `AccessGroupMember`. Doesn't help against self-hosted nodes (the data is still on-chain) but materially raises the bar for casual abuse. Would need careful thought about how it interacts with backup/restore and cross-app reads.
**4. Protocol-level rekey transaction.** A new transaction type that updates the access-group's underlying keypair (decoupling group identity from key derivation), with re-encrypted key copies for current members. This is the only option that delivers the user expectation faithfully, but it's a significant protocol surface change.
I'd love to know which (if any) of these the team thinks is on the table, and whether there's existing thinking I missed.
## Environment
- DeSo backend: `main` as of 2026-04-16
- deso-js: latest
- Reproduced against `node.deso.org` (public node)
- Originally surfaced via `deso-chat` and `getchaton.com` source review
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with backend/routes/new_message.go:689, GetPaginatedMessagesForGroupChatThread, removeAccessGroupMembers, and identity.accessGroupStandardDerivation. Trace the UtxoView membership lookup and key lifecycle, then review the four mitigation options in the issue. This needs a maintainer decision on the intended protocol or documentation change before implementation scope and completion criteria can be defined.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, typescript
- Domain
- backend-api-design, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100