KSecureMessage prekey publication¶
This document describes how a client publishes its public prekeys, how the server stores them and how it hands them out for first contacts. It also defines the HTTP API v1.
Contracts: storage/core/.../storage/ServerStorage.kt (PreKeyRepository).
Server: server/core/.../server/PreKeyService.kt. Client:
SecureMessageClient.publishPreKeys(). HTTP: server/ktor/.../KSecureMessageRoutes.kt
and client/ktor/.../KtorSecureMessageTransport.kt.
Trust boundary¶
The client owns all private key material. The server receives only public material:
| Sent to the server | Never sent |
|---|---|
| identity public key | identity private key |
| current signed prekey: ID, public key, signature | signed prekey private keys |
| one-time prekeys: ID, public key | one-time prekey private keys |
shared secrets, SecureSession / ratchet state, plaintext |
The API has no field for anything in the right column.
The server is not a trusted cryptographic endpoint. It does not verify the
signed prekey signature, because it could not protect clients by doing so. The
initiator's ProtocolEngine verifies the signature and key sizes before X3DH,
whatever server the bundle came from.
Lifecycle¶
client.initialize() // local only: identity, signed prekey, one-time prekeys
client.registerDevice() // once: registers the device authentication key (milestone 12)
client.publishPreKeys() // upload the public halves, signed
client.rotateSignedPreKey()// local only
client.publishPreKeys() // make the new signed prekey current on the server
initialize() and rotateSignedPreKey() never do network I/O. Publishing is
always an explicit call. Since milestone 7, initialize() may also rotate the
signed prekey by age, so call publishPreKeys() after it. Replaced signed
prekeys stay on the device for their grace period and are never uploaded
(signed-prekey-lifecycle.md).
publishPreKeys() reads the identity key, the current signed prekey and the
public halves of all local one-time prekeys in one ClientStorage.transaction.
It then uploads them after that transaction. More than
PreKeyFormat.MAX_ONE_TIME_PRE_KEYS_PER_PUBLICATION (1000) one-time prekeys
go out as several publications, each carrying the same identity key and
signed prekey.
What the server stores¶
The server keeps state per DeviceAddress (user + device). Two devices of the
same user are fully independent.
- Identity key: set by the first publication.
- Current signed prekey: ID, public key and signature. Older signed prekeys are not kept.
- Available one-time prekeys: public key by ID.
- Consumed one-time prekey IDs (tombstones): IDs that were handed out once. They are never handed out again.
Publication rules¶
The server treats a publication as untrusted input. PreKeyService checks the
format first (PreKeyFormat.validate):
- key sizes: 64 bytes, and 64 bytes for the signature
- no repeated one-time prekey ID within the publication
- at most 1000 one-time prekeys
IDs are non-negative by type. Then the repository applies these rules:
| Case | Result |
|---|---|
| first publication for the address | stores identity key, signed prekey, one-time prekeys |
| different identity key than stored | IdentityKeyConflict, nothing changes |
| signed prekey ID higher than current | replaces the current one (rotation) |
| same signed prekey ID, same key and signature | no change |
| same signed prekey ID, other key or signature | SignedPreKeyConflict, nothing changes |
| signed prekey ID lower than current | SignedPreKeyConflict (stale retry after rotation), nothing changes |
| one-time prekey ID never seen | added |
| one-time prekey ID available, same key | no change (retry) |
| one-time prekey ID available, other key | OneTimePreKeyConflict, nothing changes |
| one-time prekey ID already consumed | skipped silently, even with other bytes |
A publication is atomic. The repository checks every rule before it changes anything, so a rejected publication leaves no partial state. For example, one conflicting one-time prekey among many also discards the signed prekey update.
Retries¶
The same publication sent twice has the same effect as sent once. After a lost response, the client simply publishes again. Nothing is duplicated, nothing fails because it already exists, and the identity and signed prekey stay the same. Idempotency comes from the rules above, not from exactly-once networking. The client keeps no record of what it has published: it always re-sends its whole local inventory.
Bundle consumption¶
consumePreKeyBundle(address) does the following in one atomic step (one lock
hold in the in-memory repository):
- Finds the device, or returns
null(HTTP 404). - Selects the available one-time prekey with the lowest ID.
- Removes it from the inventory and records its ID as consumed.
- Returns identity key + current signed prekey + that one-time prekey.
Concurrent fetches never get the same one-time prekey. An empty inventory is not an error: the bundle then has no one-time prekey, and X3DH runs without one. Selection order is deterministic on purpose. The security property is single use, not random order.
Public vs private one-time prekey lifecycle¶
These are two separate events:
- Server: hands out public one-time prekey #42 → removes it and tombstones #42.
- Recipient client: accepts the
PreKeyMessagethat used #42 → deletes private #42 in the same transaction that stores the new session.
The recipient keeps private #42 until step 2. It needs it to decrypt the first
message. During that time the recipient's next publishPreKeys() still
uploads public #42. The tombstone makes the server skip it, so #42 is never
handed out a second time.
If the initiator never sends its first message, or the message is lost, the recipient keeps private #42 indefinitely. No timeout or reconciliation exists yet.
HTTP API v1¶
Since milestone 12, prekey publication and mailbox drain are authenticated
with the device authentication key, and devices register that key first
(PUT .../registration). Headers, signed request format, status codes and
the endpoint policy are in server-authentication.md.
This changed the behavior of API v1 on purpose; the paths stayed the same.
JSON bodies. Binary fields are Base64 strings, RFC 4648 standard alphabet with padding. Decoded sizes are validated. Unknown JSON fields are rejected. Path segments are URL-encoded user and device IDs.
| Method | Path | Body | Success |
|---|---|---|---|
PUT |
/v1/devices/{user}/{device}/registration |
{"publicKey": "<base64, 32 bytes>"}, signed |
201 Created / 204 No Content |
PUT |
/v1/devices/{user}/{device}/prekeys |
PreKeyPublicationRequest, signed |
204 No Content |
GET |
/v1/devices/{user}/{device}/prekey-bundle |
– | 200 PreKeyBundleResponse |
POST |
/v1/messages |
EncryptedEnvelope |
202 Accepted |
GET |
/v1/devices/{user}/{device}/messages |
–, signed | 200 list of EncryptedEnvelope |
// PreKeyPublicationRequest (the address comes from the path)
{
"identityKey": "<base64, 64 bytes>",
"signedPreKey": { "id": 5, "publicKey": "<base64, 64 bytes>", "signature": "<base64, 64 bytes>" },
"oneTimePreKeys": [ { "id": 10, "publicKey": "<base64, 64 bytes>" } ]
}
// PreKeyBundleResponse; oneTimePreKey is null (or absent) when none is left
{
"identityKey": "<base64>",
"signedPreKey": { "id": 5, "publicKey": "<base64>", "signature": "<base64>" },
"oneTimePreKey": { "id": 10, "publicKey": "<base64>" }
}
// Error body of 4xx responses
{ "error": "invalid_publication" }
| Status | error |
Cause |
|---|---|---|
| 400 | invalid_publication |
malformed JSON, unknown field, bad Base64, negative ID, wrong size, repeated ID, too many keys |
| 404 | device_not_found |
bundle fetch for a device that never published |
| 409 | identity_key_conflict |
different identity key |
| 409 | signed_pre_key_conflict |
same signed prekey ID with other bytes, or an older ID |
| 409 | one_time_pre_key_conflict |
available one-time prekey ID with another key |
| 401 | missing_authentication, invalid_authentication, expired_authentication, authentication_replay, device_not_registered |
authentication failed; checked before the body is parsed (server-authentication.md) |
KtorSecureMessageTransport maps these statuses to
SecureMessageTransportException: DeviceNotFound,
PublicationRejected(reason) and UnexpectedResponse(status). The bundle it
returns carries the requested address; the response has none.
The DTOs exist twice, in client:ktor and server:ktor.
server/ktor/src/test runs the real client adapter against the routes to keep
them in sync. This HTTP API is a transport API and may evolve. It is separate
from the frozen binary ciphertext wire format (docs/wire-format.md), which
stays inside EncryptedEnvelope.payload.
Known limitations¶
- Host-authorized registration. Since milestone 12 only the registered device can publish for its address, and since S1 an address is registered only with the host application's authorization (server-authentication.md). Bundle fetches stay public, so anyone can still drain a device's one-time prekeys by fetching bundles.
- No device reset or re-registration. A device that lost its identity
cannot publish a new one for the same address (
409 identity_key_conflict). - Server identity checks are not trust. The server's identity-key check is repository consistency only. Clients pin remote identity keys themselves (TOFU, see identity-trust.md) and do not rely on the server for identity continuity.
- Tombstones are never pruned. Consumed IDs accumulate, one per handed-out one-time prekey.
- Consumed tombstones hide conflicts. A re-upload of a consumed ID is skipped without comparing bytes.
- Skipped uploads are not reported. The publication does not tell the client which IDs it skipped as consumed, so a client whose IDs were consumed by someone else (for example an attacker who controlled the address before S1) may believe its one-time prekeys are available while the server holds none; new sessions then run without one. This is the accepted residual N9 (security-review-remediation.md).
- No background sync. The client re-sends its whole inventory on each
publishPreKeys(); it neither tracks what the server has nor refills automatically. - Server storage (written at milestone 4): persistent server storage
was added in milestone 13 (
storage:server:sqldelight, see server-storage.md). - Envelopes in the
/v1/messagesroutes still use kotlinx.serialization's defaultByteArrayJSON encoding (array of numbers).