fix(logdb): harden encrypted log container crypto (P1 #38)
ober
ef4e7c34380583b4f302e9f9e965b0f202796503
--- 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)