branch: elpa/jabber
commit 73f2b47801881bd536d3a05f2799c4c09e92f6a5
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
omemo: Recover out-of-order messages via skipped ratchet keys
---
CHANGELOG.org | 3 +
doap.xml | 2 +-
lisp/jabber-omemo-store.el | 24 +++++
lisp/jabber-omemo.el | 111 ++++++++++++++-----
src/jabber-omemo-core.c | 206 ++++++++++++++++++++++++++++++++++--
tests/jabber-test-omemo-message.el | 4 +-
tests/jabber-test-omemo-module.el | 84 +++++++++++++++
tests/jabber-test-omemo-protocol.el | 65 +++++++++++-
8 files changed, 461 insertions(+), 38 deletions(-)
diff --git a/CHANGELOG.org b/CHANGELOG.org
index ea8f91af08..c355539017 100644
--- a/CHANGELOG.org
+++ b/CHANGELOG.org
@@ -4,6 +4,9 @@
** Added
- OMEMO signed pre-keys now rotate automatically on connect
(~jabber-omemo-signed-pre-key-rotation-period~, 7 days default)
+
+** Fixes
+- Out-of-order OMEMO messages now decrypt: skipped ratchet keys are kept per
session and persist across restarts
- Press RET on an image URL to load it inline; C-u RET still downloads it
- Size cap for downloaded inline images (~jabber-image-max-bytes~, 25 MB
default)
- Allowlist of image types eligible for automatic display
(~jabber-chat-image-auto-types~); SVG and TIFF now require an explicit RET
diff --git a/doap.xml b/doap.xml
index 1b9aa32712..82578c638f 100644
--- a/doap.xml
+++ b/doap.xml
@@ -495,7 +495,7 @@
<xmpp:status>partial</xmpp:status>
<xmpp:version>0.2</xmpp:version>
<xmpp:since>0.10.0</xmpp:since>
- <xmpp:note>Implements OMEMO 0.3 (legacy eu.siacs.conversations.axolotl
namespace, XEP-0384 v0.2). 1:1 chat complete, including one-time pre-key
removal after use and session recovery on pre-key decrypt failure. MUC: send
and receive in non-anonymous rooms; no affiliation list fetch for offline
members.</xmpp:note>
+ <xmpp:note>Implements OMEMO 0.3 (legacy eu.siacs.conversations.axolotl
namespace, XEP-0384 v0.2). 1:1 chat complete, including one-time pre-key
removal after use, session recovery on pre-key decrypt failure, periodic signed
pre-key rotation, and out-of-order message recovery via persisted skipped
ratchet keys. MUC: send and receive in non-anonymous rooms; no affiliation
list fetch for offline members.</xmpp:note>
</xmpp:SupportedXep>
</implements>
<implements>
diff --git a/lisp/jabber-omemo-store.el b/lisp/jabber-omemo-store.el
index 7e045eee78..929f4d5591 100644
--- a/lisp/jabber-omemo-store.el
+++ b/lisp/jabber-omemo-store.el
@@ -311,6 +311,30 @@ DELETE FROM omemo_skipped_keys
AND dh_key = ? AND message_number = ?"
(list account jid device-id dh-key msg-number))))
+(defun jabber-omemo-store-all-skipped-keys (account jid device-id)
+ "Return all skipped message keys for ACCOUNT+JID+DEVICE-ID.
+Each element is (MSG-NUMBER DH-KEY MSG-KEY), the entry format of
+`jabber-omemo--session-set-skipped-keys'."
+ (when-let* ((db (jabber-db-ensure-open)))
+ (mapcar (lambda (row)
+ (list (car row)
+ (jabber-omemo-store--as-unibyte (cadr row))
+ (jabber-omemo-store--as-unibyte (caddr row))))
+ (sqlite-select db "\
+SELECT message_number, dh_key, message_key FROM omemo_skipped_keys
+ WHERE account = ? AND jid = ? AND device_id = ?"
+ (list account jid device-id)))))
+
+(defun jabber-omemo-store-delete-skipped-keys (account jid device-id)
+ "Delete all skipped message keys for ACCOUNT+JID+DEVICE-ID.
+Used when a session is replaced; keys from the old ratchet can
+never match again."
+ (when-let* ((db (jabber-db-ensure-open)))
+ (sqlite-execute db "\
+DELETE FROM omemo_skipped_keys
+ WHERE account = ? AND jid = ? AND device_id = ?"
+ (list account jid device-id))))
+
(defun jabber-omemo-store-delete-old-skipped-keys (account max-age)
"Delete skipped keys for ACCOUNT older than MAX-AGE seconds."
(when-let* ((db (jabber-db-ensure-open)))
diff --git a/lisp/jabber-omemo.el b/lisp/jabber-omemo.el
index cf39992e74..758d540fbb 100644
--- a/lisp/jabber-omemo.el
+++ b/lisp/jabber-omemo.el
@@ -133,6 +133,8 @@ Signal a `user-error' otherwise."
(declare-function jabber-omemo--deserialize-session "ext:jabber-omemo-core")
(declare-function jabber-omemo--encrypt-key "ext:jabber-omemo-core")
(declare-function jabber-omemo--decrypt-key "ext:jabber-omemo-core")
+(declare-function jabber-omemo--session-skipped-keys "ext:jabber-omemo-core")
+(declare-function jabber-omemo--session-set-skipped-keys
"ext:jabber-omemo-core")
(declare-function jabber-omemo--heartbeat "ext:jabber-omemo-core")
(declare-function jabber-omemo--aesgcm-decrypt "ext:jabber-omemo-core")
(declare-function jabber-omemo--aesgcm-encrypt "ext:jabber-omemo-core")
@@ -1111,6 +1113,55 @@ For MUC messages (type=groupchat), try in order:
(or (and real-jid (jabber-jid-user real-jid))
(jabber-omemo--match-jid-by-affiliation group nick))))))
+(defun jabber-omemo--skipped-keys-supported-p ()
+ "Return non-nil when the loaded module handles skipped message keys.
+A stale jabber-omemo-core.so lacks the accessors; degrade to the
+old no-recovery behavior instead of erroring."
+ (fboundp 'jabber-omemo--session-set-skipped-keys))
+
+(defun jabber-omemo--preload-skipped-keys (jc jid device-id session-ptr)
+ "Seed SESSION-PTR with persisted skipped keys for JC/JID/DEVICE-ID.
+Also discards phantom keys left in memory by a failed decrypt.
+Return the seeded list, the before-image for
+`jabber-omemo--sync-skipped-keys'."
+ (when (jabber-omemo--skipped-keys-supported-p)
+ (let ((keys (jabber-omemo-store-all-skipped-keys
+ (jabber-connection-bare-jid jc) jid device-id)))
+ (jabber-omemo--session-set-skipped-keys session-ptr keys)
+ keys)))
+
+(defun jabber-omemo--skipped-key-changes (before after)
+ "Return (NEW . CONSUMED) between skipped-key lists BEFORE and AFTER.
+Keys are (NR DH MK) triples; NR plus DH identifies an entry."
+ (let ((id (lambda (k) (cons (car k) (cadr k)))))
+ (cons (cl-set-difference after before :key id :test #'equal)
+ (cl-set-difference before after :key id :test #'equal))))
+
+(defun jabber-omemo--sync-skipped-keys (jc jid device-id session-ptr before)
+ "Persist SESSION-PTR's skipped-key changes since BEFORE to the database."
+ (when (jabber-omemo--skipped-keys-supported-p)
+ (pcase-let* ((account (jabber-connection-bare-jid jc))
+ (`(,new . ,consumed)
+ (jabber-omemo--skipped-key-changes
+ before (jabber-omemo--session-skipped-keys session-ptr))))
+ (dolist (k new)
+ (jabber-omemo-store-save-skipped-key
+ account jid device-id (cadr k) (car k) (caddr k)))
+ (dolist (k consumed)
+ (jabber-omemo-store-delete-skipped-key
+ account jid device-id (cadr k) (car k))))))
+
+(defun jabber-omemo--reset-skipped-keys (jc jid device-id session-ptr)
+ "Replace persisted skipped keys for JC/JID/DEVICE-ID with SESSION-PTR's.
+Used when a fresh session replaces an established one; keys from
+the old ratchet can never match again."
+ (when (jabber-omemo--skipped-keys-supported-p)
+ (let ((account (jabber-connection-bare-jid jc)))
+ (jabber-omemo-store-delete-skipped-keys account jid device-id)
+ (dolist (k (jabber-omemo--session-skipped-keys session-ptr))
+ (jabber-omemo-store-save-skipped-key
+ account jid device-id (cadr k) (car k) (caddr k))))))
+
(defun jabber-omemo--decrypt-key-with-session (jc sender-jid sender-did
store-ptr pre-key-p key-data)
"Decrypt KEY-DATA from SENDER-JID's device SENDER-DID via JC.
@@ -1124,34 +1175,44 @@ the retry is safe. This also resolves a peer that
reset their
session and simultaneous initiations. A regular message requires
an existing session.
+Skipped ratchet keys are seeded from the database before the
+decrypt and changes are persisted after it, so out-of-order
+messages decrypt across restarts.
+
Returns (SESSION-PTR DECRYPTED-KEY FRESH-P), FRESH-P non-nil when
the fresh-session pre-key path was used. Signals
`jabber-omemo-no-session' or `jabber-omemo-prekey-failed'."
- (let ((existing (jabber-omemo--get-session jc sender-jid sender-did)))
- (cond
- ((not pre-key-p)
- (unless existing
- (signal 'jabber-omemo-no-session (list sender-jid sender-did)))
- (list existing
- (jabber-omemo-decrypt-key existing store-ptr nil key-data)
- nil))
- (t
- (or (and existing
- (condition-case nil
- (list existing
- (jabber-omemo-decrypt-key
- existing store-ptr t key-data)
- nil)
- (jabber-omemo-error nil)))
- (let ((fresh (jabber-omemo-make-session)))
- (condition-case err
- (list fresh
- (jabber-omemo-decrypt-key fresh store-ptr t key-data)
- t)
- (jabber-omemo-error
- (signal 'jabber-omemo-prekey-failed
- (list sender-jid sender-did
- (error-message-string err)))))))))))
+ (let* ((existing (jabber-omemo--get-session jc sender-jid sender-did))
+ (before (and existing
+ (jabber-omemo--preload-skipped-keys
+ jc sender-jid sender-did existing))))
+ (cl-flet ((decrypt-existing (prekey)
+ (let ((key (jabber-omemo-decrypt-key
+ existing store-ptr prekey key-data)))
+ (jabber-omemo--sync-skipped-keys
+ jc sender-jid sender-did existing before)
+ (list existing key nil))))
+ (cond
+ ((not pre-key-p)
+ (unless existing
+ (signal 'jabber-omemo-no-session (list sender-jid sender-did)))
+ (decrypt-existing nil))
+ (t
+ (or (and existing
+ (condition-case nil
+ (decrypt-existing t)
+ (jabber-omemo-error nil)))
+ (let ((fresh (jabber-omemo-make-session)))
+ (condition-case err
+ (let ((key (jabber-omemo-decrypt-key
+ fresh store-ptr t key-data)))
+ (jabber-omemo--reset-skipped-keys
+ jc sender-jid sender-did fresh)
+ (list fresh key t))
+ (jabber-omemo-error
+ (signal 'jabber-omemo-prekey-failed
+ (list sender-jid sender-did
+ (error-message-string err))))))))))))
(defun jabber-omemo--decrypt-stanza (jc xml-data parsed)
"Decrypt OMEMO message on JC in XML-DATA using PARSED data.
diff --git a/src/jabber-omemo-core.c b/src/jabber-omemo-core.c
index d4d6d57df7..27703f2a17 100644
--- a/src/jabber-omemo-core.c
+++ b/src/jabber-omemo-core.c
@@ -34,21 +34,124 @@ int omemoRandom(void *p, size_t n)
return getrandom(p, n, 0) != (ssize_t)n;
}
-/* Skipped-message-key callbacks: still stubs. The decrypt path does
- hit these, so an out-of-order message within an established chain
- cannot be recovered from a skipped key; pre-key messages fall back
- to a fresh session on the Elisp side instead. */
+/* Skipped-message-key registry.
+
+ picomemo hands skipped ratchet keys to the embedder through
+ omemoStoreMessageKey and asks for them back in omemoLoadMessageKey.
+ Both fire synchronously inside omemoDecryptKey, where no emacs_env
+ is available, so keys live in a malloc'd per-session list here.
+ Elisp seeds and drains the list via
+ jabber-omemo--session-set-skipped-keys and
+ jabber-omemo--session-skipped-keys around each decrypt and
+ persists the result in SQLite. */
+
+struct skipped_key {
+ uint32_t nr;
+ uint8_t dh[32];
+ uint8_t mk[32];
+};
+
+struct session_skipped {
+ struct omemoSession *session;
+ struct skipped_key *keys;
+ size_t count, cap;
+};
+
+/* Upper bound on retained skipped keys per session; a peer jumping
+ further ahead than this in one ratchet aborts the decrypt with
+ OMEMO_ESTORE instead of allocating without limit. */
+#define SKIPPED_KEYS_MAX 1000
+
+static struct session_skipped *g_skipped;
+static size_t g_skipped_count, g_skipped_cap;
+
+static struct session_skipped *
+skipped_find(struct omemoSession *s, int create)
+{
+ for (size_t i = 0; i < g_skipped_count; i++)
+ if (g_skipped[i].session == s)
+ return &g_skipped[i];
+ if (!create)
+ return NULL;
+ if (g_skipped_count == g_skipped_cap) {
+ size_t ncap = g_skipped_cap ? g_skipped_cap * 2 : 8;
+ struct session_skipped *n = realloc(g_skipped, ncap * sizeof *n);
+ if (!n)
+ return NULL;
+ g_skipped = n;
+ g_skipped_cap = ncap;
+ }
+ struct session_skipped *e = &g_skipped[g_skipped_count++];
+ e->session = s;
+ e->keys = NULL;
+ e->count = e->cap = 0;
+ return e;
+}
+
+static void
+skipped_drop(struct omemoSession *s)
+{
+ for (size_t i = 0; i < g_skipped_count; i++) {
+ if (g_skipped[i].session == s) {
+ if (g_skipped[i].keys) {
+ memset(g_skipped[i].keys, 0,
+ g_skipped[i].cap * sizeof(struct skipped_key));
+ free(g_skipped[i].keys);
+ }
+ g_skipped[i] = g_skipped[--g_skipped_count];
+ return;
+ }
+ }
+}
+
+static int
+skipped_add(struct session_skipped *e, uint32_t nr,
+ const uint8_t *dh, const uint8_t *mk)
+{
+ if (e->count >= SKIPPED_KEYS_MAX)
+ return 1;
+ if (e->count == e->cap) {
+ size_t ncap = e->cap ? e->cap * 2 : 16;
+ struct skipped_key *n = realloc(e->keys, ncap * sizeof *n);
+ if (!n)
+ return 1;
+ memset(n + e->cap, 0, (ncap - e->cap) * sizeof *n);
+ e->keys = n;
+ e->cap = ncap;
+ }
+ e->keys[e->count].nr = nr;
+ memcpy(e->keys[e->count].dh, dh, 32);
+ memcpy(e->keys[e->count].mk, mk, 32);
+ e->count++;
+ return 0;
+}
int omemoLoadMessageKey(struct omemoSession *s, struct omemoMessageKey *k)
{
- (void)s; (void)k;
+ struct session_skipped *e = skipped_find(s, 0);
+ if (!e)
+ return 1; /* not found */
+ for (size_t i = 0; i < e->count; i++) {
+ struct skipped_key *sk = &e->keys[i];
+ if (sk->nr == k->nr && !memcmp(sk->dh, k->dh, 32)) {
+ memcpy(k->mk, sk->mk, 32);
+ /* Single use: replace with the last entry and zero it. */
+ e->keys[i] = e->keys[e->count - 1];
+ memset(&e->keys[e->count - 1], 0, sizeof(struct skipped_key));
+ e->count--;
+ return 0;
+ }
+ }
return 1; /* not found */
}
int omemoStoreMessageKey(struct omemoSession *s,
const struct omemoMessageKey *k, uint64_t n)
{
- (void)s; (void)k; (void)n;
+ (void)n;
+ struct session_skipped *e = skipped_find(s, 1);
+ if (!e || skipped_add(e, k->nr, k->dh, k->mk))
+ return OMEMO_ESTORE;
return 0;
}
@@ -116,6 +219,7 @@ free_store(void *ptr)
static void
free_session(void *ptr)
{
+ skipped_drop(ptr);
free(ptr);
}
@@ -719,6 +823,85 @@ F_decrypt_key(emacs_env *env, ptrdiff_t nargs, emacs_value
*args,
return make_unibyte(env, key, keyn);
}
+/* jabber-omemo--session-skipped-keys */
+
+static emacs_value
+F_session_skipped_keys(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
+ void *data)
+{
+ (void)nargs; (void)data;
+
+ struct omemoSession *session = env->get_user_ptr(env, args[0]);
+ if (env->non_local_exit_check(env))
+ return Qnil_v;
+
+ emacs_value Qlist = env->intern(env, "list");
+ emacs_value Qcons = env->intern(env, "cons");
+ emacs_value result = Qnil_v;
+ struct session_skipped *e = skipped_find(session, 0);
+ if (!e)
+ return result;
+ for (size_t i = e->count; i > 0; i--) {
+ struct skipped_key *sk = &e->keys[i - 1];
+ emacs_value entry_args[] = {
+ env->make_integer(env, sk->nr),
+ make_unibyte(env, sk->dh, 32),
+ make_unibyte(env, sk->mk, 32),
+ };
+ emacs_value entry = env->funcall(env, Qlist, 3, entry_args);
+ emacs_value cons_args[] = { entry, result };
+ result = env->funcall(env, Qcons, 2, cons_args);
+ }
+ return result;
+}
+
+/* jabber-omemo--session-set-skipped-keys */
+
+static emacs_value
+F_session_set_skipped_keys(emacs_env *env, ptrdiff_t nargs,
+ emacs_value *args, void *data)
+{
+ (void)nargs; (void)data;
+
+ struct omemoSession *session = env->get_user_ptr(env, args[0]);
+ if (env->non_local_exit_check(env))
+ return Qnil_v;
+
+ emacs_value Qcar = env->intern(env, "car");
+ emacs_value Qcdr = env->intern(env, "cdr");
+
+ skipped_drop(session);
+ for (emacs_value l = args[1]; env->is_not_nil(env, l);
+ l = env->funcall(env, Qcdr, 1, &l)) {
+ emacs_value entry = env->funcall(env, Qcar, 1, &l);
+ emacs_value v_nr = env->funcall(env, Qcar, 1, &entry);
+ emacs_value rest = env->funcall(env, Qcdr, 1, &entry);
+ emacs_value v_dh = env->funcall(env, Qcar, 1, &rest);
+ rest = env->funcall(env, Qcdr, 1, &rest);
+ emacs_value v_mk = env->funcall(env, Qcar, 1, &rest);
+
+ intmax_t nr = env->extract_integer(env, v_nr);
+ uint8_t dh[33], mk[33];
+ size_t dhn = 0, mkn = 0;
+ if (extract_unibyte(env, v_dh, dh, sizeof(dh), &dhn) ||
+ extract_unibyte(env, v_mk, mk, sizeof(mk), &mkn))
+ return Qnil_v;
+ if (env->non_local_exit_check(env))
+ return Qnil_v;
+ if (dhn != 32 || mkn != 32) {
+ signal_error(env, OMEMO_EPARAM,
+ "skipped key entry must hold 32-byte dh and mk");
+ return Qnil_v;
+ }
+ struct session_skipped *e = skipped_find(session, 1);
+ if (!e || skipped_add(e, (uint32_t)nr, dh, mk)) {
+ signal_error(env, OMEMO_ESTORE, "cannot store skipped key");
+ return Qnil_v;
+ }
+ }
+ return Qnil_v;
+}
+
/* jabber-omemo--heartbeat */
static emacs_value
@@ -1039,6 +1222,17 @@ emacs_module_init(struct emacs_runtime *runtime)
"MSG is the encrypted key message as a unibyte string.\n"
"Returns the decrypted key as a unibyte string.");
+ DEFUN("jabber-omemo--session-skipped-keys", F_session_skipped_keys, 1, 1,
+ "Return SESSION-PTR's in-memory skipped message keys.\n"
+ "Each element is (NR DH MK) with NR an integer and DH/MK\n"
+ "32-byte unibyte strings.");
+
+ DEFUN("jabber-omemo--session-set-skipped-keys",
+ F_session_set_skipped_keys, 2, 2,
+ "Replace SESSION-PTR's in-memory skipped message keys with KEYS.\n"
+ "KEYS is a list of (NR DH MK) entries as returned by\n"
+ "jabber-omemo--session-skipped-keys.");
+
DEFUN("jabber-omemo--heartbeat", F_heartbeat, 2, 2,
"Check if a heartbeat message is needed after decryption.\n"
"SESSION-PTR is the session to check.\n"
diff --git a/tests/jabber-test-omemo-message.el
b/tests/jabber-test-omemo-message.el
index 903ac8d2f0..62199f3ec0 100644
--- a/tests/jabber-test-omemo-message.el
+++ b/tests/jabber-test-omemo-message.el
@@ -510,8 +510,10 @@ buffer-local `jabber-group'."
(jabber-omemo--sessions (make-hash-table :test 'equal)))
(puthash "[email protected]" 42 jabber-omemo--device-ids)
(puthash "[email protected]" 'fake-store-ptr jabber-omemo--stores)
+ ;; A real session pointer: the skipped-key preload hands it to the
+ ;; C module, which rejects placeholder symbols.
(puthash (jabber-omemo--session-key "[email protected]" "[email protected]"
999)
- 'fake-session-ptr jabber-omemo--sessions)
+ (jabber-omemo-make-session) jabber-omemo--sessions)
(cl-letf (((symbol-function 'jabber-connection-bare-jid)
(lambda (_jc) "[email protected]"))
((symbol-function 'jabber-omemo-decrypt-key)
diff --git a/tests/jabber-test-omemo-module.el
b/tests/jabber-test-omemo-module.el
index 7fa2f4fa77..5d1440eac4 100644
--- a/tests/jabber-test-omemo-module.el
+++ b/tests/jabber-test-omemo-module.el
@@ -35,6 +35,8 @@
(should (fboundp 'jabber-omemo--deserialize-session))
(should (fboundp 'jabber-omemo--encrypt-key))
(should (fboundp 'jabber-omemo--decrypt-key))
+ (should (fboundp 'jabber-omemo--session-skipped-keys))
+ (should (fboundp 'jabber-omemo--session-set-skipped-keys))
(should (fboundp 'jabber-omemo--heartbeat))
(should (fboundp 'jabber-omemo--aesgcm-decrypt))
(should (fboundp 'jabber-omemo--aesgcm-encrypt)))
@@ -502,5 +504,87 @@
(plist-get encrypted :data))
(should (= (car pk) (jabber-omemo--used-pre-key-id bob-session)))))
+;;; Group: Skipped message keys
+
+(defmacro jabber-test-omemo-module--with-session-pair (&rest body)
+ "Run BODY with `alice', `bob', and `alice-session' bound.
+Alice has initiated a session towards Bob's bundle."
+ (declare (indent 0) (debug t))
+ `(let* ((alice (jabber-omemo--deserialize-store (jabber-omemo--setup-store)))
+ (bob (jabber-omemo--deserialize-store (jabber-omemo--setup-store)))
+ (bundle (jabber-omemo--get-bundle bob))
+ (pk (car (plist-get bundle :pre-keys)))
+ (alice-session (jabber-omemo--initiate-session
+ alice
+ (plist-get bundle :signature)
+ (plist-get bundle :signed-pre-key)
+ (plist-get bundle :identity-key)
+ (cdr pk)
+ (plist-get bundle :signed-pre-key-id)
+ (car pk))))
+ (ignore alice-session)
+ ,@body))
+
+(ert-deftest jabber-test-omemo-module-skipped-keys-accessors-roundtrip ()
+ "Skipped keys set on a session read back unchanged."
+ (let* ((session (jabber-omemo--make-session))
+ (keys (list (list 3 (make-string 32 ?d) (make-string 32 ?m))
+ (list 7 (make-string 32 ?e) (make-string 32 ?n)))))
+ (should (null (jabber-omemo--session-skipped-keys session)))
+ (jabber-omemo--session-set-skipped-keys session keys)
+ (should (equal keys (jabber-omemo--session-skipped-keys session)))
+ ;; Replacing clears the previous list.
+ (jabber-omemo--session-set-skipped-keys session nil)
+ (should (null (jabber-omemo--session-skipped-keys session)))))
+
+(ert-deftest jabber-test-omemo-module-out-of-order-decrypt ()
+ "A message skipped over in the ratchet still decrypts afterwards."
+ (jabber-test-omemo-module--with-session-pair
+ (let* ((k1 (make-string 32 ?1))
+ (k2 (make-string 32 ?2))
+ (k3 (make-string 32 ?3))
+ (m1 (jabber-omemo--encrypt-key alice-session k1))
+ (m2 (jabber-omemo--encrypt-key alice-session k2))
+ (m3 (jabber-omemo--encrypt-key alice-session k3))
+ (bob-session (jabber-omemo--make-session)))
+ ;; Deliver message 3 first: keys for 1 and 2 must be skipped.
+ (should (string= k3 (jabber-omemo--decrypt-key
+ bob-session bob
+ (plist-get m3 :pre-key-p) (plist-get m3 :data))))
+ (should (= 2 (length (jabber-omemo--session-skipped-keys bob-session))))
+ ;; The late arrivals decrypt from the skipped keys, single-use.
+ (should (string= k1 (jabber-omemo--decrypt-key
+ bob-session bob
+ (plist-get m1 :pre-key-p) (plist-get m1 :data))))
+ (should (string= k2 (jabber-omemo--decrypt-key
+ bob-session bob
+ (plist-get m2 :pre-key-p) (plist-get m2 :data))))
+ (should (null (jabber-omemo--session-skipped-keys bob-session))))))
+
+(ert-deftest jabber-test-omemo-module-skipped-keys-survive-reserialization ()
+ "Skipped keys carried over to a reloaded session still decrypt."
+ (jabber-test-omemo-module--with-session-pair
+ (let* ((k1 (make-string 32 ?1))
+ (k2 (make-string 32 ?2))
+ (m1 (jabber-omemo--encrypt-key alice-session k1))
+ (m2 (jabber-omemo--encrypt-key alice-session k2))
+ (bob-session (jabber-omemo--make-session)))
+ ;; Deliver message 2 first; message 1's key is skipped.
+ (should (string= k2 (jabber-omemo--decrypt-key
+ bob-session bob
+ (plist-get m2 :pre-key-p) (plist-get m2 :data))))
+ ;; Simulate a restart: serialize the session and its skipped
+ ;; keys, then restore both into a fresh session pointer.
+ (let* ((skipped (jabber-omemo--session-skipped-keys bob-session))
+ (blob (jabber-omemo--serialize-session bob-session))
+ (restored (jabber-omemo--deserialize-session blob)))
+ (should (= 1 (length skipped)))
+ (should (null (jabber-omemo--session-skipped-keys restored)))
+ (jabber-omemo--session-set-skipped-keys restored skipped)
+ (should (string= k1 (jabber-omemo--decrypt-key
+ restored bob
+ (plist-get m1 :pre-key-p)
+ (plist-get m1 :data))))))))
+
(provide 'jabber-test-omemo-module)
;;; jabber-test-omemo-module.el ends here
diff --git a/tests/jabber-test-omemo-protocol.el
b/tests/jabber-test-omemo-protocol.el
index 1597dd7fde..e12d295c96 100644
--- a/tests/jabber-test-omemo-protocol.el
+++ b/tests/jabber-test-omemo-protocol.el
@@ -474,10 +474,8 @@ side session and PK-ID the pre-key id it consumed."
(ert-deftest jabber-test-omemo-protocol-prekey-out-of-order-falls-back ()
"An earlier pre-key message still decrypts after a later one.
-The C module's skipped-message-key callbacks are stubs (see
-todo.org), so the established-session path cannot serve an older
-ratchet position; the fresh-session fallback re-derives it from
-the still-present pre-key instead."
+The established session serves the older ratchet position from its
+skipped message keys, so no fresh-session fallback is needed."
(jabber-test-omemo-protocol-with-db
(cl-letf (((symbol-function 'jabber-connection-bare-jid)
(lambda (_jc) "[email protected]")))
@@ -500,7 +498,7 @@ the still-present pre-key instead."
jc "[email protected]" 7 store-ptr t
(plist-get msg-1 :data))))
(should (string= key-1 decrypted))
- (should fresh-p))))))
+ (should-not fresh-p))))))
(ert-deftest jabber-test-omemo-protocol-prekey-falls-back-on-peer-reset ()
"A pre-key message from a re-initialized peer session decrypts fresh."
@@ -670,5 +668,62 @@ the still-present pre-key instead."
(jabber-omemo--on-mam-sync-complete nil)
(should (equal '(jc-b jc-a) flushed))))))
+;;; Group: Skipped message keys
+
+(ert-deftest jabber-test-omemo-protocol-skipped-key-changes-diff ()
+ "Diff returns new keys and consumed keys by NR + DH identity."
+ (let* ((a (list 1 (make-string 32 ?a) (make-string 32 ?x)))
+ (b (list 2 (make-string 32 ?b) (make-string 32 ?y)))
+ (c (list 3 (make-string 32 ?c) (make-string 32 ?z))))
+ (pcase-let ((`(,new . ,consumed)
+ (jabber-omemo--skipped-key-changes (list a b) (list b c))))
+ (should (equal (list c) new))
+ (should (equal (list a) consumed)))))
+
+(ert-deftest jabber-test-omemo-protocol-skipped-keys-recover-across-restart ()
+ "An out-of-order message decrypts after a session cache flush.
+Skipped ratchet keys persist in SQLite and reload with the session."
+ (jabber-test-omemo-protocol-with-db
+ (cl-letf (((symbol-function 'jabber-connection-bare-jid)
+ (lambda (_jc) "[email protected]")))
+ (let* ((jc (list :bare-jid "[email protected]"))
+ (store-ptr (jabber-omemo--get-store jc))
+ (my-bundle (jabber-omemo-get-bundle store-ptr))
+ (alice (jabber-omemo-deserialize-store
(jabber-omemo-setup-store)))
+ (pk (car (plist-get my-bundle :pre-keys)))
+ (alice-session (jabber-omemo-initiate-session
+ alice
+ (plist-get my-bundle :signature)
+ (plist-get my-bundle :signed-pre-key)
+ (plist-get my-bundle :identity-key)
+ (cdr pk)
+ (plist-get my-bundle :signed-pre-key-id)
+ (car pk)))
+ (k1 (make-string 32 ?1))
+ (k2 (make-string 32 ?2))
+ (m1 (jabber-omemo-encrypt-key alice-session k1))
+ (m2 (jabber-omemo-encrypt-key alice-session k2)))
+ ;; Deliver message 2 first; its decrypt skips message 1's key.
+ (pcase-let ((`(,session ,key ,fresh-p)
+ (jabber-omemo--decrypt-key-with-session
+ jc "[email protected]" 111 store-ptr
+ (plist-get m2 :pre-key-p) (plist-get m2 :data))))
+ (should (string= k2 key))
+ (should fresh-p)
+ (jabber-omemo--save-session jc "[email protected]" 111 session))
+ (should (= 1 (length (jabber-omemo-store-all-skipped-keys
+ "[email protected]" "[email protected]" 111))))
+ ;; Simulate a restart: drop all in-memory session state.
+ (clrhash jabber-omemo--sessions)
+ ;; The late message decrypts from the reloaded skipped key.
+ (pcase-let ((`(,_session ,key ,_fresh-p)
+ (jabber-omemo--decrypt-key-with-session
+ jc "[email protected]" 111 store-ptr
+ (plist-get m1 :pre-key-p) (plist-get m1 :data))))
+ (should (string= k1 key)))
+ ;; Consumed: the persisted copy is gone.
+ (should (null (jabber-omemo-store-all-skipped-keys
+ "[email protected]" "[email protected]" 111)))))))
+
(provide 'jabber-test-omemo-protocol)
;;; jabber-test-omemo-protocol.el ends here