fix(logdb): harden encrypted log container crypto (P1 #38)

ober

ef4e7c34380583b4f302e9f9e965b0f202796503

diff --git a/signal/logdb.ss b/signal/logdb.ss
index 49efbe6..0ad5ea0 100644
--- a/signal/logdb.ss
+++ b/signal/logdb.ss
@@ -9,7 +9,8 @@
   (export logdb-available?
           logdb-open logdb-close logdb-put logdb-count logdb-recent
           logdb-migrate-legacy-to-jsqlite
-          logdb-prompt-passphrase)
+          logdb-prompt-passphrase
+          logdb-handle-key-zeroed?)
 
   (import (except (scheme)
                   make-hash-table hash-table?
@@ -37,7 +38,7 @@
 
   (defstruct logdb-handle (backend inner))
   (defstruct jlog
-    (path key salt db dirty? tx-open?
+    (path key salt generation db dirty? tx-open?
           row-count payload-bytes pending-rows pending-bytes last-persist-ms
           lock closed? flush-thread flush-stop-box))
 
@@ -172,6 +173,7 @@
   (def *jlog-magic* (string->utf8 "JSQLITELOGv1\n"))
   (def *jlog-salt-len* 16)
   (def *jlog-nonce-len* 12)
+  (def *jlog-generation-len* 8)
   (def *jlog-key-len* 32)
   (def *jlog-scrypt-n* 16384)
   (def *jlog-scrypt-r* 8)
@@ -294,24 +296,79 @@
     (log-scrypt-key passphrase salt *jlog-key-len*
                     *jlog-scrypt-n* *jlog-scrypt-r* *jlog-scrypt-p*))
 
-  (def (encrypt-container key salt plain)
-    (let* ([nonce (log-random-bytes *jlog-nonce-len*)]
-           [sealed (log-aead-seal key nonce plain *jlog-magic*)])
-      (bv-append *jlog-magic* salt nonce sealed)))
+  ;; The whole database is re-sealed under one long-lived key, so the nonce is a
+  ;; deterministic 64-bit counter (the persist generation) rather than a random
+  ;; value. A monotonic counter can never repeat a (key, nonce) pair, removing
+  ;; the AES-GCM/ChaCha20-Poly1305 birthday-bound nonce-reuse hazard.
+  (def (generation->bytes generation)
+    (let ([bv (make-bytevector *jlog-generation-len* 0)])
+      (bytevector-u64-set! bv 0 generation (endianness big))
+      bv))
+
+  (def (bytes->generation bv)
+    (bytevector-u64-ref bv 0 (endianness big)))
+
+  (def (generation->nonce generation)
+    (let ([nonce (make-bytevector *jlog-nonce-len* 0)])
+      (bytevector-u64-set! nonce (- *jlog-nonce-len* *jlog-generation-len*)
+                           generation (endianness big))
+      nonce))
+
+  ;; AAD binds the magic, per-container salt, nonce, and generation so a sealed
+  ;; blob cannot be transplanted across containers or generations.
+  (def (container-aad salt nonce generation-bytes)
+    (bv-append *jlog-magic* salt nonce generation-bytes))
+
+  (def (raise-passphrase-auth-error)
+    (raise
+      (condition
+        (make-who-condition 'logdb-passphrase-mismatch)
+        (make-message-condition
+          "encrypted log authentication failed (wrong passphrase or corrupted container)"))))
+
+  ;; Container layout: magic || salt || generation(8 BE) || AEAD(plain).
+  (def (encrypt-container key salt generation plain)
+    (let* ([nonce (generation->nonce generation)]
+           [generation-bytes (generation->bytes generation)]
+           [aad (container-aad salt nonce generation-bytes)]
+           [sealed (log-aead-seal key nonce plain aad)])
+      (bv-append *jlog-magic* salt generation-bytes sealed)))
 
   (def (decrypt-container bytes passphrase)
     (let* ([magic-len (bytevector-length *jlog-magic*)]
-           [need (+ magic-len *jlog-salt-len* *jlog-nonce-len*)])
+           [need (+ magic-len *jlog-salt-len* *jlog-generation-len*)])
       (when (< (bytevector-length bytes) need)
         (error 'logdb-open "encrypted jsqlite log is too short"))
       (let* ([salt (bv-sub bytes magic-len *jlog-salt-len*)]
-             [nonce (bv-sub bytes (+ magic-len *jlog-salt-len*) *jlog-nonce-len*)]
-             [sealed-start (+ magic-len *jlog-salt-len* *jlog-nonce-len*)]
+             [generation-bytes (bv-sub bytes (+ magic-len *jlog-salt-len*)
+                                       *jlog-generation-len*)]
+             [generation (bytes->generation generation-bytes)]
+             [nonce (generation->nonce generation)]
+             [sealed-start (+ magic-len *jlog-salt-len* *jlog-generation-len*)]
              [sealed (bv-sub bytes sealed-start
                              (- (bytevector-length bytes) sealed-start))]
              [key (derive-key passphrase salt)]
-             [plain (log-aead-open key nonce sealed *jlog-magic*)])
-        (values key salt plain))))
+             [aad (container-aad salt nonce generation-bytes)]
+             [plain (guard (e [(condition? e) (raise-passphrase-auth-error)])
+                      (log-aead-open key nonce sealed aad))])
+        (values key salt generation plain))))
+
+  ;; Anti-rollback high-water mark stored OUTSIDE the mutable container file.
+  (def (generation-marker-path path)
+    (string-append path ".gen"))
+
+  (def (read-generation-marker path)
+    (let ([marker-path (generation-marker-path path)])
+      (if (file-exists? marker-path)
+        (let ([bv (read-file-bytevector* marker-path)])
+          (if (>= (bytevector-length bv) *jlog-generation-len*)
+            (bytes->generation (bv-sub bv 0 *jlog-generation-len*))
+            0))
+        0)))
+
+  (def (write-generation-marker! path generation)
+    (write-file-atomic! (generation-marker-path path)
+                        (generation->bytes generation)))
 
   (def (ensure-schema! db)
     (for-each (lambda (sql) (sqlite-exec db sql)) *schema-sql*))
@@ -320,7 +377,7 @@
     (let* ([salt (log-random-bytes *jlog-salt-len*)]
            [key (derive-key passphrase salt)]
            [db (sqlite-open-bytevector (make-bytevector 0 0))]
-           [log (make-jlog path key salt db #f #f
+           [log (make-jlog path key salt 0 db #f #f
                            0 0 0 0 (real-time) (make-mutex) #f
                            #f (box #f))])
       (ensure-schema! db)
@@ -329,29 +386,43 @@
       (make-logdb-handle 'jsqlite log)))
 
   (def (open-jlog-container path passphrase bytes)
-    (let-values ([(key salt plain) (decrypt-container bytes passphrase)])
-      (let ([db (sqlite-open-bytevector plain)])
-        (ensure-schema! db)
-        (let-values ([(rows payload) (database-retention-stats db)])
-          (let ([log
-                 (make-jlog path key salt db #f #f
-                            rows payload 0 0 (real-time) (make-mutex) #f
-                            #f (box #f))])
-            (start-jlog-flusher! log)
-            (make-logdb-handle 'jsqlite log))))))
-
+    (let-values ([(key salt generation plain)
+                  (decrypt-container bytes passphrase)])
+      (let ([marker (read-generation-marker path)])
+        (when (< generation marker)
+          (error 'logdb-open
+                 "encrypted log generation regression detected (rollback)"
+                 path generation marker))
+        (write-generation-marker! path generation)
+        (let ([db (sqlite-open-bytevector plain)])
+          (ensure-schema! db)
+          (let-values ([(rows payload) (database-retention-stats db)])
+            (let ([log
+                   (make-jlog path key salt generation db #f #f
+                              rows payload 0 0 (real-time) (make-mutex) #f
+                              #f (box #f))])
+              (start-jlog-flusher! log)
+              (make-logdb-handle 'jsqlite log)))))))
+
+  ;; Fail closed: a wrong passphrase or a rolled-back/truncated container raises
+  ;; a distinct error instead of returning #f, so auto-mode never silently falls
+  ;; back to an unencrypted legacy log. Only a genuine non-container file (legacy
+  ;; SQLCipher) or a missing file yields #f / a fresh log.
   (def (open-jlog path passphrase)
     (and (log-crypto-available?)
-         (guard (e [(condition? e) #f])
-           (if (file-exists? path)
-             (let ([bytes (read-file-bytevector* path)])
-               (cond
-                 [(= (bytevector-length bytes) 0)
-                  (new-jlog path passphrase)]
-                 [(jlog-container-bytes? bytes)
-                  (open-jlog-container path passphrase bytes)]
-                 [else #f]))
-             (new-jlog path passphrase)))))
+         (if (file-exists? path)
+           (let ([bytes (read-file-bytevector* path)])
+             (cond
+               [(= (bytevector-length bytes) 0)
+                (if (> (read-generation-marker path) 0)
+                  (error 'logdb-open
+                         "encrypted log truncated to zero bytes; refusing to reset a prior generation"
+                         path)
+                  (new-jlog path passphrase))]
+               [(jlog-container-bytes? bytes)
+                (open-jlog-container path passphrase bytes)]
+               [else #f]))
+           (new-jlog path passphrase))))
 
   (def (safe-display x)
     (cond
@@ -445,14 +516,16 @@
                          (cons 'error-chars
                                (string-length (safe-display e)))))
                  (raise e)])
-        (let* ([plain-start (real-time)]
+        (let* ([generation (+ (jlog-generation log) 1)]
+               [plain-start (real-time)]
                [plain-alloc-start (allocated-bytes*)]
                [plain (sqlite-db->bytevector (jlog-db log))]
                [plain-alloc-end (allocated-bytes*)]
                [plain-ms (elapsed-ms plain-start)]
                [enc-start (real-time)]
                [enc-alloc-start (allocated-bytes*)]
-               [container (encrypt-container (jlog-key log) (jlog-salt log) plain)]
+               [container (encrypt-container (jlog-key log) (jlog-salt log)
+                                             generation plain)]
                [enc-alloc-end (allocated-bytes*)]
                [enc-ms (elapsed-ms enc-start)]
                [write-start (real-time)])
@@ -471,6 +544,8 @@
                         (alloc-delta enc-alloc-start enc-alloc-end))
                   (cons 'ms enc-ms)))
           (write-file-atomic! path container)
+          (write-generation-marker! path generation)
+          (jlog-generation-set! log generation)
           (jlog-dirty?-set! log #f)
           (jlog-pending-rows-set! log 0)
           (jlog-pending-bytes-set! log 0)
@@ -850,6 +925,7 @@
             (persist-jlog! log))
           ;; There is no separate statement handle to finalize before close.
           (close-jsqlite-database (jlog-db log))
+          (bytevector-fill! (jlog-key log) 0)
           (jlog-closed?-set! log #t))))
     (when (jlog-flush-thread log)
       (thread-join (jlog-flush-thread log))))
@@ -917,6 +993,18 @@
         "logdb-close-done"
         (list (cons 'backend (logdb-handle-backend handle))))))
 
+  ;; Audit/test hook: reports whether the derived AEAD key has been zeroed.
+  ;; Exposes only a boolean, never the key material itself.
+  (def (logdb-handle-key-zeroed? handle)
+    (and (logdb-handle? handle)
+         (eq? (logdb-handle-backend handle) 'jsqlite)
+         (let ([key (jlog-key (logdb-handle-inner handle))])
+           (and (bytevector? key)
+                (let loop ([i 0])
+                  (or (= i (bytevector-length key))
+                      (and (= (bytevector-u8-ref key i) 0)
+                           (loop (+ i 1)))))))))
+
   (def (logdb-put handle account direction conversation sender
                   timestamp kind body raw)
     (and (logdb-handle? handle)