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

Reply via email to