branch: elpa/jabber
commit ff43627ae685e7a84b9be814b2fabed002f0c052
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>

    omemo: Make session migration transaction-safe
---
 lisp/jabber-omemo-store.el          |  74 ++---
 lisp/jabber-omemo.el                |  83 +-----
 src/jabber-omemo-core.c             | 564 +++++++++++++++++++++++++++---------
 src/picomemo/omemo.c                |  23 +-
 tests/jabber-test-mam.el            |  34 +++
 tests/jabber-test-omemo-message.el  |   3 +-
 tests/jabber-test-omemo-module.el   | 158 +++++++++-
 tests/jabber-test-omemo-protocol.el |  53 +++-
 tests/jabber-test-omemo-store.el    | 106 ++++---
 9 files changed, 768 insertions(+), 330 deletions(-)

diff --git a/lisp/jabber-omemo-store.el b/lisp/jabber-omemo-store.el
index 929f4d5591..50571b6a5b 100644
--- a/lisp/jabber-omemo-store.el
+++ b/lisp/jabber-omemo-store.el
@@ -245,6 +245,31 @@ INSERT OR REPLACE INTO omemo_sessions (account, jid, 
device_id, session_blob)
   VALUES (?, ?, ?, ?)"
                    (list account jid device-id blob))))
 
+(defun jabber-omemo-store-save-session-and-clear-legacy-keys
+    (account jid device-id blob)
+  "Save session BLOB for ACCOUNT, JID, DEVICE-ID and remove legacy keys."
+  (when-let* ((db (jabber-db-ensure-open)))
+    (sqlite-execute db "SAVEPOINT omemo_session_migration")
+    (condition-case err
+        (prog1
+            (progn
+              (jabber-omemo-store-save-session account jid device-id blob)
+              (jabber-omemo-store-delete-skipped-keys
+               account jid device-id))
+          (sqlite-execute db "RELEASE omemo_session_migration"))
+      (error
+       (condition-case cleanup-err
+           (sqlite-execute db "ROLLBACK TO omemo_session_migration")
+         (error
+          (message "OMEMO session rollback failed: %s"
+                   (error-message-string cleanup-err))))
+       (condition-case cleanup-err
+           (sqlite-execute db "RELEASE omemo_session_migration")
+         (error
+          (message "OMEMO session savepoint release failed: %s"
+                   (error-message-string cleanup-err))))
+       (signal (car err) (cdr err))))))
+
 (defun jabber-omemo-store-load-session (account jid device-id)
   "Load session blob for ACCOUNT, JID, DEVICE-ID, or nil."
   (when-let* ((db (jabber-db-ensure-open)))
@@ -274,42 +299,7 @@ SELECT device_id, session_blob FROM omemo_sessions
   WHERE account = ? AND jid = ?"
                           (list account jid)))))
 
-;;; Skipped key CRUD
-
-(defun jabber-omemo-store-save-skipped-key (account jid device-id
-                                                   dh-key msg-number msg-key)
-  "Store a skipped message key for ACCOUNT, JID, DEVICE-ID.
-DH-KEY and MSG-KEY are unibyte blobs.  MSG-NUMBER is an integer."
-  (when-let* ((db (jabber-db-ensure-open)))
-    (let ((now (truncate (float-time))))
-      (sqlite-execute db "\
-INSERT OR REPLACE INTO omemo_skipped_keys
-  (account, jid, device_id, dh_key, message_number, message_key, created_at)
-  VALUES (?, ?, ?, ?, ?, ?, ?)"
-                     (list account jid device-id dh-key msg-number msg-key 
now)))))
-
-(defun jabber-omemo-store-load-skipped-key (account jid device-id
-                                                   dh-key msg-number)
-  "Load a skipped message key for ACCOUNT+JID+DEVICE-ID, or nil.
-DH-KEY identifies the ratchet step and MSG-NUMBER the message within it."
-  (when-let* ((db (jabber-db-ensure-open)))
-    (jabber-omemo-store--as-unibyte
-     (caar (sqlite-select db "\
-SELECT message_key FROM omemo_skipped_keys
-  WHERE account = ? AND jid = ? AND device_id = ?
-    AND dh_key = ? AND message_number = ?"
-                         (list account jid device-id dh-key msg-number))))))
-
-(defun jabber-omemo-store-delete-skipped-key (account jid device-id
-                                                     dh-key msg-number)
-  "Delete a skipped message key for ACCOUNT+JID+DEVICE-ID after use.
-DH-KEY and MSG-NUMBER identify the entry."
-  (when-let* ((db (jabber-db-ensure-open)))
-    (sqlite-execute db "\
-DELETE FROM omemo_skipped_keys
-  WHERE account = ? AND jid = ? AND device_id = ?
-    AND dh_key = ? AND message_number = ?"
-                   (list account jid device-id dh-key msg-number))))
+;;; Legacy skipped-key migration
 
 (defun jabber-omemo-store-all-skipped-keys (account jid device-id)
   "Return all skipped message keys for ACCOUNT+JID+DEVICE-ID.
@@ -322,7 +312,8 @@ Each element is (MSG-NUMBER DH-KEY MSG-KEY), the entry 
format of
                     (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 = ?"
+  WHERE account = ? AND jid = ? AND device_id = ?
+  ORDER BY created_at ASC, rowid ASC"
                            (list account jid device-id)))))
 
 (defun jabber-omemo-store-delete-skipped-keys (account jid device-id)
@@ -335,14 +326,5 @@ 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)))
-    (let ((cutoff (- (truncate (float-time)) max-age)))
-      (sqlite-execute db "\
-DELETE FROM omemo_skipped_keys
-  WHERE account = ? AND created_at < ?"
-                     (list account cutoff)))))
-
 (provide 'jabber-omemo-store)
 ;;; jabber-omemo-store.el ends here
diff --git a/lisp/jabber-omemo.el b/lisp/jabber-omemo.el
index 758d540fbb..c1caa86d6b 100644
--- a/lisp/jabber-omemo.el
+++ b/lisp/jabber-omemo.el
@@ -63,12 +63,6 @@ compile jabber-omemo-core."
   :type 'boolean
   :group 'jabber)
 
-(defcustom jabber-omemo-skipped-key-max-age (* 30 86400)
-  "Maximum age in seconds for OMEMO skipped message keys.
-Keys older than this are deleted on connect."
-  :type 'integer
-  :group 'jabber)
-
 (defcustom jabber-omemo-signed-pre-key-rotation-period (* 7 86400)
   "Seconds between OMEMO signed pre-key rotations.
 Checked on connect.  XEP-0384 recommends rotating once a week to
@@ -131,6 +125,7 @@ Signal a `user-error' otherwise."
 (declare-function jabber-omemo--initiate-session "ext:jabber-omemo-core")
 (declare-function jabber-omemo--serialize-session "ext:jabber-omemo-core")
 (declare-function jabber-omemo--deserialize-session "ext:jabber-omemo-core")
+(declare-function jabber-omemo--legacy-session-blob-p "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")
@@ -405,6 +400,11 @@ Returns a deserialized session user-ptr, or nil."
         (when-let* ((blob (jabber-omemo-store-load-session
                            account jid device-id)))
           (let ((session-ptr (jabber-omemo-deserialize-session blob)))
+            (when (jabber-omemo--legacy-session-blob-p blob)
+              (jabber-omemo--session-set-skipped-keys
+               session-ptr
+               (jabber-omemo-store-all-skipped-keys
+                account jid device-id)))
             (puthash key session-ptr jabber-omemo--sessions)
             session-ptr)))))
 
@@ -414,7 +414,8 @@ Updates both the database and in-memory cache."
   (let* ((account (jabber-connection-bare-jid jc))
          (key (jabber-omemo--session-key account jid device-id))
          (blob (jabber-omemo-serialize-session session-ptr)))
-    (jabber-omemo-store-save-session account jid device-id blob)
+    (jabber-omemo-store-save-session-and-clear-legacy-keys
+     account jid device-id blob)
     (puthash key session-ptr jabber-omemo--sessions)))
 
 ;;; Device list XML helpers
@@ -1113,55 +1114,6 @@ 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.
@@ -1175,22 +1127,16 @@ 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.
+Skipped ratchet keys are owned by the native session and persisted
+inside its serialized blob, so out-of-order messages survive 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))
-         (before (and existing
-                      (jabber-omemo--preload-skipped-keys
-                       jc sender-jid sender-did existing))))
+  (let ((existing (jabber-omemo--get-session jc sender-jid sender-did)))
     (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)
@@ -1206,8 +1152,6 @@ the fresh-session pre-key path was used.  Signals
               (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
@@ -1628,10 +1572,7 @@ of date, and pre-fetches sessions for open chat buffers."
   (jabber-omemo--maybe-rotate-signed-pre-key jc)
   (jabber-omemo--ensure-device-listed jc)
   (jabber-omemo--publish-bundle-if-needed jc)
-  (jabber-omemo--prefetch-open-chats jc)
-  (jabber-omemo-store-delete-old-skipped-keys
-   (jabber-connection-bare-jid jc)
-   jabber-omemo-skipped-key-max-age))
+  (jabber-omemo--prefetch-open-chats jc))
 
 (defun jabber-omemo--prefetch-open-chats (jc)
   "Pre-fetch OMEMO sessions for all open OMEMO chat buffers on JC."
diff --git a/src/jabber-omemo-core.c b/src/jabber-omemo-core.c
index c126332558..2e3bd8e490 100644
--- a/src/jabber-omemo-core.c
+++ b/src/jabber-omemo-core.c
@@ -40,10 +40,8 @@ int omemoRandom(void *p, size_t n)
    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. */
+   The list is serialized with the native session so skipped keys
+   survive restarts without separate database writes. */
 
 struct skipped_key {
     uint32_t nr;
@@ -51,10 +49,11 @@ struct skipped_key {
     uint8_t mk[32];
 };
 
-struct session_skipped {
-    struct omemoSession *session;
+struct native_session {
+    struct omemoSession session;
     struct skipped_key *keys;
     size_t count, cap;
+    size_t decrypt_new_count;
 };
 
 /* Upper bound on retained skipped keys per session; a peer jumping
@@ -62,8 +61,11 @@ struct session_skipped {
    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;
+#define SESSION_MAGIC "JOMEMO\0\1"
+#define SESSION_MAGIC_SIZE 8
+#define SESSION_VERSION 1
+#define SESSION_HEADER_SIZE 20
+#define SKIPPED_KEY_SIZE 68
 
 static void
 skipped_clear(void *ptr, size_t size)
@@ -73,74 +75,58 @@ skipped_clear(void *ptr, size_t size)
         *p++ = 0;
 }
 
-static struct session_skipped *
-skipped_find(struct omemoSession *s, int create)
+static struct native_session *
+native_session(struct omemoSession *session)
 {
-    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;
+    return (struct native_session *)session;
 }
 
 static void
-skipped_drop(struct omemoSession *s)
+skipped_drop(struct native_session *session)
 {
-    for (size_t i = 0; i < g_skipped_count; i++) {
-        if (g_skipped[i].session == s) {
-            if (g_skipped[i].keys) {
-                skipped_clear(g_skipped[i].keys,
-                              g_skipped[i].cap * sizeof(struct skipped_key));
-                free(g_skipped[i].keys);
-            }
-            g_skipped[i] = g_skipped[--g_skipped_count];
-            return;
-        }
+    if (session->keys) {
+        skipped_clear(session->keys,
+                      session->cap * sizeof(struct skipped_key));
+        free(session->keys);
     }
+    session->keys = NULL;
+    session->count = session->cap = 0;
 }
 
 static int
-skipped_add(struct session_skipped *e, uint32_t nr,
+skipped_add(struct native_session *session, 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 (session->count == SKIPPED_KEYS_MAX) {
+        skipped_clear(&session->keys[0], sizeof session->keys[0]);
+        memmove(&session->keys[0], &session->keys[1],
+                (session->count - 1) * sizeof session->keys[0]);
+        session->count--;
+    }
+    if (session->count == session->cap) {
+        size_t ncap = session->cap ? session->cap * 2 : 16;
+        if (ncap > SKIPPED_KEYS_MAX)
+            ncap = SKIPPED_KEYS_MAX;
+        struct skipped_key *n = realloc(session->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++;
+        memset(n + session->cap, 0,
+               (ncap - session->cap) * sizeof *n);
+        session->keys = n;
+        session->cap = ncap;
+    }
+    session->keys[session->count].nr = nr;
+    memcpy(session->keys[session->count].dh, dh, 32);
+    memcpy(session->keys[session->count].mk, mk, 32);
+    session->count++;
     return 0;
 }
 
 int omemoLoadMessageKey(struct omemoSession *s, struct omemoMessageKey *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];
+    struct native_session *session = native_session(s);
+    for (size_t i = 0; i < session->count; i++) {
+        struct skipped_key *sk = &session->keys[i];
         if (sk->nr == k->nr && !memcmp(sk->dh, k->dh, 32)) {
             memcpy(k->mk, sk->mk, 32);
             return 0;
@@ -152,16 +138,16 @@ int omemoLoadMessageKey(struct omemoSession *s, struct 
omemoMessageKey *k)
 int omemoRemoveMessageKey(struct omemoSession *s,
                           const struct omemoMessageKey *k)
 {
-    struct session_skipped *e = skipped_find(s, 0);
-    if (!e)
-        return OMEMO_ESTORE;
-    for (size_t i = 0; i < e->count; i++) {
-        struct skipped_key *sk = &e->keys[i];
+    struct native_session *session = native_session(s);
+    for (size_t i = 0; i < session->count; i++) {
+        struct skipped_key *sk = &session->keys[i];
         if (sk->nr == k->nr && !memcmp(sk->dh, k->dh, 32)) {
-            e->keys[i] = e->keys[e->count - 1];
-            skipped_clear(&e->keys[e->count - 1],
+            skipped_clear(sk, sizeof *sk);
+            memmove(sk, sk + 1,
+                    (session->count - i - 1) * sizeof *sk);
+            skipped_clear(&session->keys[session->count - 1],
                           sizeof(struct skipped_key));
-            e->count--;
+            session->count--;
             return 0;
         }
     }
@@ -172,9 +158,11 @@ int omemoStoreMessageKey(struct omemoSession *s,
                          const struct omemoMessageKey *k, uint64_t n)
 {
     (void)n;
-    struct session_skipped *e = skipped_find(s, 1);
-    if (!e || skipped_add(e, k->nr, k->dh, k->mk))
+    struct native_session *session = native_session(s);
+    if (session->decrypt_new_count >= SKIPPED_KEYS_MAX ||
+        skipped_add(session, k->nr, k->dh, k->mk))
         return OMEMO_ESTORE;
+    session->decrypt_new_count++;
     return 0;
 }
 
@@ -206,7 +194,9 @@ provide(emacs_env *env, const char *feature)
 static void
 signal_error(emacs_env *env, int code, const char *msg)
 {
-    emacs_value data = env->make_string(env, msg, strlen(msg));
+    emacs_value message = env->make_string(env, msg, strlen(msg));
+    emacs_value data = env->funcall(env, env->intern(env, "list"),
+                                    1, &message);
     emacs_value errsym = Qjabber_omemo_error;
     env->non_local_exit_signal(env, errsym, data);
     (void)code;
@@ -231,6 +221,21 @@ extract_unibyte(emacs_env *env, emacs_value arg,
     return 0;
 }
 
+static int
+extract_exact_unibyte(emacs_env *env, emacs_value arg, uint8_t *buf,
+                      size_t expected, const char *message)
+{
+    ptrdiff_t len = 0;
+    env->copy_string_contents(env, arg, NULL, &len);
+    if (env->non_local_exit_check(env))
+        return -1;
+    if (len != (ptrdiff_t)expected + 1) {
+        signal_error(env, OMEMO_ESTORE, message);
+        return -1;
+    }
+    return extract_unibyte(env, arg, buf, expected + 1, NULL);
+}
+
 /*  Finalizers for user-ptr  */
 
 static void
@@ -242,10 +247,63 @@ free_store(void *ptr)
 static void
 free_session(void *ptr)
 {
-    skipped_drop(ptr);
+    struct native_session *session = ptr;
+    skipped_drop(session);
+    skipped_clear(&session->session, sizeof session->session);
     free(ptr);
 }
 
+static void *
+checked_user_ptr(emacs_env *env, emacs_value value,
+                 void (*expected)(void *), const char *message)
+{
+    void (*finalizer)(void *) = env->get_user_finalizer(env, value);
+    if (env->non_local_exit_check(env))
+        return NULL;
+    if (finalizer != expected) {
+        signal_error(env, OMEMO_ESTORE, message);
+        return NULL;
+    }
+    return env->get_user_ptr(env, value);
+}
+
+static struct omemoStore *
+extract_store(emacs_env *env, emacs_value value)
+{
+    return checked_user_ptr(env, value, free_store,
+                            "expected an OMEMO store pointer");
+}
+
+static struct native_session *
+extract_session(emacs_env *env, emacs_value value)
+{
+    return checked_user_ptr(env, value, free_session,
+                            "expected an OMEMO session pointer");
+}
+
+static uint32_t
+read_u32(const uint8_t *p)
+{
+    return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
+           ((uint32_t)p[2] << 8) | p[3];
+}
+
+static void
+write_u32(uint8_t *p, uint32_t value)
+{
+    p[0] = value >> 24;
+    p[1] = value >> 16;
+    p[2] = value >> 8;
+    p[3] = value;
+}
+
+static bool
+session_envelope_p(const uint8_t *blob, size_t len)
+{
+    return len >= SESSION_MAGIC_SIZE &&
+           !memcmp(blob, SESSION_MAGIC, SESSION_MAGIC_SIZE);
+}
+
 /*  jabber-omemo--setup-store  */
 
 static emacs_value
@@ -327,7 +385,7 @@ F_serialize_store(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoStore *store = env->get_user_ptr(env, args[0]);
+    struct omemoStore *store = extract_store(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
@@ -352,7 +410,7 @@ F_get_bundle(emacs_env *env, ptrdiff_t nargs, emacs_value 
*args,
 {
     (void)nargs; (void)data;
 
-    struct omemoStore *store = env->get_user_ptr(env, args[0]);
+    struct omemoStore *store = extract_store(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
@@ -426,7 +484,7 @@ F_rotate_signed_pre_key(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoStore *store = env->get_user_ptr(env, args[0]);
+    struct omemoStore *store = extract_store(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
@@ -446,7 +504,7 @@ F_refill_pre_keys(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoStore *store = env->get_user_ptr(env, args[0]);
+    struct omemoStore *store = extract_store(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
@@ -466,7 +524,7 @@ F_remove_pre_key(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoStore *store = env->get_user_ptr(env, args[0]);
+    struct omemoStore *store = extract_store(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
@@ -494,11 +552,11 @@ F_used_pre_key_id(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoSession *session = env->get_user_ptr(env, args[0]);
+    struct native_session *native = extract_session(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
-    return env->make_integer(env, session->usedpk_id);
+    return env->make_integer(env, native->session.usedpk_id);
 }
 
 /*  jabber-omemo--encrypt-message  */
@@ -567,15 +625,15 @@ F_decrypt_message(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
     (void)nargs; (void)data;
 
     /* Extract key (32 bytes) */
-    uint8_t key[33]; /* +1 for NUL from copy_string_contents */
-    size_t keylen;
-    if (extract_unibyte(env, args[0], key, sizeof(key), &keylen))
+    uint8_t key[33];
+    if (extract_exact_unibyte(env, args[0], key, 32,
+                              "message key must be exactly 32 bytes"))
         return Qnil_v;
 
     /* Extract IV (12 bytes) */
     uint8_t iv[13];
-    size_t ivlen;
-    if (extract_unibyte(env, args[1], iv, sizeof(iv), &ivlen))
+    if (extract_exact_unibyte(env, args[1], iv, 12,
+                              "message IV must be exactly 12 bytes"))
         return Qnil_v;
 
     /* Extract ciphertext */
@@ -603,7 +661,7 @@ F_decrypt_message(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
         return Qnil_v;
     }
 
-    int rc = omemoDecryptMessage(plaintext, key, keylen, iv, ciphertext,
+    int rc = omemoDecryptMessage(plaintext, key, 32, iv, ciphertext,
                                  ctlen);
     free(ciphertext);
     if (rc) {
@@ -625,7 +683,7 @@ F_make_session(emacs_env *env, ptrdiff_t nargs, emacs_value 
*args,
 {
     (void)nargs; (void)args; (void)data;
 
-    struct omemoSession *session = calloc(1, sizeof(*session));
+    struct native_session *session = calloc(1, sizeof(*session));
     if (!session) {
         signal_error(env, -1, "calloc failed");
         return Qnil_v;
@@ -642,28 +700,32 @@ F_initiate_session(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoStore *store = env->get_user_ptr(env, args[0]);
+    struct omemoStore *store = extract_store(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
     /* Extract signature (64 bytes) */
     uint8_t sig[65];
-    if (extract_unibyte(env, args[1], sig, sizeof(sig), NULL))
+    if (extract_exact_unibyte(env, args[1], sig, 64,
+                              "signature must be exactly 64 bytes"))
         return Qnil_v;
 
     /* Extract signed pre-key (33 bytes) */
     uint8_t spk[34];
-    if (extract_unibyte(env, args[2], spk, sizeof(spk), NULL))
+    if (extract_exact_unibyte(env, args[2], spk, 33,
+                              "signed pre-key must be exactly 33 bytes"))
         return Qnil_v;
 
     /* Extract identity key (33 bytes) */
     uint8_t ik[34];
-    if (extract_unibyte(env, args[3], ik, sizeof(ik), NULL))
+    if (extract_exact_unibyte(env, args[3], ik, 33,
+                              "identity key must be exactly 33 bytes"))
         return Qnil_v;
 
     /* Extract pre-key (33 bytes) */
     uint8_t pk[34];
-    if (extract_unibyte(env, args[4], pk, sizeof(pk), NULL))
+    if (extract_exact_unibyte(env, args[4], pk, 33,
+                              "pre-key must be exactly 33 bytes"))
         return Qnil_v;
 
     uint32_t spk_id = (uint32_t)env->extract_integer(env, args[5]);
@@ -674,13 +736,13 @@ F_initiate_session(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
-    struct omemoSession *session = calloc(1, sizeof(*session));
+    struct native_session *session = calloc(1, sizeof(*session));
     if (!session) {
         signal_error(env, -1, "calloc failed");
         return Qnil_v;
     }
 
-    int rc = omemoInitiateSession(session, store, sig, spk, ik, pk,
+    int rc = omemoInitiateSession(&session->session, store, sig, spk, ik, pk,
                                   spk_id, pk_id);
     if (rc) {
         free(session);
@@ -699,19 +761,39 @@ F_serialize_session(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoSession *session = env->get_user_ptr(env, args[0]);
+    struct native_session *session = extract_session(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
-    size_t sz = omemoGetSerializedSessionSize(session);
+    size_t raw_size = omemoGetSerializedSessionSize(&session->session);
+    if (session->count > SKIPPED_KEYS_MAX ||
+        raw_size > UINT32_MAX ||
+        session->count > (SIZE_MAX - SESSION_HEADER_SIZE - raw_size) /
+                         SKIPPED_KEY_SIZE) {
+        signal_error(env, OMEMO_ESTORE, "session is too large to serialize");
+        return Qnil_v;
+    }
+    size_t sz = SESSION_HEADER_SIZE + raw_size +
+                session->count * SKIPPED_KEY_SIZE;
     uint8_t *buf = malloc(sz);
     if (!buf) {
         signal_error(env, -1, "malloc failed");
         return Qnil_v;
     }
-    omemoSerializeSession(buf, session);
+    memcpy(buf, SESSION_MAGIC, SESSION_MAGIC_SIZE);
+    write_u32(buf + 8, SESSION_VERSION);
+    write_u32(buf + 12, (uint32_t)raw_size);
+    write_u32(buf + 16, (uint32_t)session->count);
+    omemoSerializeSession(buf + SESSION_HEADER_SIZE, &session->session);
+    uint8_t *p = buf + SESSION_HEADER_SIZE + raw_size;
+    for (size_t i = 0; i < session->count; i++, p += SKIPPED_KEY_SIZE) {
+        write_u32(p, session->keys[i].nr);
+        memcpy(p + 4, session->keys[i].dh, 32);
+        memcpy(p + 36, session->keys[i].mk, 32);
+    }
 
     emacs_value result = make_unibyte(env, buf, sz);
+    skipped_clear(buf, sz);
     free(buf);
     return result;
 }
@@ -741,16 +823,53 @@ F_deserialize_session(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
     }
     size_t datalen = (size_t)(bloblen - 1);
 
-    struct omemoSession *session = calloc(1, sizeof(*session));
+    struct native_session *session = calloc(1, sizeof(*session));
     if (!session) {
         free(blob);
         signal_error(env, -1, "calloc failed");
         return Qnil_v;
     }
 
-    int rc = omemoDeserializeSession(blob, datalen, session);
+    const uint8_t *raw = blob;
+    size_t raw_size = datalen;
+    uint32_t key_count = 0;
+    if (session_envelope_p(blob, datalen)) {
+        if (datalen < SESSION_HEADER_SIZE || read_u32(blob + 8) != 
SESSION_VERSION) {
+            skipped_clear(blob, datalen);
+            free(blob);
+            free(session);
+            signal_error(env, OMEMO_ECORRUPT, "invalid session envelope");
+            return Qnil_v;
+        }
+        raw_size = read_u32(blob + 12);
+        key_count = read_u32(blob + 16);
+        if (key_count > SKIPPED_KEYS_MAX ||
+            raw_size > datalen - SESSION_HEADER_SIZE ||
+            key_count > (SIZE_MAX - SESSION_HEADER_SIZE - raw_size) /
+                        SKIPPED_KEY_SIZE ||
+            SESSION_HEADER_SIZE + raw_size +
+                (size_t)key_count * SKIPPED_KEY_SIZE != datalen) {
+            skipped_clear(blob, datalen);
+            free(blob);
+            free(session);
+            signal_error(env, OMEMO_ECORRUPT, "malformed session envelope");
+            return Qnil_v;
+        }
+        raw = blob + SESSION_HEADER_SIZE;
+    }
+    int rc = omemoDeserializeSession(raw, raw_size, &session->session);
+    if (!rc && key_count) {
+        const uint8_t *p = raw + raw_size;
+        for (uint32_t i = 0; i < key_count; i++, p += SKIPPED_KEY_SIZE)
+            if (skipped_add(session, read_u32(p), p + 4, p + 36)) {
+                rc = OMEMO_ESTORE;
+                break;
+            }
+    }
+    skipped_clear(blob, datalen);
     free(blob);
     if (rc) {
+        skipped_drop(session);
         free(session);
         signal_error(env, rc, "omemoDeserializeSession failed");
         return Qnil_v;
@@ -759,6 +878,34 @@ F_deserialize_session(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
     return env->make_user_ptr(env, free_session, session);
 }
 
+/*  jabber-omemo--legacy-session-blob-p  */
+
+static emacs_value
+F_legacy_session_blob_p(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
+                        void *data)
+{
+    (void)nargs; (void)data;
+
+    ptrdiff_t bloblen = 0;
+    env->copy_string_contents(env, args[0], NULL, &bloblen);
+    if (env->non_local_exit_check(env))
+        return Qnil_v;
+    uint8_t *blob = malloc((size_t)bloblen);
+    if (!blob) {
+        signal_error(env, -1, "malloc failed");
+        return Qnil_v;
+    }
+    if (!env->copy_string_contents(env, args[0], (char *)blob, &bloblen)) {
+        free(blob);
+        return Qnil_v;
+    }
+    emacs_value result = session_envelope_p(blob, (size_t)bloblen - 1)
+                         ? Qnil_v : Qt_v;
+    skipped_clear(blob, (size_t)bloblen - 1);
+    free(blob);
+    return result;
+}
+
 /*  jabber-omemo--encrypt-key  */
 
 static emacs_value
@@ -767,7 +914,7 @@ F_encrypt_key(emacs_env *env, ptrdiff_t nargs, emacs_value 
*args,
 {
     (void)nargs; (void)data;
 
-    struct omemoSession *session = env->get_user_ptr(env, args[0]);
+    struct native_session *native = extract_session(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
@@ -780,7 +927,7 @@ F_encrypt_key(emacs_env *env, ptrdiff_t nargs, emacs_value 
*args,
     struct omemoKeyMessage msg;
     memset(&msg, 0, sizeof(msg));
 
-    int rc = omemoEncryptKey(session, &msg, keybuf, keylen);
+    int rc = omemoEncryptKey(&native->session, &msg, keybuf, keylen);
     if (rc) {
         signal_error(env, rc, "omemoEncryptKey failed");
         return Qnil_v;
@@ -804,11 +951,11 @@ F_decrypt_key(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoSession *session = env->get_user_ptr(env, args[0]);
+    struct native_session *native = extract_session(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
-    struct omemoStore *store = env->get_user_ptr(env, args[1]);
+    struct omemoStore *store = extract_store(env, args[1]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
@@ -835,13 +982,33 @@ F_decrypt_key(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
     uint8_t key[OMEMO_KEYSIZE];
     size_t keyn = sizeof(key);
 
-    int rc = omemoDecryptKey(session, store, key, &keyn,
+    size_t old_count = native->count;
+    struct skipped_key *old_keys = NULL;
+    if (old_count) {
+        old_keys = malloc(old_count * sizeof *old_keys);
+        if (!old_keys) {
+            free(msgbuf);
+            signal_error(env, OMEMO_ESTORE, "cannot snapshot skipped keys");
+            return Qnil_v;
+        }
+        memcpy(old_keys, native->keys, old_count * sizeof *old_keys);
+    }
+    native->decrypt_new_count = 0;
+    int rc = omemoDecryptKey(&native->session, store, key, &keyn,
                              isprekey, msgbuf, msglen);
+    native->decrypt_new_count = 0;
     free(msgbuf);
     if (rc) {
+        skipped_drop(native);
+        native->keys = old_keys;
+        native->count = native->cap = old_count;
         signal_error(env, rc, "omemoDecryptKey failed");
         return Qnil_v;
     }
+    if (old_keys) {
+        skipped_clear(old_keys, old_count * sizeof *old_keys);
+        free(old_keys);
+    }
 
     return make_unibyte(env, key, keyn);
 }
@@ -854,20 +1021,19 @@ F_session_skipped_keys(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 {
     (void)nargs; (void)data;
 
-    struct omemoSession *session = env->get_user_ptr(env, args[0]);
+    struct native_session *session = extract_session(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
-    struct session_skipped *e = skipped_find(session, 0);
-    if (!e || !e->count)
+    if (!session->count)
         return Qnil_v;
-    size_t count = e->count;
+    size_t count = session->count;
     struct skipped_key *snapshot = malloc(count * sizeof *snapshot);
     if (!snapshot && count) {
         signal_error(env, OMEMO_ESTORE, "cannot snapshot skipped keys");
         return Qnil_v;
     }
-    memcpy(snapshot, e->keys, count * sizeof *snapshot);
+    memcpy(snapshot, session->keys, count * sizeof *snapshot);
 
     emacs_value Qlist = env->intern(env, "list");
     emacs_value Qcons = env->intern(env, "cons");
@@ -890,48 +1056,164 @@ F_session_skipped_keys(emacs_env *env, ptrdiff_t nargs, 
emacs_value *args,
 
 /*  jabber-omemo--session-set-skipped-keys  */
 
+struct skipped_key_symbols {
+    emacs_value car, cdr, consp, integerp, stringp;
+};
+
+static int
+intern_skipped_key_symbols(emacs_env *env, struct skipped_key_symbols *symbols)
+{
+    symbols->car = env->intern(env, "car");
+    if (env->non_local_exit_check(env))
+        return -1;
+    symbols->cdr = env->intern(env, "cdr");
+    if (env->non_local_exit_check(env))
+        return -1;
+    symbols->consp = env->intern(env, "consp");
+    if (env->non_local_exit_check(env))
+        return -1;
+    symbols->integerp = env->intern(env, "integerp");
+    if (env->non_local_exit_check(env))
+        return -1;
+    symbols->stringp = env->intern(env, "stringp");
+    return env->non_local_exit_check(env) ? -1 : 0;
+}
+
+static int
+emacs_truth(emacs_env *env, emacs_value value, bool *result)
+{
+    *result = env->is_not_nil(env, value);
+    return env->non_local_exit_check(env) ? -1 : 0;
+}
+
+static int
+take_cons(emacs_env *env, const struct skipped_key_symbols *symbols,
+          emacs_value *list, emacs_value *value, const char *message)
+{
+    emacs_value proper = env->funcall(env, symbols->consp, 1, list);
+    if (env->non_local_exit_check(env))
+        return -1;
+    bool is_cons;
+    if (emacs_truth(env, proper, &is_cons))
+        return -1;
+    if (!is_cons) {
+        signal_error(env, OMEMO_EPARAM, message);
+        return -1;
+    }
+    *value = env->funcall(env, symbols->car, 1, list);
+    if (env->non_local_exit_check(env))
+        return -1;
+    *list = env->funcall(env, symbols->cdr, 1, list);
+    return env->non_local_exit_check(env) ? -1 : 0;
+}
+
+static int
+require_type(emacs_env *env, emacs_value predicate, emacs_value value,
+             const char *message)
+{
+    emacs_value valid = env->funcall(env, predicate, 1, &value);
+    if (env->non_local_exit_check(env))
+        return -1;
+    bool matches;
+    if (emacs_truth(env, valid, &matches))
+        return -1;
+    if (!matches) {
+        signal_error(env, OMEMO_EPARAM, message);
+        return -1;
+    }
+    return 0;
+}
+
+static int
+parse_skipped_key(emacs_env *env,
+                  const struct skipped_key_symbols *symbols,
+                  emacs_value entry, struct skipped_key *key)
+{
+    emacs_value nr, dh, mk;
+    if (take_cons(env, symbols, &entry, &nr,
+                  "malformed skipped key entry") ||
+        take_cons(env, symbols, &entry, &dh,
+                  "malformed skipped key entry") ||
+        take_cons(env, symbols, &entry, &mk,
+                  "malformed skipped key entry"))
+        return -1;
+    bool has_tail;
+    if (emacs_truth(env, entry, &has_tail))
+        return -1;
+    if (has_tail) {
+        signal_error(env, OMEMO_EPARAM, "malformed skipped key entry");
+        return -1;
+    }
+    if (require_type(env, symbols->integerp, nr,
+                     "skipped key message number must be an integer") ||
+        require_type(env, symbols->stringp, dh,
+                     "skipped dh key must be a string") ||
+        require_type(env, symbols->stringp, mk,
+                     "skipped message key must be a string"))
+        return -1;
+
+    intmax_t number = env->extract_integer(env, nr);
+    if (env->non_local_exit_check(env))
+        return -1;
+    if (number < 0 || number > UINT32_MAX) {
+        signal_error(env, OMEMO_EPARAM,
+                     "skipped key message number is out of range");
+        return -1;
+    }
+    uint8_t dh_buf[33], mk_buf[33];
+    if (extract_exact_unibyte(env, dh, dh_buf, 32,
+                              "skipped dh key must be exactly 32 bytes") ||
+        extract_exact_unibyte(env, mk, mk_buf, 32,
+                              "skipped message key must be exactly 32 bytes"))
+        return -1;
+    key->nr = (uint32_t)number;
+    memcpy(key->dh, dh_buf, sizeof key->dh);
+    memcpy(key->mk, mk_buf, sizeof key->mk);
+    return 0;
+}
+
 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]);
+    struct native_session *session = extract_session(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)) {
+    struct skipped_key_symbols symbols;
+    if (intern_skipped_key_symbols(env, &symbols))
+        return Qnil_v;
+    struct native_session temporary = {0};
+    emacs_value l = args[1];
+
+    for (;;) {
+        bool more;
+        if (emacs_truth(env, l, &more))
+            goto fail;
+        if (!more)
+            break;
+        emacs_value entry;
+        struct skipped_key key;
+        if (take_cons(env, &symbols, &l, &entry,
+                      "skipped keys must be a proper list") ||
+            parse_skipped_key(env, &symbols, entry, &key))
+            goto fail;
+        if (skipped_add(&temporary, key.nr, key.dh, key.mk)) {
             signal_error(env, OMEMO_ESTORE, "cannot store skipped key");
-            return Qnil_v;
+            goto fail;
         }
     }
+
+    skipped_drop(session);
+    session->keys = temporary.keys;
+    session->count = temporary.count;
+    session->cap = temporary.cap;
+    return Qnil_v;
+
+fail:
+    skipped_drop(&temporary);
     return Qnil_v;
 }
 
@@ -943,18 +1225,18 @@ F_heartbeat(emacs_env *env, ptrdiff_t nargs, emacs_value 
*args,
 {
     (void)nargs; (void)data;
 
-    struct omemoSession *session = env->get_user_ptr(env, args[0]);
+    struct native_session *native = extract_session(env, args[0]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
-    struct omemoStore *store = env->get_user_ptr(env, args[1]);
+    struct omemoStore *store = extract_store(env, args[1]);
     if (env->non_local_exit_check(env))
         return Qnil_v;
 
     struct omemoKeyMessage msg;
     memset(&msg, 0, sizeof(msg));
 
-    int rc = omemoHeartbeat(session, store, &msg);
+    int rc = omemoHeartbeat(&native->session, store, &msg);
     if (rc) {
         signal_error(env, rc, "omemoHeartbeat failed");
         return Qnil_v;
@@ -1242,6 +1524,10 @@ emacs_module_init(struct emacs_runtime *runtime)
           "Deserialize BLOB into an OMEMO session object.\n"
           "Returns a session user-ptr; freed automatically by GC.");
 
+    DEFUN("jabber-omemo--legacy-session-blob-p",
+          F_legacy_session_blob_p, 1, 1,
+          "Return non-nil when BLOB uses the legacy raw session format.");
+
     DEFUN("jabber-omemo--encrypt-key", F_encrypt_key, 2, 2,
           "Encrypt KEY for a recipient using SESSION-PTR.\n"
           "KEY is a unibyte string (the message encryption key).\n"
diff --git a/src/picomemo/omemo.c b/src/picomemo/omemo.c
index 44f63b5844..0e393a2759 100644
--- a/src/picomemo/omemo.c
+++ b/src/picomemo/omemo.c
@@ -170,8 +170,8 @@ struct ProtobufField {
 /**
  * Parse Protobuf varint.
  *
- * Only supports uint32, higher bits are skipped so it will neither
- * overflow nor clamp to UINT32_MAX.
+ * Only supports uint32.  Encodings wider than five bytes and values
+ * whose fifth byte exceeds the remaining four bits are rejected.
  *
  * @param s points to the location of the varint in the protobuf data
  * @param e points to the end of the protobuf data
@@ -182,15 +182,18 @@ struct ProtobufField {
  */
 static const uint8_t *ParseVarInt(const uint8_t *s, const uint8_t *e,
                                   uint32_t *v) {
-  int i = 0;
   *v = 0;
-  do {
+  for (unsigned int i = 0; i < 5; i++) {
     if (s >= e)
       return NULL;
-    *v |= (*s & 0x7f) << i;
-    i += 7;
-  } while (*s++ & 0x80);
-  return s;
+    uint8_t byte = *s++;
+    if (i == 4 && (byte & 0x7f) > 0x0f)
+      return NULL;
+    *v |= (uint32_t)(byte & 0x7f) << (7 * i);
+    if (!(byte & 0x80))
+      return s;
+  }
+  return NULL;
 }
 
 /**
@@ -238,6 +241,8 @@ static bool ParseProtobuf(const uint8_t *s, size_t n,
       return true;
     fields[id].v = v;
     if (type == PB_LEN) {
+      if ((size_t)(e - s) < fields[id].v)
+        return true;
       fields[id].p = s;
       s += fields[id].v;
     }
@@ -268,6 +273,8 @@ static bool ParseRepeatingField(const uint8_t *s, const 
uint8_t *e,
     if (id == fieldid)
       field->v = v;
     if (type == PB_LEN) {
+      if ((size_t)(e - s) < v)
+        return true;
       if (id == fieldid)
         field->p = s;
       s += v;
diff --git a/tests/jabber-test-mam.el b/tests/jabber-test-mam.el
index 394d9a8d89..d58cca58e6 100644
--- a/tests/jabber-test-mam.el
+++ b/tests/jabber-test-mam.el
@@ -14,6 +14,7 @@
 (require 'jabber-mam)
 (require 'jabber-core)
 (require 'jabber-message-correct)
+(require 'jabber-omemo-store)
 
 ;;; Test infrastructure
 
@@ -187,6 +188,39 @@ When COMPLETE is non-nil, mark the archive as fully 
consumed."
           ;; Batched should be under 2 seconds for 500 messages
           (should (< batched-time 2.0)))))))
 
+(ert-deftest jabber-test-mam-encrypted-session-save-in-transaction ()
+  "An encrypted MAM message can migrate its session inside the MAM 
transaction."
+  (jabber-test-mam-with-db
+    (let* ((jc (jabber-test-mam--make-fake-jc "[email protected]"))
+           (jabber-mam--syncing (list (cons jc jabber-test-mam-queryid)))
+           (jabber-muc-participants nil)
+           (stanza (jabber-test-mam--make-message 1))
+           (inner (nth 2 (jabber-mam--parse-result stanza)))
+           (envelope (unibyte-string ?J ?O ?M ?E ?M ?O 0 1 0))
+           (body (car (jabber-xml-get-children inner 'body))))
+      (setcdr (last inner) '((encrypted ((xmlns . 
"eu.siacs.conversations.axolotl")))))
+      (sqlite-execute jabber-db--connection "\
+INSERT INTO omemo_skipped_keys
+  (account, jid, device_id, dh_key, message_number, message_key, created_at)
+  VALUES ('[email protected]', '[email protected]', 7, 'dh', 3, 'mk', 0)")
+      (sqlite-execute jabber-db--connection "BEGIN")
+      (cl-letf (((symbol-function 'jabber-chat--decrypt-if-needed)
+                 (lambda (_jc message)
+                   (jabber-omemo-store-save-session-and-clear-legacy-keys
+                    "[email protected]" "[email protected]" 7 envelope)
+                   (setcdr (cdr body) '("decrypted"))
+                   message)))
+        (jabber-mam--process-message jc stanza))
+      (sqlite-execute jabber-db--connection "COMMIT")
+      (should (equal envelope
+                     (jabber-omemo-store-load-session
+                      "[email protected]" "[email protected]" 7)))
+      (should-not (jabber-omemo-store-all-skipped-keys
+                   "[email protected]" "[email protected]" 7))
+      (should (equal "decrypted"
+                     (caar (sqlite-select jabber-db--connection "\
+SELECT body FROM message WHERE stanza_id = 'stanza-000001'")))))))
+
 ;;; Group 4: Parse helpers
 
 (ert-deftest jabber-test-mam-parse-result ()
diff --git a/tests/jabber-test-omemo-message.el 
b/tests/jabber-test-omemo-message.el
index 62199f3ec0..f5354f958c 100644
--- a/tests/jabber-test-omemo-message.el
+++ b/tests/jabber-test-omemo-message.el
@@ -510,8 +510,7 @@ 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.
+    ;; Use a real session pointer because the C module rejects placeholders.
     (puthash (jabber-omemo--session-key "[email protected]" "[email protected]" 
999)
              (jabber-omemo-make-session) jabber-omemo--sessions)
     (cl-letf (((symbol-function 'jabber-connection-bare-jid)
diff --git a/tests/jabber-test-omemo-module.el 
b/tests/jabber-test-omemo-module.el
index d8a9d088e0..3e45b39da3 100644
--- a/tests/jabber-test-omemo-module.el
+++ b/tests/jabber-test-omemo-module.el
@@ -63,6 +63,35 @@
          (blob2 (jabber-omemo--serialize-store ptr)))
     (should (string= blob1 blob2))))
 
+(ert-deftest jabber-test-omemo-module-protobuf-rejects-bad-varints ()
+  "Deserialization rejects oversized and unterminated uint32 varints."
+  (dolist (blob (list (unibyte-string #x08 #x80 #x80 #x80 #x80 #x80 #x00)
+                      (unibyte-string #x08 #x80 #x80 #x80 #x80 #x80)
+                      (unibyte-string #x08 #x80 #x80 #x80 #x80 #x10)))
+    (should-error (jabber-omemo--deserialize-store blob)
+                  :type 'jabber-omemo-error)))
+
+(ert-deftest jabber-test-omemo-module-protobuf-rejects-truncated-field ()
+  "Deserialization rejects a length field beyond the remaining input."
+  (should-error
+   (jabber-omemo--deserialize-store (unibyte-string #x12 #x20))
+   :type 'jabber-omemo-error))
+
+(ert-deftest jabber-test-omemo-module-pointer-kinds-are-distinct ()
+  "Store and session wrappers reject the other native pointer kind."
+  (let ((store (jabber-omemo--deserialize-store (jabber-omemo--setup-store)))
+        (session (jabber-omemo--make-session)))
+    (dolist (form (list (lambda () (jabber-omemo--serialize-store session))
+                        (lambda () (jabber-omemo--get-bundle session))
+                        (lambda () (jabber-omemo--serialize-session store))
+                        (lambda () (jabber-omemo--used-pre-key-id store))
+                        (lambda () (jabber-omemo--encrypt-key store
+                                                              (make-string 32 
0)))
+                        (lambda () (jabber-omemo--decrypt-key
+                                    session session nil "bad"))
+                        (lambda () (jabber-omemo--heartbeat session session))))
+      (should-error (funcall form) :type 'jabber-omemo-error))))
+
 ;;; Group 3: Bundle extraction
 
 (ert-deftest jabber-test-omemo-module-get-bundle-plist-keys ()
@@ -184,6 +213,19 @@
                (plist-get enc :ciphertext))))
     (should (string= msg dec))))
 
+(ert-deftest jabber-test-omemo-module-decrypt-requires-exact-key-and-iv ()
+  "Message decryption rejects short and oversized keys and IVs."
+  (let* ((enc (jabber-omemo--encrypt-message "payload"))
+         (key (plist-get enc :key))
+         (iv (plist-get enc :iv))
+         (ciphertext (plist-get enc :ciphertext)))
+    (dolist (bad-key (list (substring key 1) (concat key "x")))
+      (should-error (jabber-omemo--decrypt-message bad-key iv ciphertext)
+                    :type 'jabber-omemo-error))
+    (dolist (bad-iv (list (substring iv 1) (concat iv "x")))
+      (should-error (jabber-omemo--decrypt-message key bad-iv ciphertext)
+                    :type 'jabber-omemo-error))))
+
 (ert-deftest jabber-test-omemo-module-decrypt-wrong-key-signals-error ()
   "Decrypting with a wrong key signals jabber-omemo-error."
   (let* ((msg (encode-coding-string "secret" 'utf-8))
@@ -259,6 +301,49 @@
     (should (user-ptrp session2))
     (should (string= blob1 blob2))))
 
+(defun jabber-test-omemo-module--set-u32 (string offset value)
+  "Store big-endian VALUE in STRING at OFFSET."
+  (dotimes (i 4)
+    (aset string (+ offset i) (logand 255 (ash value (* -8 (- 3 i))))))
+  string)
+
+(ert-deftest jabber-test-omemo-module-session-envelope-validation ()
+  "Malformed envelopes, excess keys, and trailing data are rejected."
+  (let* ((session (jabber-omemo--make-session))
+         (blob (jabber-omemo--serialize-session session))
+         (bad-version (copy-sequence blob))
+         (too-many (copy-sequence blob)))
+    (aset bad-version 11 2)
+    (jabber-test-omemo-module--set-u32 too-many 16 1001)
+    (dolist (bad (list bad-version too-many (concat blob "x")))
+      (should-error (jabber-omemo--deserialize-session bad)
+                    :type 'jabber-omemo-error))))
+
+(ert-deftest jabber-test-omemo-module-session-legacy-blob-loads ()
+  "The raw picomemo payload remains a valid legacy session blob."
+  (let* ((session (jabber-omemo--make-session))
+         (blob (jabber-omemo--serialize-session session))
+         (raw-size (+ (ash (aref blob 12) 24)
+                      (ash (aref blob 13) 16)
+                      (ash (aref blob 14) 8)
+                      (aref blob 15)))
+         (raw (substring blob 20 (+ 20 raw-size))))
+    (should-not (jabber-omemo--legacy-session-blob-p blob))
+    (should (jabber-omemo--legacy-session-blob-p raw))
+    (should (user-ptrp (jabber-omemo--deserialize-session raw)))))
+
+(ert-deftest jabber-test-omemo-module-skipped-key-fifo-limit ()
+  "A session retains the newest 1000 imported skipped keys in FIFO order."
+  (let* ((session (jabber-omemo--make-session))
+         (dh (make-string 32 ?d))
+         (keys (cl-loop for i below 1001
+                        collect (list i dh (make-string 32 ?m)))))
+    (jabber-omemo--session-set-skipped-keys session keys)
+    (let ((stored (jabber-omemo--session-skipped-keys session)))
+      (should (= 1000 (length stored)))
+      (should (= 1 (caar stored)))
+      (should (= 1000 (car (car (last stored))))))))
+
 (ert-deftest jabber-test-omemo-module-initiate-session-bad-signature ()
   "initiate-session with a bad signature signals an error."
   (let* ((alice-blob (jabber-omemo--setup-store))
@@ -278,6 +363,28 @@
       (car pk))
      :type 'jabber-omemo-error)))
 
+(ert-deftest jabber-test-omemo-module-initiate-requires-exact-inputs ()
+  "Session initiation rejects short and oversized cryptographic inputs."
+  (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))
+         (signature (plist-get bundle :signature))
+         (spk (plist-get bundle :signed-pre-key))
+         (ik (plist-get bundle :identity-key))
+         (pk (car (plist-get bundle :pre-keys)))
+         (inputs (list signature spk ik (cdr pk))))
+    (dotimes (index 4)
+      (dolist (bad (list (substring (nth index inputs) 1)
+                         (concat (nth index inputs) "x")))
+        (let ((changed (copy-sequence inputs)))
+          (setf (nth index changed) bad)
+          (should-error
+           (apply #'jabber-omemo--initiate-session
+                  alice (append changed
+                                (list (plist-get bundle :signed-pre-key-id)
+                                      (car pk))))
+           :type 'jabber-omemo-error))))))
+
 ;;; Group 7: Key encrypt/decrypt round-trip
 
 (ert-deftest jabber-test-omemo-module-encrypt-key-returns-plist ()
@@ -349,6 +456,19 @@
                      bob-session bob is-prekey enc-data)))
     (should (string= original-key decrypted))))
 
+(ert-deftest jabber-test-omemo-module-decrypt-key-rejects-bad-protobuf ()
+  "Key decryption rejects hostile varints and truncated length fields."
+  (let ((store (jabber-omemo--deserialize-store (jabber-omemo--setup-store)))
+        (session (jabber-omemo--make-session)))
+    (dolist (message
+             (list (unibyte-string #x33 #x08 #x80 #x80 #x80 #x80 #x80 #x00)
+                   (unibyte-string #x33 #x08 #x80 #x80 #x80 #x80 #x80)
+                   (unibyte-string #x33 #x08 #x80 #x80 #x80 #x80 #x10)
+                   (unibyte-string #x33 #x12 #x20)))
+      (should-error
+       (jabber-omemo--decrypt-key session store t message)
+       :type 'jabber-omemo-error))))
+
 (ert-deftest jabber-test-omemo-module-consecutive-messages-are-pre-key ()
   "Consecutive messages from initiator stay pre-key until reply."
   (let* ((alice-blob (jabber-omemo--setup-store))
@@ -537,6 +657,33 @@ Alice has initiated a session towards Bob's bundle."
     (jabber-omemo--session-set-skipped-keys session nil)
     (should (null (jabber-omemo--session-skipped-keys session)))))
 
+(ert-deftest jabber-test-omemo-module-skipped-key-import-is-atomic ()
+  "Rejected skipped-key imports preserve the session's original keys."
+  (let* ((session (jabber-omemo--make-session))
+         (dh (make-string 32 ?d))
+         (mk (make-string 32 ?m))
+         (original (list (list 7 dh mk)))
+         (bad-lists
+          (list (cons (list 8 dh mk) 9)
+                (list 8 dh)
+                (list 8 dh mk 'extra)
+                (list (cons 8 (cons dh mk)))
+                (list (list 'bad dh mk))
+                (list (list -1 dh mk))
+                (list (list (1+ #xffffffff) dh mk))
+                (list (list 8 'bad mk))
+                (list (list 8 dh 'bad))
+                (list (list 8 (make-string 31 ?d) mk))
+                (list (list 8 (make-string 33 ?d) mk))
+                (list (list 8 dh (make-string 31 ?m)))
+                (list (list 8 dh (make-string 33 ?m))))))
+    (jabber-omemo--session-set-skipped-keys session original)
+    (dolist (bad bad-lists)
+      (should-error (jabber-omemo--session-set-skipped-keys session bad)
+                    :type 'jabber-omemo-error)
+      (should (equal original
+                     (jabber-omemo--session-skipped-keys session))))))
+
 (ert-deftest jabber-test-omemo-module-skipped-keys-survive-finalizers ()
   "Enumerating skipped keys is safe while other sessions are finalized."
   (let* ((session (jabber-omemo--make-session))
@@ -619,14 +766,11 @@ Alice has initiated a session towards Bob's bundle."
       (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))
+      ;; Simulate a restart.  The session blob owns its skipped keys.
+      (let* ((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 (= 1 (length
+                      (jabber-omemo--session-skipped-keys restored))))
         (should (string= k1 (jabber-omemo--decrypt-key
                              restored bob
                              (plist-get m1 :pre-key-p)
diff --git a/tests/jabber-test-omemo-protocol.el 
b/tests/jabber-test-omemo-protocol.el
index e12d295c96..321d90ad15 100644
--- a/tests/jabber-test-omemo-protocol.el
+++ b/tests/jabber-test-omemo-protocol.el
@@ -670,19 +670,46 @@ skipped message keys, so no fresh-session fallback is 
needed."
 
 ;;; 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-legacy-session-migrates-atomically ()
+  "A raw session imports legacy keys and saves one self-contained blob."
+  (jabber-test-omemo-protocol-with-db
+    (cl-letf (((symbol-function 'jabber-connection-bare-jid)
+               (lambda (_jc) "[email protected]")))
+      (let* ((jc 'jc)
+             (session (jabber-omemo-make-session))
+             (envelope (jabber-omemo-serialize-session session))
+             (raw-size (+ (ash (aref envelope 12) 24)
+                          (ash (aref envelope 13) 16)
+                          (ash (aref envelope 14) 8)
+                          (aref envelope 15)))
+             (raw (substring envelope 20 (+ 20 raw-size)))
+             (dh (make-string 32 ?d))
+             (mk (make-string 32 ?m)))
+        (jabber-omemo-store-save-session
+         "[email protected]" "[email protected]" 111 raw)
+        (sqlite-execute jabber-db--connection "\
+INSERT INTO omemo_skipped_keys
+  (account, jid, device_id, dh_key, message_number, message_key, created_at)
+  VALUES (?, ?, ?, ?, ?, ?, ?)"
+                        (list "[email protected]" "[email protected]"
+                              111 dh 7 mk 0))
+        (let ((loaded (jabber-omemo--get-session
+                       jc "[email protected]" 111)))
+          (should (equal (list (list 7 dh mk))
+                         (jabber-omemo--session-skipped-keys loaded)))
+          (jabber-omemo--save-session jc "[email protected]" 111 loaded))
+        (let ((blob (jabber-omemo-store-load-session
+                     "[email protected]" "[email protected]" 111)))
+          (should-not (jabber-omemo--legacy-session-blob-p blob))
+          (should (= 1 (length
+                        (jabber-omemo--session-skipped-keys
+                         (jabber-omemo-deserialize-session blob))))))
+        (should-not (jabber-omemo-store-all-skipped-keys
+                     "[email protected]" "[email protected]" 111))))))
 
 (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."
+Skipped ratchet keys persist inside the session blob."
   (jabber-test-omemo-protocol-with-db
     (cl-letf (((symbol-function 'jabber-connection-bare-jid)
                (lambda (_jc) "[email protected]")))
@@ -711,8 +738,8 @@ Skipped ratchet keys persist in SQLite and reload with the 
session."
           (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))))
+        (should-not (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.
@@ -721,7 +748,7 @@ Skipped ratchet keys persist in SQLite and reload with the 
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.
+        ;; No separate skipped-key rows are written or consumed.
         (should (null (jabber-omemo-store-all-skipped-keys
                        "[email protected]" "[email protected]" 111)))))))
 
diff --git a/tests/jabber-test-omemo-store.el b/tests/jabber-test-omemo-store.el
index dc6140e67a..def664a5c6 100644
--- a/tests/jabber-test-omemo-store.el
+++ b/tests/jabber-test-omemo-store.el
@@ -276,59 +276,77 @@ Binds `jabber-db-path' to a temp file and tears down on 
exit."
         (should (cl-find 42 all :key (lambda (p) (plist-get p :device-id))))
         (should (cl-find 99 all :key (lambda (p) (plist-get p 
:device-id))))))))
 
-;;; Group 6: Skipped key CRUD
+;;; Group 6: Legacy skipped-key migration
 
-(ert-deftest jabber-test-omemo-store-skipped-key-save-load-roundtrip ()
-  "save + load round-trips a skipped key."
+(ert-deftest jabber-test-omemo-store-session-save-clears-legacy-keys ()
+  "Saving a self-contained session removes its legacy key rows."
   (jabber-test-omemo-store-with-db
     (let ((dh (encode-coding-string "dh-key-data" 'raw-text))
           (mk (encode-coding-string "msg-key-data" 'raw-text)))
-      (jabber-omemo-store-save-skipped-key "[email protected]" 
"[email protected]"
-                                            42 dh 7 mk)
-      (should (equal mk (jabber-omemo-store-load-skipped-key
-                         "[email protected]" "[email protected]" 42 dh 7))))))
-
-(ert-deftest jabber-test-omemo-store-skipped-key-load-unknown ()
-  "load returns nil for unknown skipped key."
+      (sqlite-execute jabber-db--connection "\
+INSERT INTO omemo_skipped_keys
+  (account, jid, device_id, dh_key, message_number, message_key, created_at)
+  VALUES (?, ?, ?, ?, ?, ?, ?)"
+                      (list "[email protected]" "[email protected]"
+                            42 dh 7 mk 0))
+      (jabber-omemo-store-save-session-and-clear-legacy-keys
+       "[email protected]" "[email protected]" 42 "session")
+      (should (equal "session" (jabber-omemo-store-load-session
+                                "[email protected]" "[email protected]" 42)))
+      (should-not (jabber-omemo-store-all-skipped-keys
+                   "[email protected]" "[email protected]" 42)))))
+
+(ert-deftest jabber-test-omemo-store-session-save-composes-with-transaction ()
+  "Session migration succeeds inside an existing transaction."
   (jabber-test-omemo-store-with-db
-    (should (null (jabber-omemo-store-load-skipped-key
-                   "[email protected]" "[email protected]" 42
-                   (encode-coding-string "x" 'raw-text) 0)))))
-
-(ert-deftest jabber-test-omemo-store-skipped-key-delete ()
-  "delete removes a skipped key after use."
+    (sqlite-execute jabber-db--connection "\
+INSERT INTO omemo_skipped_keys
+  (account, jid, device_id, dh_key, message_number, message_key, created_at)
+  VALUES ('[email protected]', '[email protected]', 42, 'dh', 7, 'mk', 0)")
+    (sqlite-execute jabber-db--connection "BEGIN")
+    (jabber-omemo-store-save-session-and-clear-legacy-keys
+     "[email protected]" "[email protected]" 42 "session")
+    (sqlite-execute jabber-db--connection "COMMIT")
+    (should (equal "session" (jabber-omemo-store-load-session
+                              "[email protected]" "[email protected]" 42)))
+    (should-not (jabber-omemo-store-all-skipped-keys
+                 "[email protected]" "[email protected]" 42))))
+
+(ert-deftest jabber-test-omemo-store-session-save-rolls-back-on-delete-error ()
+  "A failed legacy-key deletion restores the old session and key rows."
   (jabber-test-omemo-store-with-db
-    (let ((dh (encode-coding-string "dh" 'raw-text))
-          (mk (encode-coding-string "mk" 'raw-text)))
-      (jabber-omemo-store-save-skipped-key "[email protected]" 
"[email protected]"
-                                            42 dh 7 mk)
-      (jabber-omemo-store-delete-skipped-key "[email protected]" 
"[email protected]"
-                                              42 dh 7)
-      (should (null (jabber-omemo-store-load-skipped-key
-                     "[email protected]" "[email protected]" 42 dh 7))))))
-
-(ert-deftest jabber-test-omemo-store-skipped-key-delete-old ()
-  "delete-old-skipped-keys removes by age."
+    (jabber-omemo-store-save-session
+     "[email protected]" "[email protected]" 42 "old-session")
+    (sqlite-execute jabber-db--connection "\
+INSERT INTO omemo_skipped_keys
+  (account, jid, device_id, dh_key, message_number, message_key, created_at)
+  VALUES ('[email protected]', '[email protected]', 42, 'dh', 7, 'mk', 0)")
+    (cl-letf (((symbol-function 'jabber-omemo-store-delete-skipped-keys)
+               (lambda (&rest _) (error "injected deletion failure"))))
+      (should-error
+       (jabber-omemo-store-save-session-and-clear-legacy-keys
+        "[email protected]" "[email protected]" 42 "new-session")
+       :type 'error))
+    (should (equal "old-session" (jabber-omemo-store-load-session
+                                  "[email protected]" "[email protected]" 42)))
+    (should (= 1 (length (jabber-omemo-store-all-skipped-keys
+                          "[email protected]" "[email protected]" 42))))))
+
+(ert-deftest jabber-test-omemo-store-legacy-keys-load-fifo ()
+  "Legacy keys load chronologically with insertion order as a tie-breaker."
   (jabber-test-omemo-store-with-db
-    (let ((dh (encode-coding-string "dh" 'raw-text))
-          (mk (encode-coding-string "mk" 'raw-text))
-          (now (truncate (float-time))))
-      ;; Insert an old key by directly using SQL
+    (dolist (row '((30 "dh-3" "mk-3" 20)
+                   (10 "dh-1" "mk-1" 10)
+                   (20 "dh-2" "mk-2" 10)))
       (sqlite-execute jabber-db--connection "\
 INSERT INTO omemo_skipped_keys
-  (account, jid, device_id, dh_key, message_number, message_key, created_at)
-  VALUES (?, ?, ?, ?, ?, ?, ?)"
-        (list "[email protected]" "[email protected]" 42 dh 1 mk (- now 7200)))
-      ;; Insert a recent key
-      (jabber-omemo-store-save-skipped-key "[email protected]" 
"[email protected]"
-                                            42 dh 2 mk)
-      ;; Delete keys older than 1 hour
-      (jabber-omemo-store-delete-old-skipped-keys "[email protected]" 3600)
-      ;; Old key gone, recent key remains
-      (should (null (jabber-omemo-store-load-skipped-key
-                     "[email protected]" "[email protected]" 42 dh 1)))
-      (should (jabber-omemo-store-load-skipped-key
-               "[email protected]" "[email protected]" 42 dh 2)))))
+  (account, jid, device_id, message_number, dh_key, message_key, created_at)
+  VALUES ('[email protected]', '[email protected]', 42, ?, ?, ?, ?)"
+                      row))
+    (should (equal '(10 20 30)
+                   (mapcar #'car
+                           (jabber-omemo-store-all-skipped-keys
+                            "[email protected]" "[email protected]" 42))))))
 
 (provide 'jabber-test-omemo-store)
 

Reply via email to