KSecureMessage client storage and local key lifecycle¶
This document describes how a KSecureMessage client keeps its local protocol
state: the identity key, prekeys and sessions, and since milestone 8 its
message reliability state. It also defines what a
ClientStorage implementation must guarantee.
Contracts: storage/core/.../storage/ClientStorage.kt.
Lifecycle: client/core/.../client/SecureMessageClient.kt and PreKeyManager.kt.
Sensitive data at rest¶
Client storage holds secret key material (the identity private key, signed
and one-time prekey private keys), session state (SecureSession.state,
which contains the ratchet chain keys) and, since milestone 8, application
message content: the plaintext of every sent message stays in
PendingOutboundStore until the recipient acknowledges it
(message-reliability.md), and since milestone 20
the plaintext of every received message stays in PendingInboundStore until
the application commits it (application-delivery.md)
or, since milestone 21, discards it (message-discard.md);
a discard deletes the plaintext. ProcessedInboundStore holds the terminal
tombstones: sender addresses, logical message IDs, finalization times and,
since milestone 21, the outcome (committed or discarded) and discard reason
(metadata), and, since milestone 20, a digest of each finalized body (a
content fingerprint, so it is sealed too).
Since milestone 9 SqlDelightClientStorage encrypts these records with
AES-256-GCM under a storage key from an application-supplied
StorageKeyProvider, bound to record type and row key
(storage-encryption.md). This is record-level
encryption, not database encryption: IDs, addresses, timestamps, pins,
retired initiations and processed message IDs stay plaintext metadata, and
row deletion or rollback of the whole database is not detected. The
protection is only as good as the key storage. InMemoryClientStorage does
not encrypt (it persists nothing).
Key bytes, session state and message plaintext never appear in logs,
exception messages or toString().
Client lifecycle¶
val client = SecureMessageClient(
localAddress = address,
storage = storage, // InMemoryClientStorage, SqlDelightClientStorage, ...
protocol = KodiumProtocolEngine(),
transport = transport,
)
client.initialize() // on every start
val bundle = client.currentPreKeyBundle()
val oneTimePreKeys = client.publicOneTimePreKeys()
initialize() runs one storage transaction:
- Load the local identity.
- If it exists, keep it.
- If it is missing and no prekey state exists, create it (
ProtocolEngine.createIdentity) and store it. - If it is missing but prekeys exist, fail with
SecureMessageClientException.InconsistentStorage. A new identity would not match the stored prekeys, so the client never creates one silently. - Signed prekey maintenance (milestone 7, see
signed-prekey-lifecycle.md): stamp keys stored
before milestone 7 with the current time, create a current signed prekey if
there is none or rotate it once it reached
PreKeyConfiguration.signedPreKeyRotationAge(default 7 days), delete replaced keys whosesignedPreKeyGracePeriod(default 30 days) is over, and prune retired session initiations that only those keys could accept. New signed prekeys are signed by the identity. - If fewer than
PreKeyConfiguration.oneTimePreKeyTargetone-time prekeys (default 100) are stored, create only the missing number.
initialize() is idempotent. A second call, or a call after an app restart
on the same storage, changes nothing unless one-time prekeys were consumed
or time moved past a rotation or expiry deadline. Sessions are never touched.
Time comes from the client's clock parameter (Clock.System by default).
Initialization is explicit. Other operations do not create state. Without
a stored identity they throw SecureMessageClientException.NotInitialized.
The client caches nothing, and every operation reads storage.
Key material is always created by the ProtocolEngine. Storage only persists
it.
Prekeys¶
Signed prekeys¶
The current signed prekey is the one to publish. rotateSignedPreKey(), or
initialize() once the current key reached the rotation age, creates a new
current signed prekey. The replaced one stays stored and available by ID for
the grace period. A PreKeyMessage created from an older bundle names the
old ID, and its private key is needed to accept it. After the grace period,
new initiations naming it fail with ExpiredSignedPreKey, and initialize()
deletes it. Established sessions are not affected. Each key stores
createdAt and, once replaced, replacedAt. See
signed-prekey-lifecycle.md.
One-time prekeys¶
A one-time prekey is deleted when a session is accepted with it. That
happens in the same transaction that stores the new session.
The next initialize() replaces it with a new key under a new ID.
ID allocation¶
SignedPreKeyId and OneTimePreKeyId are in 0..Int.MAX_VALUE. Each store
keeps a persisted high-water mark, the highest ID ever stored
(highestSignedPreKeyId, highestOneTimePreKeyId). Deleting keys does not
lower it. New IDs are allocated upward from highest + 1, starting at 0.
- IDs of consumed one-time prekeys and deleted signed prekeys are never reused.
- Stores reject a key whose ID is not above the high-water mark.
- There is no wraparound. When the next IDs would pass
Int.MAX_VALUE,initialize()orrotateSignedPreKey()fails withSecureMessageClientException.PreKeyIdsExhaustedand writes nothing.
Publication boundary¶
publishPreKeys() uploads this public material (see
prekey-publication.md). It reads it in one
transaction and does the network call afterwards:
currentPreKeyBundle(): the identity public key and the current signed prekey (ID, public key, signature).oneTimePreKeyis alwaysnull.publicOneTimePreKeys(): the public halves of all unused local one-time prekeys.
The client does not select, reserve or mark one-time prekeys as handed out. Which one a fetcher receives is a server concern. Locally a key is only removed when a session is accepted with it.
ClientStorage contract¶
interface ClientStorage {
val identity: IdentityStore
val remoteIdentities: RemoteIdentityStore
val sessions: SessionStore
val sessionInitiations: SessionInitiationStore
val preKeys: PreKeyStore
val pendingOutbound: PendingOutboundStore
val pendingInbound: PendingInboundStore
val processedInbound: ProcessedInboundStore
suspend fun <T> transaction(block: suspend ClientStorage.() -> T): T
}
Every implementation must provide:
- Atomic transactions. If
transaction's block throws, none of its writes become visible. Otherwise all of them do. This is what keeps the ratchet state consistent with sent and received messages. It also makes first contact safe: storing the session and removing the one-time prekey commit together or not at all. A failure never leaves a stored session with its one-time prekey still available, or a removed one-time prekey with no session. The first remote identity pin commits with the same transaction (see identity-trust.md). A replaced session, its retired initiation and the consumed one-time prekey commit together too (see session-lifecycle.md). An accepted identity change replaces the pin, removes the session and retires its initiation in one transaction (see identity-verification.md). - Nesting. A
transactioncall inside a running transaction of the same storage joins it, through the receiver or the storage object. - Single-call writes. Store calls outside
transactionbehave as single-call transactions. - No identity replacement.
IdentityStore.storefails if an identity exists. - No silent pin replacement.
RemoteIdentityStore.storeis a no-op for the key already pinned for an address and fails withIllegalStateExceptionfor a different key. Pins are perDeviceAddressand are never removed. New pins areUNVERIFIED.setVerificationandreplaceare compare-and-set on the pinned key;replace(only for an explicitly accepted identity change) makes the new pinUNVERIFIED. A verification never moves to another key. - Retired initiations are kept until pruned.
SessionInitiationStore.retireis idempotent (the first entry wins), and entries are perDeviceAddress. Each entry may name a local signed prekey. Entries are removed only byremoveRetiredFor(signedPreKeyId), which the client calls after that key was deleted. - Signed prekey lifecycle.
storeCurrentSignedPreKey(key, createdAt)sets the previous current key'sreplacedAttocreatedAtin the same call.signedPreKeyInfos()returns metadata without key material.removeSignedPreKeyrefuses the current key and keeps the high-water mark.stampLegacySignedPreKeysonly fills missing timestamps. - Pending outbound messages (milestone 8).
store(recipient, id, frame)fails withIllegalArgumentExceptionif (recipient, id) is pending and returns a sequence number that is higher than every earlier one, also after removals and restarts.page(afterSequence, limit, recipient?)returns at mostlimitentries with a greater sequence in ascending order, to one recipient if given, and opens only those records (milestone 22; it replaced the unboundedlist(recipient));count(recipient?)opens none.removedeletes exactly (recipient, id) without opening it and returns whether it removed something. The client stores a message in the transaction that first encrypts it and removes it in the transaction that decrypts its acknowledgement or in the abandon transaction (outbound-message-lifecycle.md). - Pending inbound messages (milestone 20).
storeassigns a sequence number that increases with every call and is never reused, also after removals and restarts; a secondstoreof the same (sender, ID) throwsIllegalArgumentException.list()andlist(sender)are ordered by sequence;containsnever opens a record. The client stores a message in the transaction that decrypts it and removes it only in the commit transaction. Nothing expires them. - Processed inbound messages (milestones 8 and 20). Scoped by sender.
markProcessed(sender, id, digest, committedAt)throws if the entry exists (never replaced).isProcessednever opens a record;getreturns the digest and commit time (nullfor entries from before milestone 20).stampLegacyCommitTimes(at)sets only missing commit times.pruneCommittedAtOrBefore(cutoff)removes entries with a commit time at or beforecutoff, never entries without one and never pending messages. The client writes the entry in the commit transaction, together with removing the pending inbound message. - Monotonic prekey IDs, as described above.
- No aliasing. Mutating an array after storing it, or an array returned by a load, must not change stored state.
Rules for transaction blocks:
- No network I/O or other long suspension. A transaction holds a lock, and
SecureMessageClientfetches prekey bundles before its transaction starts. - Do not switch threads or dispatchers. The SQLite drivers bind a transaction to the thread that started it.
storage:testing contains ClientStorageContractTest. Every adapter's tests
extend it.
Adapters¶
Client storage adapters live under storage:client:*, server repository
adapters under storage:server:*; the contracts they implement stay in
storage:core. storage:encryption, storage:keyprovider:* and
storage:rotation:core are storage security infrastructure; today they serve
only the persistent client adapter.
storage:client:inmemory¶
InMemoryClientStorage is not persistent. It is meant for tests and examples.
Its committed state is one immutable snapshot. A transaction works on a copy
and replaces the snapshot only when the block returns normally. A failed
block drops the copy. A Mutex serializes transactions. Byte arrays are
copied on store and on load.
storage:client:sqldelight¶
SqlDelightClientStorage persists to SQLite through SQLDelight 2.4.0 with
async code generation. It supports all client targets: JVM, Android, iOS,
macOS, linuxX64, mingwX64, JS and Wasm. The application creates the
platform driver and closes it:
| Platform | Driver artifact | Example |
|---|---|---|
| JVM | app.cash.sqldelight:sqlite-driver |
JdbcSqliteDriver("jdbc:sqlite:client.db", Properties(), SqlDelightClientStorage.Schema.synchronous()) |
| Android | app.cash.sqldelight:android-driver |
AndroidSqliteDriver(SqlDelightClientStorage.Schema.synchronous(), context, "client.db") |
| Apple, Linux, Windows | app.cash.sqldelight:native-driver |
NativeSqliteDriver(SqlDelightClientStorage.Schema.synchronous(), "client.db") |
| JS, Wasm | app.cash.sqldelight:web-worker-driver |
web worker driver, async schema |
Use one SqlDelightClientStorage instance per database.
Each transaction is a SQLite transaction (SuspendingTransacter.transactionWithResult).
If the block throws, SQLite rolls back. A Mutex serializes transactions of
the instance.
Tables:
storage_encryption: one row with the storage format and the bound storage key IDlocal_identity: one row, the sealed key pairdevice_authentication_key: one row, the sealed device authentication key pair (schema version 8, milestone 12, server-authentication.md)device_authentication_recovery_key: at most one row, the sealed pending replacement key of a device recovery (record type 8, schema version 9, milestone 14, device-recovery.md)device_authentication_rotation_key: at most one row, the sealed pending replacement key of a routine rotation (record type 9, schema version 11, milestone 16, device-authentication-rotation.md); never filled together with the recovery keydevice_authentication_last_device_recovery_key: at most one row, the sealed pending replacement key of a last-device recovery (record type 10, schema version 12, milestone 18, last-device-recovery.md); never filled together with the recovery or the rotation key. The offline recovery key is never storeddevice_authentication_state: one row,awaits_upgrade_key(plaintext flag: set by the version 8 migration when an identity existed without a key)signed_pre_key: sealed key pair, withcreated_atandreplaced_at(epoch milliseconds, nullable)one_time_pre_key: sealed key pairpre_key_state: one row holding the current signed prekey ID and both high-water markssession: sealed state, keyed by remote user and device IDremote_identity: pinned remote identity public keys, keyed by remote user and device ID, withverification(0 unverified, 1 verified; schema version 10)retired_session_initiation: retired 32-byte session initiation IDs, keyed by remote user, device ID and initiation ID, with the nullable localsigned_pre_key_idused for pruningpending_outbound_message:sequence INTEGER PRIMARY KEY AUTOINCREMENT(never reused), recipient user and device ID, 16-bytemessage_id, and the sealed frame (encrypted; the frame holds the application plaintext); unique per recipient and message ID; since schema version 15 indexed by (recipient,sequence) for recipient-filtered pagesprocessed_inbound_message: the terminal tombstone per sender user and device ID and 16-bytemessage_id; since schema version 13committed_at(the finalization time, commit or discard; epoch milliseconds, nullable for legacy rows, indexed) andsealed_digest(record type 12 for both outcomes,NULLfor legacy rows); since schema version 14finalization(0 committed, 1 discarded; CHECK, default 0) anddiscard_reason(1..5, set exactly for discarded rows, CHECK; plaintext metadata)pending_inbound_message(schema version 13, milestone 20):sequence INTEGER PRIMARY KEY AUTOINCREMENT(never reused), sender user and device ID, 16-bytemessage_id,received_at(epoch milliseconds) and the sealed frame (record type 11; holds the application plaintext); unique per sender and message ID; since schema version 14 indexed by (sender,sequence) for sender-filtered pages
Since schema version 6 the key pairs, session state and pending frames are
in sealed_* columns holding encrypted storage records, and
storage_encryption (one row) records the storage format and the bound
storage key ID (storage-encryption.md). The schema
does not depend on Kodium internals. All IDs have a
CHECK (id BETWEEN 0 AND 2147483647) constraint.
Open the storage with SqlDelightClientStorage.open(driver, keyProvider).
There is no unencrypted mode.
The schema version is 15. Version 1 (milestones 3 and 4) had no
remote_identity table; 1.sqm adds it and changes nothing else. Version 2
(milestone 5) had no retired_session_initiation table; 2.sqm adds it and
changes nothing else. Version 3 (milestone 6) had no lifecycle columns;
3.sqm adds signed_pre_key.created_at, signed_pre_key.replaced_at and
retired_session_initiation.signed_pre_key_id, all nullable, and changes
nothing else. Upgraded signed prekeys get their timestamps on the next
initialize(): the current key counts as created then, older keys start a
full grace period then, and nothing is deleted on that first start
(signed-prekey-lifecycle.md).
Version 4 (milestone 7) had no reliability tables; 4.sqm creates
pending_outbound_message and processed_inbound_message, empty, and
changes nothing else. Messages from before the upgrade are neither pending
nor processed. Version 5 (milestone 8) stored key pairs, session state and
pending frames in plaintext; 5.sqm only adds storage_encryption with
format 0, and the next SqlDelightClientStorage.open encrypts every
sensitive record in one transaction, rebuilding the five sensitive tables
(storage-encryption.md).
Plaintext that the old version wrote may survive in free pages, the journal
and backups. Version 6 (milestones 9 and 10) had no storage key rotation
state; 6.sqm adds it to storage_encryption (storage-key-rotation.md).
Version 7 (milestone 11) had no device authentication key; 7.sqm creates
device_authentication_key (empty) and device_authentication_state, whose
flag is 1 if the database held an identity. The migration creates no key
material; the next initialize() creates the key once, and a key that goes
missing later fails closed (server-authentication.md).
Version 8 (milestones 12 and 13) had no pending recovery key; 8.sqm creates
device_authentication_recovery_key, empty (device-recovery.md).
Version 9 (milestone 14) had no verification state; 9.sqm adds
remote_identity.verification with default 0, so every existing pin becomes
unverified and nothing else changes
(identity-verification.md).
Version 10 (milestone 15) had no pending rotation key; 10.sqm creates
device_authentication_rotation_key, empty, and changes no existing row
(device-authentication-rotation.md).
Version 11 (milestones 16 and 17) had no pending last-device recovery key;
11.sqm creates device_authentication_last_device_recovery_key, empty, and
changes no existing row (last-device-recovery.md).
Version 12 (milestones 18 and 19) had no application commit boundary;
12.sqm creates pending_inbound_message, empty, and adds the nullable
processed_inbound_message.committed_at and sealed_digest plus an index on
committed_at. Existing processed IDs are kept; the next initialize()
stamps their commit time once with the client clock, their digest stays
unknown (application-delivery.md).
Version 13 (milestone 20) had no discard; 13.sqm adds
processed_inbound_message.finalization (default 0, so every existing row is
committed) and discard_reason (NULL) and the pending sender index. No
existing value changes, digests and times included
(message-discard.md).
Version 14 (milestone 21) had no recipient index on pending outbound
messages; 14.sqm adds pending_outbound_message_recipient and changes no
row (outbound-message-lifecycle.md).
Frozen copies of versions 1–14 (Version1Schema … Version14Schema) back the
migration tests.
The offline recovery key lifecycle (milestone 19) and the delayed recovery
key reset (milestone 23, recovery-key-reset.md)
change no client schema: the client never stores the offline recovery key,
a replacement key or a pending reset; that state lives on the server only.
A driver created with SqlDelightClientStorage.Schema,
as in the table above, reads SQLite's user_version on open and runs the
migrations itself. An application that manages schema versions on its own
calls SqlDelightClientStorage.Schema.migrate(driver, oldVersion, SqlDelightClientStorage.Schema.version).
Existing identities, prekeys, sessions, pins and retired initiations are kept. Sessions from
version 1 have no pin, see
identity-trust.md. Session
BLOBs written before milestone 6 use the older local state format and stay
readable (see session-lifecycle.md).
The tests run on JVM (file database) and Apple/native targets. They cover the
contract, closing and reopening the database, on-disk rollback, a client
restart that continues an existing session, remote trust across restarts,
replay protection and simultaneous-initiation resolution across restarts, the
signed prekey lifecycle (grace, expiry, pruning, clock set back) across
restarts, pending messages, duplicate suppression and collision recovery
across restarts, and migrating version 1, 2, 3 and 4 databases. Since
milestone 9 they also check the persisted bytes: sensitive columns hold only
encrypted records, corrupted and swapped records fail closed, wrong and
missing keys fail at open, encryption failures roll back, and a milestone 8
database migrates completely or not at all.
JS/Wasm have no SQLDelight tests: the web worker driver needs a browser worker.
The linuxX64 and mingwX64 test binaries link against the target's libsqlite3,
so they are only linked and run on a Linux or Windows host.
Not covered yet¶
- Messaging identity recovery, reset or backup (a changed remote identity can be accepted, see identity-verification.md).
- Tracking locally what the server handed out (the server tombstones consumed one-time prekey IDs instead, see prekey-publication.md).
- Pruning of retired session initiations that name no local signed prekey (initiations this device started, entries from before milestone 7).
- A secure or monotonic clock for the signed prekey lifecycle.
- Automatic pruning of processed message IDs (only explicit
pruneProcessedMessages, see application-delivery.md), and any expiry of uncommitted pending inbound or unacknowledged pending outbound messages (only explicit discard and abandon). - Platform storage key providers, storage key rotation, rollback protection and integrity of plaintext metadata (storage-encryption.md).