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

    omemo: Rotate signed pre-key periodically on connect
---
 CHANGELOG.org                      |  1 +
 lisp/jabber-db.el                  | 10 +++++--
 lisp/jabber-omemo-store.el         | 22 ++++++++++++--
 lisp/jabber-omemo.el               | 32 +++++++++++++++++++--
 tests/jabber-test-db.el            | 59 +++++++++++++++++++++++++++++++++++++-
 tests/jabber-test-omemo-message.el | 45 +++++++++++++++++++++++++++++
 tests/jabber-test-omemo-store.el   | 18 ++++++++++++
 7 files changed, 179 insertions(+), 8 deletions(-)

diff --git a/CHANGELOG.org b/CHANGELOG.org
index cc5337c0a6..ea8f91af08 100644
--- a/CHANGELOG.org
+++ b/CHANGELOG.org
@@ -3,6 +3,7 @@
 * [Unreleased]
 
 ** Added
+- OMEMO signed pre-keys now rotate automatically on connect 
(~jabber-omemo-signed-pre-key-rotation-period~, 7 days default)
 - 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/lisp/jabber-db.el b/lisp/jabber-db.el
index 158cf9f2aa..f23a36463d 100644
--- a/lisp/jabber-db.el
+++ b/lisp/jabber-db.el
@@ -128,7 +128,8 @@ END"
 END"
     "CREATE TABLE IF NOT EXISTS omemo_store (
   account TEXT PRIMARY KEY,
-  store_blob BLOB NOT NULL)"
+  store_blob BLOB NOT NULL,
+  spk_rotated_at INTEGER)"
     "CREATE TABLE IF NOT EXISTS omemo_sessions (
   account TEXT NOT NULL,
   jid TEXT NOT NULL,
@@ -262,7 +263,7 @@ WHERE updated_at < (
     (jabber-db--ensure-reaction-actor-table db)
     (jabber-db--backfill-reaction-actors db)))
 
-(defconst jabber-db--schema-version 5
+(defconst jabber-db--schema-version 6
   "Current schema version.
 Bump this when adding migrations.  A database whose version
 exceeds this value is from a newer (or development) build and
@@ -347,6 +348,11 @@ CREATE INDEX IF NOT EXISTS idx_reaction_message_id
       (sqlite-execute db "PRAGMA user_version=5")
       (setq version 5))
     (when (= version 5)
+      (sqlite-execute db
+                      "ALTER TABLE omemo_store ADD COLUMN spk_rotated_at 
INTEGER")
+      (sqlite-execute db "PRAGMA user_version=6")
+      (setq version 6))
+    (when (= version 6)
       (jabber-db--repair-reaction-actors db))))
 
 (defun jabber-db-ensure-open ()
diff --git a/lisp/jabber-omemo-store.el b/lisp/jabber-omemo-store.el
index 096265b4a9..7e045eee78 100644
--- a/lisp/jabber-omemo-store.el
+++ b/lisp/jabber-omemo-store.el
@@ -40,12 +40,30 @@ for the C module which expects unibyte."
 ;;; Store blob CRUD
 
 (defun jabber-omemo-store-save (account blob)
-  "Upsert serialized OMEMO store BLOB for ACCOUNT."
+  "Upsert serialized OMEMO store BLOB for ACCOUNT.
+Preserves the spk_rotated_at column; INSERT OR REPLACE would
+delete and re-insert the row, wiping it."
   (when-let* ((db (jabber-db-ensure-open)))
     (sqlite-execute db
-                   "INSERT OR REPLACE INTO omemo_store (account, store_blob) 
VALUES (?, ?)"
+                   "INSERT INTO omemo_store (account, store_blob) VALUES (?, ?)
+  ON CONFLICT(account) DO UPDATE SET store_blob = excluded.store_blob"
                    (list account blob))))
 
+(defun jabber-omemo-store-spk-rotated-at (account)
+  "Return ACCOUNT's signed pre-key rotation time as epoch seconds, or nil."
+  (when-let* ((db (jabber-db-ensure-open)))
+    (caar (sqlite-select db
+                        "SELECT spk_rotated_at FROM omemo_store WHERE account 
= ?"
+                        (list account)))))
+
+(defun jabber-omemo-store-set-spk-rotated-at (account time)
+  "Record TIME (epoch seconds) as ACCOUNT's signed pre-key rotation time.
+The account's store row must already exist."
+  (when-let* ((db (jabber-db-ensure-open)))
+    (sqlite-execute db
+                   "UPDATE omemo_store SET spk_rotated_at = ? WHERE account = 
?"
+                   (list time account))))
+
 (defun jabber-omemo-store-load (account)
   "Load serialized OMEMO store blob for ACCOUNT, or nil."
   (when-let* ((db (jabber-db-ensure-open)))
diff --git a/lisp/jabber-omemo.el b/lisp/jabber-omemo.el
index 69dedc6c83..cf39992e74 100644
--- a/lisp/jabber-omemo.el
+++ b/lisp/jabber-omemo.el
@@ -69,6 +69,14 @@ 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
+once a month.  The previous signed pre-key is retained for one
+rotation, so in-flight pre-key messages still decrypt."
+  :type 'integer
+  :group 'jabber)
+
 (defvar jabber-omemo--reconfigured-nodes (make-hash-table :test 'equal)
   "Nodes already reconfigured this session to prevent retry loops.")
 
@@ -1531,14 +1539,32 @@ Opens a tabulated-list buffer with interactive trust 
controls."
 
 ;;; Connect/disconnect hooks
 
+(defun jabber-omemo--maybe-rotate-signed-pre-key (jc)
+  "Rotate JC's signed pre-key when the rotation period has passed.
+On the first check for an account, record the current time as a
+baseline without rotating.  After a rotation, the bundle
+republish check on connect picks up the new signed pre-key id."
+  (let* ((account (jabber-connection-bare-jid jc))
+         (rotated-at (jabber-omemo-store-spk-rotated-at account))
+         (now (time-convert nil 'integer)))
+    (cond
+     ((null rotated-at)
+      (jabber-omemo-store-set-spk-rotated-at account now))
+     ((>= (- now rotated-at) jabber-omemo-signed-pre-key-rotation-period)
+      (jabber-omemo-rotate-signed-pre-key (jabber-omemo--get-store jc))
+      (jabber-omemo--persist-store jc)
+      (jabber-omemo-store-set-spk-rotated-at account now)
+      (message "OMEMO: rotated signed pre-key for %s" account)))))
+
 ;;;###autoload
 (defun jabber-omemo-on-connect (jc)
   "Post-connect hook on JC for OMEMO initialization.
-Loads or creates the store, ensures our device is listed,
-republishes our bundle if it's out of date, and pre-fetches
-sessions for open chat buffers."
+Loads or creates the store, rotates the signed pre-key when due,
+ensures our device is listed, republishes our bundle if it's out
+of date, and pre-fetches sessions for open chat buffers."
   (jabber-omemo--get-store jc)
   (jabber-omemo--get-device-id jc)
+  (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)
diff --git a/tests/jabber-test-db.el b/tests/jabber-test-db.el
index 13427ff57e..3150836232 100644
--- a/tests/jabber-test-db.el
+++ b/tests/jabber-test-db.el
@@ -1250,7 +1250,10 @@ the corrected jabber-muc-create-buffer order."
     "CREATE INDEX IF NOT EXISTS idx_msg_stanza_id
   ON message(account, stanza_id) WHERE stanza_id IS NOT NULL"
     "CREATE INDEX IF NOT EXISTS idx_msg_server_id
-  ON message(account, server_id) WHERE server_id IS NOT NULL")
+  ON message(account, server_id) WHERE server_id IS NOT NULL"
+    "CREATE TABLE IF NOT EXISTS omemo_store (
+  account TEXT PRIMARY KEY,
+  store_blob BLOB NOT NULL)")
   "V1 schema DDL for migration tests.")
 
 (defmacro jabber-test-db-with-v1-db (&rest body)
@@ -1585,6 +1588,10 @@ INSERT INTO message (account, peer, direction, type, 
body, timestamp,
   stanza_id)
 VALUES ('[email protected]', '[email protected]', 'in', 'chat', 'text', 1001,
   'sid-2')")
+            (sqlite-execute db "\
+CREATE TABLE omemo_store (
+  account TEXT PRIMARY KEY,
+  store_blob BLOB NOT NULL)")
             (sqlite-execute db "PRAGMA user_version=2")
             (sqlite-close db))
           ;; Open with migration.
@@ -1614,6 +1621,52 @@ VALUES ('[email protected]', '[email protected]', 'in', 'chat', 
'text', 1001,
       (when (file-directory-p jabber-test-db--dir)
         (delete-directory jabber-test-db--dir t)))))
 
+(ert-deftest jabber-test-db-migration-v5-to-v6 ()
+  "Migration v5->v6 adds spk_rotated_at and preserves store blobs."
+  (let* ((jabber-test-db--dir (make-temp-file "jabber-db-test" t))
+         (jabber-db-path (expand-file-name "test.sqlite" jabber-test-db--dir))
+         (jabber-db--connection nil))
+    (unwind-protect
+        (progn
+          ;; Create a minimal v5 database with a store row.  The
+          ;; reaction tables satisfy the post-migration repair step.
+          (let ((db (sqlite-open jabber-db-path)))
+            (sqlite-execute db "\
+CREATE TABLE omemo_store (
+  account TEXT PRIMARY KEY,
+  store_blob BLOB NOT NULL)")
+            (sqlite-execute db "\
+INSERT INTO omemo_store (account, store_blob) VALUES ('[email protected]', x'0102')")
+            (sqlite-execute db "\
+CREATE TABLE message_reaction (
+  message_id INTEGER NOT NULL,
+  sender     TEXT NOT NULL,
+  reaction   TEXT NOT NULL,
+  updated_at INTEGER NOT NULL,
+  PRIMARY KEY (message_id, sender, reaction))")
+            (sqlite-execute db "\
+CREATE TABLE message_reaction_actor (
+  message_id INTEGER NOT NULL,
+  sender     TEXT NOT NULL,
+  updated_at INTEGER NOT NULL,
+  PRIMARY KEY (message_id, sender))")
+            (sqlite-execute db "PRAGMA user_version=5")
+            (sqlite-close db))
+          (jabber-db-ensure-open)
+          (should (= jabber-db--schema-version
+                     (caar (sqlite-select jabber-db--connection
+                                          "PRAGMA user_version"))))
+          (let ((cols (mapcar #'car
+                              (sqlite-select jabber-db--connection
+                                "SELECT name FROM 
pragma_table_info('omemo_store')"))))
+            (should (member "spk_rotated_at" cols)))
+          (should (equal "\x01\x02"
+                         (caar (sqlite-select jabber-db--connection
+                                "SELECT store_blob FROM omemo_store WHERE 
account = '[email protected]'")))))
+      (jabber-db-close)
+      (when (file-directory-p jabber-test-db--dir)
+        (delete-directory jabber-test-db--dir t)))))
+
 (ert-deftest jabber-test-db-oob-dedup-replacement ()
   "Failed-decrypt replacement updates OOB entries."
   (jabber-test-db-with-db
@@ -1688,6 +1741,10 @@ VALUES (1, '[email protected]', '[email protected]', 
'in', 'chat', 'hello', 1000,
             (sqlite-execute db "\
 INSERT INTO message_reaction (message_id, sender, reaction, updated_at)
 VALUES (1, '[email protected]', '👍', 1001)")
+            (sqlite-execute db "\
+CREATE TABLE omemo_store (
+  account TEXT PRIMARY KEY,
+  store_blob BLOB NOT NULL)")
             (sqlite-execute db "PRAGMA user_version=5")
             (sqlite-close db))
           (jabber-db-ensure-open)
diff --git a/tests/jabber-test-omemo-message.el 
b/tests/jabber-test-omemo-message.el
index 4da1869207..903ac8d2f0 100644
--- a/tests/jabber-test-omemo-message.el
+++ b/tests/jabber-test-omemo-message.el
@@ -677,5 +677,50 @@ SENT-VAR is bound to the stanza passed to 
`jabber-send-sexp'."
       (should sent)
       (should-not (jabber-xml-get-children sent 'probe)))))
 
+;;; Group 12: Signed pre-key rotation
+
+(defmacro jabber-test-omemo-message--with-rotation-stubs (rotated-var &rest 
body)
+  "Run BODY with rotation collaborators stubbed.
+ROTATED-VAR is bound to non-nil when a rotation was performed."
+  (declare (indent 1) (debug t))
+  `(jabber-test-omemo-message-with-db
+     (let ((,rotated-var nil))
+       (cl-letf (((symbol-function 'jabber-connection-bare-jid)
+                  (lambda (_jc) "[email protected]"))
+                 ((symbol-function 'jabber-omemo--get-store)
+                  (lambda (_jc) 'fake-store))
+                 ((symbol-function 'jabber-omemo-rotate-signed-pre-key)
+                  (lambda (_store) (setq ,rotated-var t)))
+                 ((symbol-function 'jabber-omemo--persist-store)
+                  (lambda (_jc) nil)))
+         (jabber-omemo-store-save "[email protected]" (unibyte-string 1))
+         ,@body))))
+
+(ert-deftest jabber-test-omemo-message-spk-rotation-records-baseline ()
+  "First rotation check records a timestamp without rotating."
+  (jabber-test-omemo-message--with-rotation-stubs rotated
+    (jabber-omemo--maybe-rotate-signed-pre-key 'fake-jc)
+    (should-not rotated)
+    (should (jabber-omemo-store-spk-rotated-at "[email protected]"))))
+
+(ert-deftest jabber-test-omemo-message-spk-rotation-skips-when-fresh ()
+  "A recent rotation timestamp is left alone."
+  (jabber-test-omemo-message--with-rotation-stubs rotated
+    (let ((now (time-convert nil 'integer)))
+      (jabber-omemo-store-set-spk-rotated-at "[email protected]" now)
+      (jabber-omemo--maybe-rotate-signed-pre-key 'fake-jc)
+      (should-not rotated)
+      (should (= now (jabber-omemo-store-spk-rotated-at 
"[email protected]"))))))
+
+(ert-deftest jabber-test-omemo-message-spk-rotation-rotates-when-due ()
+  "A timestamp older than the rotation period triggers a rotation."
+  (jabber-test-omemo-message--with-rotation-stubs rotated
+    (let* ((now (time-convert nil 'integer))
+           (stale (- now jabber-omemo-signed-pre-key-rotation-period 10)))
+      (jabber-omemo-store-set-spk-rotated-at "[email protected]" stale)
+      (jabber-omemo--maybe-rotate-signed-pre-key 'fake-jc)
+      (should rotated)
+      (should (> (jabber-omemo-store-spk-rotated-at "[email protected]") 
stale)))))
+
 (provide 'jabber-test-omemo-message)
 ;;; jabber-test-omemo-message.el ends here
diff --git a/tests/jabber-test-omemo-store.el b/tests/jabber-test-omemo-store.el
index 50eb2e7df9..dc6140e67a 100644
--- a/tests/jabber-test-omemo-store.el
+++ b/tests/jabber-test-omemo-store.el
@@ -78,6 +78,24 @@ Binds `jabber-db-path' to a temp file and tears down on 
exit."
     (jabber-omemo-store-delete "[email protected]")
     (should (null (jabber-omemo-store-load "[email protected]")))))
 
+(ert-deftest jabber-test-omemo-store-spk-rotated-at-roundtrip ()
+  "Signed pre-key rotation timestamp round-trips; nil when unset."
+  (jabber-test-omemo-store-with-db
+    (jabber-omemo-store-save "[email protected]" (unibyte-string 1 2))
+    (should (null (jabber-omemo-store-spk-rotated-at "[email protected]")))
+    (jabber-omemo-store-set-spk-rotated-at "[email protected]" 1234567)
+    (should (= 1234567 (jabber-omemo-store-spk-rotated-at 
"[email protected]")))))
+
+(ert-deftest jabber-test-omemo-store-save-preserves-spk-rotated-at ()
+  "Re-saving the store blob does not clear the rotation timestamp."
+  (jabber-test-omemo-store-with-db
+    (jabber-omemo-store-save "[email protected]" (unibyte-string 1 2))
+    (jabber-omemo-store-set-spk-rotated-at "[email protected]" 1234567)
+    (jabber-omemo-store-save "[email protected]" (unibyte-string 3 4))
+    (should (= 1234567 (jabber-omemo-store-spk-rotated-at "[email protected]")))
+    (should (equal (unibyte-string 3 4)
+                   (jabber-omemo-store-load "[email protected]")))))
+
 ;;; Group 3: Trust CRUD
 
 (ert-deftest jabber-test-omemo-store-trust-save-load-roundtrip ()

Reply via email to