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 ()