Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
40623d2
rfc: agent support for PRC pattern without duplex connection
evgeny-simplex Jul 11, 2026
97e7e9b
update rfc, plan
evgeny-simplex Jul 12, 2026
5633bbf
corrections
epoberezkin Jul 12, 2026
d722663
split rfcs, add plan
evgeny-simplex Jul 13, 2026
3235199
update rfc
evgeny-simplex Jul 13, 2026
88ab412
update rfc and plan
evgeny-simplex Jul 13, 2026
af99df7
update plan
evgeny-simplex Jul 14, 2026
1fe6b20
add double ratchet keys to address
evgeny-simplex Jul 14, 2026
ac1a1dc
Merge branch 'master' into ep/rpc
epoberezkin Jul 14, 2026
8309f95
agent schema
epoberezkin Jul 14, 2026
18d8b19
fix version in test
epoberezkin Jul 14, 2026
b09e7c0
change ContactRequest encoding
evgeny-simplex Jul 14, 2026
3eb0426
fix tests
evgeny-simplex Jul 14, 2026
8b95b5a
update plan
evgeny-simplex Jul 14, 2026
406bdb5
tests, refactor
evgeny-simplex Jul 14, 2026
975b58e
fix compilation
epoberezkin Jul 14, 2026
2413dbe
refactor
evgeny-simplex Jul 14, 2026
c19d8a7
refactor more
evgeny-simplex Jul 14, 2026
ab61864
fix
evgeny-simplex Jul 14, 2026
c945140
add export
epoberezkin Jul 14, 2026
45ee5df
fix
evgeny-simplex Jul 14, 2026
6620ab0
refactor
evgeny-simplex Jul 15, 2026
06c8261
refactor
evgeny-simplex Jul 15, 2026
6bcae59
update schema
epoberezkin Jul 15, 2026
19f1567
rename
epoberezkin Jul 15, 2026
91a28ec
refactor
evgeny-simplex Jul 16, 2026
e50ae44
refactor 2
evgeny-simplex Jul 16, 2026
3159eaa
comment, type
epoberezkin Jul 16, 2026
2a98035
type
epoberezkin Jul 16, 2026
c636ca2
rename
evgeny-simplex Jul 16, 2026
d222394
refactor more
evgeny-simplex Jul 16, 2026
565a64d
move type
epoberezkin Jul 16, 2026
cbc8bb4
refactor
evgeny-simplex Jul 17, 2026
fa256bd
remove comment
epoberezkin Jul 17, 2026
564e93e
diff
epoberezkin Jul 17, 2026
95de67a
refactor
evgeny-simplex Jul 17, 2026
7bc3523
comment
epoberezkin Jul 17, 2026
7b98f39
puns
epoberezkin Jul 17, 2026
1894038
get correct key
evgeny-simplex Jul 17, 2026
50e681e
rename
epoberezkin Jul 17, 2026
45c3192
add autoincrement
evgeny-simplex Jul 17, 2026
559c9ad
refactor
epoberezkin Jul 17, 2026
d8fb2c6
move
evgeny-simplex Jul 17, 2026
20a6229
move back
epoberezkin Jul 17, 2026
d4224ea
simplify
epoberezkin Jul 17, 2026
6456664
rename
epoberezkin Jul 17, 2026
ec6959a
refactor
evgeny-simplex Jul 17, 2026
76f7b03
tuple
epoberezkin Jul 17, 2026
cf3ef76
comment
epoberezkin Jul 17, 2026
bb6e375
split decryption
evgeny-simplex Jul 17, 2026
ce48e6b
refactor
epoberezkin Jul 17, 2026
79a4683
rename
evgeny-simplex Jul 17, 2026
69f7c3f
refactor
evgeny-simplex Jul 17, 2026
1085b64
move
epoberezkin Jul 17, 2026
64b3701
remove createSndQueue
evgeny-simplex Jul 17, 2026
dea95d9
remove comment
epoberezkin Jul 17, 2026
6a4428d
remove JoinInvitationReq
evgeny-simplex Jul 18, 2026
08e726c
simplify
epoberezkin Jul 18, 2026
9bb9ce5
rename
epoberezkin Jul 18, 2026
64dad46
fix
epoberezkin Jul 18, 2026
7b7042a
type synonim
evgeny-simplex Jul 18, 2026
c815e7f
getConnData
evgeny-simplex Jul 18, 2026
f77d0fb
simplify
epoberezkin Jul 18, 2026
6bf9593
move
epoberezkin Jul 18, 2026
79fd9b2
encoding
evgeny-simplex Jul 18, 2026
0aec792
version
epoberezkin Jul 18, 2026
b5a4c70
async join with ratchet keys
evgeny-simplex Jul 18, 2026
3a2d673
fix encoding
epoberezkin Jul 18, 2026
fb08d40
rotate address ratchet keys
evgeny-simplex Jul 19, 2026
e7d0a73
rename, parameter order
epoberezkin Jul 19, 2026
068d7d9
refactor, include ratchet keys into ConnectionRequestUri
evgeny-simplex Jul 19, 2026
6913d6d
simplify
epoberezkin Jul 19, 2026
6060be4
remove records
evgeny-simplex Jul 19, 2026
125b85a
Merge branch 'master' into ep/rpc
epoberezkin Jul 19, 2026
14ed679
refactor
epoberezkin Jul 19, 2026
7e6b4fb
rename, encoding
epoberezkin Jul 19, 2026
f8a117b
clean up
evgeny-simplex Jul 19, 2026
151deac
refactor
epoberezkin Jul 19, 2026
dc1449f
compatible
epoberezkin Jul 19, 2026
38093bb
addrKeysE2EVersion
epoberezkin Jul 19, 2026
75dc1ce
support IKUsePQ for contact addresses
evgeny-simplex Jul 20, 2026
8d847ff
comments, refactor condition
epoberezkin Jul 20, 2026
699694d
typos
epoberezkin Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions plans/2026-07-11-service-rpc-implementation.md

Large diffs are not rendered by default.

279 changes: 279 additions & 0 deletions plans/2026-07-12-address-dr-implementation.md

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions rfcs/2026-07-11-service-rpc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
Proposed: 2026-07-11
Protocol: agent-protocol (new version)
Depends on: 2026-07-12-address-pqdr-keys
---

# One-off requests to service addresses

Implementation plan: to follow, after this and the address-DR RFC are reviewed.

## Problem

Client applications need to interact with services, for example: badge issuance, directory requests, telemetry submissions, blockchain reads and writes, LLM calls. The only communication primitive available today is a duplex connection, so each of these interactions requires the full connection procedure - creating queues, key agreement, double ratchet initialization - and leaves persistent state on both sides: queues, ratchet state, connection records, message history.

This is the wrong primitive for most service interactions:

1. Cost. To send the first request to a not yet connected service, the client and the service exchange multiple commands across two servers. For "search the directory" all of it is overhead. The setup cost also creates an incentive to keep connections open, and a service with N users who used it once permanently holds N sets of queues and ratchet states.

2. Privacy. A connection is a stable pairwise pseudonym. If a service were to use a duplex connection, it could link all requests made over it into a profile: search history in the directory, blockchain operations linked even when different on-chain keys are used, telemetry that becomes longitudinal tracking. The client also accumulates history that can be recovered from the device. Where continuity is needed, it can be provided in the application protocol (e.g., a token included in requests), without a transport-level identity.

3. Encryption. Messages sent to contact addresses outside an established connection have a single layer of X25519 encryption, with no post-quantum protection and no forward secrecy. This is not acceptable for service requests.

In-app service addresses should be stored as names resolving to links via the existing addressing layer (server host in the link authority, current link data retrieved with `LGET`), so that service links can be changed without redeploying the apps. Name resolution is already supported, and out of scope.

## Security objectives

1. Requests from the same client must not be linkable to each other by the service or by servers, and no long term state is created on either side in the transport layer.
2. Post-quantum resistant end-to-end encryption of requests and replies.
3. Reply authenticity must be verifiable against the link; substitution, replay, dropping or reordering of replies by servers must be detectable.
4. A repeated request for the same operation must not be executed twice.

## Solution

A service address is an ordinary short-link contact address. The client sends one request to the address queue and receives replies in a reply queue it creates for the request. The double ratchet is established from the address's published keys (see the address-DR RFC): the request is the first ratchet message, and replies are subsequent ratchet messages. So a request-response exchange is a short-lived one-directional double ratchet connection, established from the first message and removed after the last.

The exchange:

1. Retrieve the address link data (`LGET`, via proxy when IP protection is needed): the root key, the identity key, the prekey with its id, and the KEM key.
2. Create a reply queue (`NEW`, subscribed).
3. Establish the sending ratchet from the published keys (`pqX3dhSnd`, `initSndRatchet`). Build the request: the reply queue, the requester's X3DH parameters, and the payload encrypted under the ratchet. Encrypt the whole request to the address queue and send it once (unauthenticated `SEND`, via proxy when IP protection is needed). There is no transport retry; a reply is the success signal, and a hard error fails the request.
4. The service establishes the receiving ratchet from its private keys and the request's X3DH parameters (`pqX3dhRcv`, `initRcvRatchet`), decrypts the payload, and delivers it to the service application. To reply it creates a send connection with the ratchet and sends reply messages to the reply queue, each encrypted under the ratchet.
5. The client decrypts and delivers each reply message to the application. The first reply message returns from the request; later reply messages are delivered through a callback the application registered. The exchange ends on a reply marked final. The client deletes the reply queue and the ratchet on the final message, the deadline, or when the application cancels.

How this meets the objectives:

1. Unlinkability: fresh X3DH keys and a fresh reply queue per request; the sender's IP address and session are protected by existing private routing; the reply queue and both ratchets are removed after the exchange; nothing is shared between two requests.
2. Encryption: the double ratchet with its sntrup761 KEM, from the first message.
3. Authenticity: the ratchet is established against the identity key committed by the link hash, so a decryptable reply proves it came from the address owner; the ratchet message numbering detects dropped and reordered replies. No separate signature is needed.
4. Single execution: the service identifies a request by the hash of its decrypted payload and, within a fixed retention period, re-sends the stored replies for a repeated request without running the operation again.

## Design

Syntax uses [ABNF][1] with [case-sensitive strings extension][2]. `SMPQueueInfo`, `agentVersion`, and the X3DH and ratchet types are as in the [agent protocol](../protocol/agent-protocol.md) and `Crypto.Ratchet`.

### Correlation and chat

A reply is connected to its request by the reply queue: each request has its own reply queue, and every message in that queue is a reply to that request. The request hash (of the decrypted payload) is used only for idempotency. The application sets an id inside the payload to make two requests the same operation or different ones; the agent does not read it.

Both ends are chat bots on the chat library over the agent. The chat library serializes a service command into the request payload and deserializes the responses; the agent transports them, establishes and removes the ratchet, and correlates by reply queue.

### Request

A new agent envelope, shaped like `AgentConfirmation` (X3DH parameters to establish the ratchet, plus a body encrypted under it):

```abnf
agentRequest = agentVersion %s"Q" replyQueues prekeyId sndE2EParams encRequest
replyQueues = length 1*SMPQueueInfo ; the first is used; more are for redundancy
prekeyId = shortString ; the published prekey used, from link data
sndE2EParams = <requester Snd X3DH parameters, see address-DR RFC>
encRequest = <ratchet-encrypted request payload>
```

The whole `agentRequest` is encrypted to the address queue with the per-queue layer and sent with `SEND`, as an invitation is today. The reply queue keys and the X3DH parameters are not visible to servers. A request must fit one message; a larger payload is an XFTP file description in the payload.

The request hash is the SHA3-256 of the decrypted payload - the same bytes on both sides. There is no transport retry; a hard error (`AUTH`, `QUOTA`) fails the request, and the application decides whether to send a new one.

### Replies

The service creates a send connection with the ratchet and sends reply messages to the reply queue, each encrypted under the ratchet. A reply message uses a new agent envelope:

```abnf
agentResponse = agentVersion %s"P" final responses
final = %s"T" / %s"F" ; T - no more reply messages follow
responses = length 1*responseItem ; non-empty list of application responses
responseItem = largeString ; opaque application response
```

Each message includes a list of responses, so responses known together are sent in one message and responses that become known over time are sent in separate messages. The message is encrypted and numbered by the ratchet, which authenticates it against the committed identity key and detects dropped or reordered messages; no separate signature is used.

The first reply message returns from the request. Later reply messages are delivered through the callback the application registered with the request, while the process runs. The exchange ends on a message with `final = T`. The client deletes the reply queue and the ratchet on that message, on the deadline, or when the application cancels. Deleting the reply queue stops further replies. The transport keeps no exchange across a client restart; the application keeps its own state and sends a new request when it needs to.

### Rejection

The service refuses a request with the `AgentRejection` envelope from the [communicating rejection RFC](../../simplex-chat/docs/rfcs/2024-03-22-communicating-reject.md), sent to the reply queue under the ratchet, with an opaque application reason. The same envelope communicates refusal of a connection request, where today it is dropped silently. A rejection ends the exchange like a final reply.

### Idempotency

The service keeps, for a fixed retention period it chooses (1 to 24 hours, in service configuration, not in link data), the request hash, the ordered response messages it produced, and the reply queues and ratchets subscribed under that hash. A repeat request with the same hash does not reach the service application:

- while the first request is being answered, the repeat establishes its own ratchet and reply queue, is added to the record, receives the responses already produced, and receives each later response too.
- after the operation completed, the repeat receives the whole stored sequence of responses, re-encrypted under its own ratchet.

The stored responses are the application response bytes, not the ratchet ciphertext, because a repeat establishes a new ratchet and the responses are re-encrypted for it. This gives single execution over at-least-once delivery. After the retention period a request with the same hash is a new operation and runs again.

### Out of scope

- Recovery across restart: the transport keeps no exchange across a client restart; the application persists its own state and sends a new request when it needs to.
- Service-initiated messages: there is no standing channel; use a connection where the service must reach the client without a request.
- Abuse protection beyond existing queue quotas: services can require application-level credentials (e.g., a badge) in the request payload; rate limiting is a separate discussion.
- Scaling request reception: a single address queue bounds service throughput; distributing reception across multiple queues or relays (the existing `relays` field in contact link data) is a separate question, but it would fit well with name resolving to multiple addresses, both for redundancy, reliability and higher throughput.
- Name resolution: existing addressing layer.

[1]: https://tools.ietf.org/html/rfc5234
[2]: https://tools.ietf.org/html/rfc7405
83 changes: 83 additions & 0 deletions rfcs/2026-07-12-address-pqdr-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
Proposed: 2026-07-12
Protocol: agent-protocol (new version)
---

# Establishing the double ratchet from address data

## Problem

A contact address receives a connection request as an `AgentInvitation` message, encrypted only with the per-queue X25519 layer. The double ratchet is established later: the address owner joins the requester's connection request, generates its X3DH keys, and sends them in the confirmation. So the first message to an address - the invitation and the profile in it - is not under the double ratchet, and has no post-quantum protection.

The cause is that the address owner's X3DH contribution is generated per request and sent in the confirmation, so it cannot exist before the requester's first message.

## Solution

Publish the address owner's X3DH contribution in the address link data, so a requester can establish the double ratchet in its first message. The requester runs the existing `pqX3dhSnd` against the published keys, initializes a sending ratchet, and encrypts its first message under it. The owner runs the existing `pqX3dhRcv` against its stored private keys and the requester's X3DH keys from the message, initializes a receiving ratchet, and decrypts it.

Publish the owner's X3DH contribution - two X448 keys and an optional sntrup761 KEM key, with the e2e version range - as one bundle in the mutable contact user data, signed by the root key. The bundle is the existing `RcvE2ERatchetParamsUri` type that a one-time invitation already advertises, with an id for rotation.

This is backward compatible. A requester that does not use the published bundle sends a current `AgentInvitation` with its own X3DH keys, and the owner does what it does today: generates fresh X3DH keys and sends them in the confirmation. The owner branches on whether the incoming message uses the published bundle.

Three properties follow. The first message, including the profile, is under the double ratchet, which closes the profile gap and gives it post-quantum protection through the ratchet's sntrup761 KEM. A decryptable message proves the sender established X3DH against the root-signed keys, so it authenticates the address owner without a separate signature. And because the bundle is in mutable data, an existing address can advertise the double ratchet by updating its mutable data - no new link.

## Design

Syntax uses [ABNF][1] with [case-sensitive strings extension][2]. Key and ratchet types are as in `Crypto.Ratchet` and the [agent protocol](../protocol/agent-protocol.md); all DH keys are X448, the KEM is sntrup761.

### Published keys in link data

The owner's X3DH contribution is a ratchet-keys bundle appended to the mutable contact user data, signed by the root key. Nothing is added to the immutable fixed link data:

```abnf
userContactData =/ ratchetKeys ; appended, ignored by earlier versions
ratchetKeys = %s"0" / (%s"1" ratchetKeyId e2eParams)
ratchetKeyId = shortString ; identifies this bundle, changes on rotation, echoed in the request
e2eParams = <RcvE2ERatchetParamsUri: e2e version range, two X448 keys, optional sntrup761 key>
```

`e2eParams` is the existing `RcvE2ERatchetParamsUri` - the same type a one-time invitation advertises - so a requester reads it, negotiates the concrete e2e version against its own range, and runs `pqX3dhSnd` against it, exactly as it does for an invitation. The KEM key is optional (the params' KEM field is a `Maybe`), controlled by the address's initial-keys mode, the same 3-way choice as invitations: advertise the KEM (post-quantum from the first message), advertise X448-only but still support post-quantum if the requester proposes its own KEM (one message later, avoiding the ~1158-byte key in link data), or no post-quantum.

The owner keeps the private side of each bundle - the two X448 private keys and, when PQ is on, the KEM keypair - indexed by `ratchetKeyId`. On rotation with `LSET` it publishes a new bundle with a new `ratchetKeyId` and keeps the previous private keys for a window covering queue message retention, so a request that used a just-rotated bundle still decrypts. Because the bundle is in mutable data, an existing address advertises the double ratchet by updating its mutable data - no new link, no re-creation.

### Request confirmation

A requester that uses the published keys establishes the sending ratchet before its first message, so it sends that message as a confirmation, not an invitation - the same envelope a joining party sends in a connection. The confirmation gains an optional `ratchetKeyId` naming the bundle the requester used, so the owner selects the matching private keys:

```abnf
agentConfirmation =/ ratchetKeyId ; the bundle the requester used, echoed; absent on other confirmations
```

The confirmation holds the requester's Snd X3DH parameters (so the owner runs `pqX3dhRcv`) and, encrypted under the ratchet, the first message. A confirmation with `ratchetKeyId` on a contact address takes the published-key path; an `AgentInvitation`, as today, takes the current path where the owner generates fresh X3DH keys and returns them in its own confirmation. A connection-request URI is unchanged: it advertises the requester's Rcv parameters and must not include Snd parameters.

### Establishing the ratchet

Requester:

1. Retrieve link data (`LGET`), read the bundle - its `ratchetKeyId` and `e2eParams` (`RcvE2ERatchetParamsUri`) - and negotiate the concrete e2e version against its own range.
2. `generateSndE2EParams` for its own X3DH contribution - encapsulating to the bundle's KEM if it advertises one, or proposing its own KEM if the requester wants post-quantum and the bundle is X448-only.
3. `pqX3dhSnd` against the bundle's parameters, then `initSndRatchet` - the sending ratchet.
4. Encrypt the first message under the ratchet, and send a confirmation with its Snd parameters and `ratchetKeyId`.

Owner:

1. On a confirmation with `ratchetKeyId` on a contact address, select the private X3DH keys and, if any, KEM keypair by `ratchetKeyId` (current or a retained previous generation).
2. `pqX3dhRcv` against the requester's Snd parameters with those private keys, then `initRcvRatchet` - the receiving ratchet. Decrypting the first message advances the ratchet and gives it a send side, so the owner can reply.
3. Decrypt, and reply under the ratchet.

A request whose `ratchetKeyId` is no longer retained cannot be decrypted; the owner does not learn the requester or its reply address, and the requester's attempt fails at its own timeout.

### Authentication

The ratchet-keys bundle is in the mutable link data, signed by the root key. A decryptable message proves the sender established X3DH against those root-signed keys: an SMP server cannot substitute them without forging the root signature (`decryptLinkData` verifies it), which is the X3DH anti-substitution property. Where a message today relies on a separate signature over its content for authenticity, this establishment provides it, and the signature is not needed. The root Ed25519 key is the address's signing identity; the X3DH keys are separate DH keys (X448), and X3DH is over crypto_box, so deniability is preserved. A malicious server can still serve an older but validly-signed bundle (rollback to a retired generation); this is bounded by the retention window and by the ratchet advancing after the first message, and a per-key signature would not prevent it. Reusing a bundle across requesters is consistent with the address already being a shared identifier.

## Uses

- Invitations to an address, and the profile in them, are under the double ratchet from the first message.
- An existing address gains the double ratchet when the app updates its mutable link data (`LSET`, with the user's confirmation and current profile, combined with the full→short address migration); no new link is issued.
- The service RPC (see the RPC RFC) establishes the ratchet this way to send the request as the first ratchet message.

This RFC depends on nothing else here. It replaces the need for the PQ-queue RFC in the address case, because the ratchet provides post-quantum protection for the first message; the PQ-queue RFC remains for first messages that do not establish a ratchet.

[1]: https://tools.ietf.org/html/rfc5234
[2]: https://tools.ietf.org/html/rfc7405
Loading
Loading