Add encrypted SQLite message logging and an always-on daemon
ober
e758e145d590461f8d26750f3110d9f755ca2a7c
--- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ program_boot.h vendor/ signal_tui_shim.dylib signal_tui_shim.so +signal_log_shim.dylib +signal_log_shim.so +*.db --- a/Makefile +++ b/Makefile @@ -19,8 +19,10 @@ else TUI_SHIM_EXT := so endif TUI_SHIM := $(TUI_SHIM_DIR)/signal_tui_shim.$(TUI_SHIM_EXT) +LOG_SHIM := signal_log_shim.$(TUI_SHIM_EXT) +SQLCIPHER_PREFIX := $(shell brew --prefix sqlcipher 2>/dev/null) -.PHONY: all build binary run run-tui test install clean help vendor-deps tui-shim +.PHONY: all build binary run run-tui test install clean help vendor-deps tui-shim log-shim .DEFAULT_GOAL := help all: binary @@ -34,7 +36,7 @@ build: binary run: binary ./$(BIN) $(ARGS) -run-tui: binary tui-shim +run-tui: binary tui-shim log-shim ./$(BIN) tui $(ARGS) test: binary @@ -44,11 +46,13 @@ install: binary mkdir -p $(BIN_DIR) install -m 0755 $(BIN) $(BIN_DIR)/$(BIN) test ! -f signal_tui_shim.$(TUI_SHIM_EXT) || install -m 0755 signal_tui_shim.$(TUI_SHIM_EXT) $(BIN_DIR)/signal_tui_shim.$(TUI_SHIM_EXT) + test ! -f $(LOG_SHIM) || install -m 0755 $(LOG_SHIM) $(BIN_DIR)/$(LOG_SHIM) @echo "Installed $(BIN) to $(BIN_DIR)/$(BIN)" clean: rm -f $(BIN) rm -f signal_tui_shim.dylib signal_tui_shim.so + rm -f signal_log_shim.dylib signal_log_shim.so find signal \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true vendor-deps: vendor/termbox2 @@ -65,6 +69,19 @@ tui-shim: vendor/termbox2 signal/tui/signal_tui_shim.c cp $(TUI_SHIM) signal_tui_shim.$(TUI_SHIM_EXT) +# Encrypted-logging shim (SQLCipher). Skipped with a notice if sqlcipher is +# absent, so building/running still works without message logging. +log-shim: + @if [ -z "$(SQLCIPHER_PREFIX)" ]; then \ + echo "log-shim: sqlcipher not found; skipping (encrypted logging disabled)."; \ + echo " enable it with: brew install sqlcipher && make log-shim"; \ + else \ + cc -shared -fPIC \ + -I$(SQLCIPHER_PREFIX)/include/sqlcipher \ + -L$(SQLCIPHER_PREFIX)/lib -lsqlcipher \ + -o $(LOG_SHIM) signal/log_shim.c && echo "Built $(LOG_SHIM)"; \ + fi + help: @echo "jerboa-signal -- Signal client over signal-cli" @echo "" @@ -75,6 +92,7 @@ help: @echo " test Run tests" @echo " install Install ./jerboa-signal to ~/.local/bin" @echo " tui-shim Build the termbox2 TUI shim" + @echo " log-shim Build the SQLCipher encrypted-logging shim" @echo " clean Remove build artifacts" @echo "" @echo "Prerequisite: signal-cli must be linked to your Signal account." --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Bidirectional bridge between Signal and your shell: jerboa-signal send [-a +PHONE] RECIPIENT MESSAGE # send one message, exit jerboa-signal listen [-a +PHONE] # stream inbound as NDJSON jerboa-signal tui [-a +PHONE] # terminal UI shell +jerboa-signal log [-a +PHONE] # headless: capture all to an encrypted DB ``` `-a` is required only if multiple Signal accounts are linked to signal-cli. @@ -21,6 +22,8 @@ jerboa-signal tui [-a +PHONE] # terminal UI shell - `signal-cli` on `PATH` (Homebrew: `brew install signal-cli`) - A Jerboa checkout next to this repo at `../jerboa`, or `JERBOA_HOME` set +- Optional, for the encrypted message log: `brew install sqlcipher`, then + `make log-shim` (without it the TUI runs fine, just without logging) ## One-time setup: link as a secondary device @@ -90,6 +93,54 @@ phone's contacts. If a removed person messages you again the thread reappears; to remove someone from your Signal account everywhere, delete them on your phone too. Use q, Esc, or Ctrl-C to quit. +## Encrypted message log + +The TUI can log every message to an encrypted SQLite database so you keep a copy +even when the sender later deletes it ("delete for everyone"). It uses +[SQLCipher](https://www.zetetic.net/sqlcipher/) (AES-256) — the whole file, +contents and metadata, is unreadable without your passphrase. + +Enable it: + +```sh +brew install sqlcipher +make log-shim # builds signal_log_shim.dylib +./jerboa-signal tui # prompts: passphrase for encrypted message log +``` + +At startup the TUI asks for a passphrase (echo off). Enter one to log; leave it +blank to skip logging for that session. To run unattended, set +`JERBOA_SIGNAL_DB_KEY` instead and the prompt is skipped. + +The database lives at `~/.local/share/jerboa-signal/messages-<account>.db`. Each +inbound notification and outbound send is one append-only row (with the full raw +JSON); remote-deletes and edits are logged as new rows and never overwrite the +original. Read it back with the matching key: + +```sh +sqlcipher ~/.local/share/jerboa-signal/messages-+15551234567.db \ + "PRAGMA key='your passphrase'; SELECT timestamp,direction,sender,kind,body FROM messages;" +``` + +### Always-on logging daemon + +The TUI only logs while it is open. To capture messages around the clock, run +the headless logger, which connects to signal-cli in receive mode and appends +every event to the same encrypted database until stopped: + +```sh +export JERBOA_SIGNAL_DB_KEY='your passphrase' # required when headless +jerboa-signal log # prints a line per captured event +``` + +Run it under launchd/`nohup`/tmux for 24/7 capture. It reads the passphrase from +`JERBOA_SIGNAL_DB_KEY`, or prompts if started attached to a terminal. Each event +is committed immediately, so killing the daemon never loses logged messages. + +Note: signal-cli allows only one connection per account, so the daemon and the +TUI cannot run at the same time for the same account — use the daemon for +unattended capture and the TUI for reading/replying. + ## Architecture ``` @@ -109,6 +160,10 @@ Modules: - `signal/cmd-send.ss` — `send` subcommand - `signal/cmd-listen.ss` — `listen` subcommand - `signal/cmd-tui.ss` — TUI command entry point; see `docs/TUI_PLAN.md` +- `signal/cmd-log.ss` — always-on headless logging daemon (`log` subcommand) +- `signal/store.ss` — persistent local state (deleted-conversation list) +- `signal/capture.ss` — notification → encrypted log row (shared by TUI + daemon) +- `signal/logdb.ss` + `signal/log_shim.c` — FFI to the SQLCipher encrypted log - `signal/tui/` — termbox2 FFI and the first terminal shell ## License new file mode 100644 --- /dev/null +++ b/signal/capture.ss @@ -0,0 +1,173 @@ +#!chezscheme +;;; signal/capture -- normalize one signal-cli notification into a single +;;; encrypted log row. Shared by the TUI and the `log` daemon so both capture +;;; identically. The full raw JSON is always stored; the other columns are a +;;; best-effort index for querying. Remote-deletes and edits are logged as their +;;; own rows (kind = "remote-delete"/"edit") and never overwrite the original. + +(library (signal capture) + (export capture-notification! capture-outbound!) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (std text json) + (signal logdb)) + + ;; Log one notification. Returns a short summary string (for daemon output) + ;; or #f when nothing is written. Never raises -- logging must not break + ;; the caller's event loop. + (def (capture-notification! logdb account notif) + (and logdb + (hashtable? notif) + (guard (e [#t #f]) + (let ([raw (event->json notif)] + [env (notif-envelope notif)]) + (if (hashtable? env) + (let ([row (envelope->row env)]) + (logdb-put logdb (safe account) + (list-ref row 0) ;; direction + (safe (list-ref row 1)) ;; conversation + (safe (list-ref row 2)) ;; sender + (as-int (list-ref row 3)) ;; timestamp + (list-ref row 4) ;; kind + (safe (list-ref row 5)) ;; body + raw) + (string-append (list-ref row 0) " " + (safe (list-ref row 1)) " " + (list-ref row 4))) + (begin + (logdb-put logdb (safe account) "event" "" "" 0 + (or (notif-method notif) "event") "" raw) + "event")))))) + + ;; Log a locally-sent message (TUI composer). Returns a summary or #f. + (def (capture-outbound! logdb account conversation text timestamp) + (and logdb + (guard (e [#t #f]) + (let ([raw (make-hashtable equal-hash equal?)]) + (hashtable-set! raw "type" "sent") + (hashtable-set! raw "conversation" (safe conversation)) + (hashtable-set! raw "message" (safe text)) + (when (number? timestamp) + (hashtable-set! raw "timestamp" timestamp)) + (logdb-put logdb (safe account) "out" (safe conversation) "You" + (as-int timestamp) "data" (safe text) + (event->json raw)) + "out sent")))) + + ;; --- extraction: envelope -> (direction conversation sender ts kind body) --- + + (def (envelope->row env) + (let ([data (htref env "dataMessage")] + [sync (htref env "syncMessage")] + [receipt (htref env "receiptMessage")] + [typing (htref env "typingMessage")] + [src-name (first-string (htref env "sourceName") + (htref env "sourceNumber") + (htref env "source") + "unknown")] + [src-target (first-string (htref env "sourceNumber") + (htref env "source"))] + [env-ts (htref env "timestamp")]) + (cond + [(hashtable? data) + (let ([gid (data-group-id data)]) + (list "in" + (conv-id gid (or gid src-target src-name)) + src-name + (or (htref data "timestamp") env-ts) + (data-kind data) + (or (htref data "message") (body-marker data))))] + [(hashtable? sync) + (let ([sent (htref sync "sentMessage")]) + (if (hashtable? sent) + (let ([gid (data-group-id sent)] + [dest (first-string (htref sent "destinationNumber") + (htref sent "destination") + (htref sent "destinationUuid"))]) + (list "out" + (conv-id gid (or gid dest "unknown")) + "You" + (or (htref sent "timestamp") env-ts) + (data-kind sent) + (or (htref sent "message") (body-marker sent)))) + (list "out" "" "You" env-ts "sync" "")))] + [(hashtable? receipt) + (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "receipt" "")] + [(hashtable? typing) + (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "typing" "")] + [else + (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "event" "")]))) + + (def (data-kind msg) + (cond + [(htref msg "remoteDelete") "remote-delete"] + [(htref msg "editMessage") "edit"] + [else "data"])) + + (def (body-marker msg) + (if (htref msg "attachments") "[attachment]" "")) + + (def (conv-id gid target) + (string-append (if gid "group:" "direct:") + (safe-display (or target "unknown")))) + + (def (data-group-id msg) + (or (nonempty (htref msg "groupId")) + (let ([gi (htref msg "groupInfo")]) + (and (hashtable? gi) + (or (nonempty (htref gi "groupId")) + (nonempty (htref gi "groupIdBase64")) + (nonempty (htref gi "id"))))) + (let ([g2 (htref msg "groupV2")]) + (and (hashtable? g2) + (or (nonempty (htref g2 "id")) + (nonempty (htref g2 "masterKey"))))))) + + (def (notif-envelope notif) + (let ([params (htref notif "params")]) + (and (hashtable? params) + (let ([payload (or (htref params "result") params)]) + (and (hashtable? payload) (htref payload "envelope")))))) + + (def (notif-method notif) + (let ([m (htref notif "method")]) (and (string? m) m))) + + ;; --- small helpers --- + + (def (htref ht key) + (and (hashtable? ht) (hashtable-ref ht key #f))) + + (def (nonempty s) (and (string? s) (not (string=? s "")) s)) + + (def (first-string . xs) + (cond + [(null? xs) #f] + [(nonempty (car xs)) (car xs)] + [else (apply first-string (cdr xs))])) + + (def (safe x) (cond [(string? x) x] [x (safe-display x)] [else ""])) + + (def (as-int x) (if (number? x) x 0)) + + (def (safe-display x) + (if (string? x) + x + (let ([p (open-output-string)]) (display x p) (get-output-string p)))) + + (def (event->json x) + (guard (e [#t ""]) + (cond + [(hashtable? x) (json-object->string x)] + [(string? x) x] + [else (safe-display x)]))) + + ) ;; end library new file mode 100644 --- /dev/null +++ b/signal/cmd-log.ss @@ -0,0 +1,145 @@ +#!chezscheme +;;; signal/cmd-log -- always-on headless message logger. +;;; +;;; Connects to signal-cli in receive mode and appends every event to the +;;; encrypted SQLCipher log, so messages are kept even if the sender later +;;; deletes them ("delete for everyone"). Runs until the stream closes or it is +;;; interrupted. Each insert is its own committed transaction, so an abrupt kill +;;; never loses already-logged messages. +;;; +;;; Passphrase: JERBOA_SIGNAL_DB_KEY if set (for unattended/launchd use), +;;; otherwise prompt when attached to a terminal. + +(library (signal cmd-log) + (export cmd-log) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (std misc process) + (signal rpc-actor) + (signal store) + (signal logdb) + (signal capture)) + + (def (cmd-log account) + (let* ([acct (or account "default")] + [logdb (open-log-or-exit acct)] + [actor (start-signal-actor account "on-connection")]) + (dynamic-wind + (lambda () (void)) + (lambda () + (announce acct) + (confirm-backend actor) + (log-loop logdb acct actor 0)) + (lambda () + (stop-signal-actor actor) + (logdb-close logdb) + (display "jerboa-signal log: stopped.\n"))))) + + (def (open-log-or-exit acct) + (unless (logdb-available?) + (die "encrypted logging unavailable; build it with: brew install sqlcipher && make log-shim")) + (let ([key (resolve-key)]) + (unless (and key (not (string=? key ""))) + (die "no passphrase; set JERBOA_SIGNAL_DB_KEY or run attached to a terminal")) + (ensure-store-dir!) + (let ([db (logdb-open (messages-store-path acct) key)]) + (unless db + (die "could not open encrypted log (wrong passphrase, or corrupt file)")) + db))) + + (def (resolve-key) + (let ([env (getenv "JERBOA_SIGNAL_DB_KEY")]) + (cond + [(and env (not (string=? env ""))) env] + [(tty? (current-input-port)) + (logdb-prompt-passphrase + "jerboa-signal log: passphrase for encrypted message log: ")] + [else #f]))) + + (def (announce acct) + (display "jerboa-signal log: capturing to ") + (display (messages-store-path acct)) + (newline) + (display " receive mode on-connection; press Ctrl-C to stop.\n") + (flush-output-port (current-output-port))) + + (def (confirm-backend actor) + (guard (e [#t (void)]) + (let ([v (actor-call actor "version" #f)]) + (when (hashtable? v) + (display " signal-cli ") + (display (hashtable-ref v "version" "unknown")) + (newline) + (flush-output-port (current-output-port)))))) + + ;; Poll the actor's event stream, log every notification, and keep a running + ;; total. Exits when signal-cli closes the stream. + (def (log-loop logdb acct actor total) + (cond + [(signal-actor-closed? actor) + (display "jerboa-signal log: signal-cli stream closed.\n") + total] + [else + (let ([n (handle-events! logdb acct (actor-drain-events actor) total)]) + (sleep-ms 150) + (log-loop logdb acct actor n))])) + + (def (handle-events! logdb acct events total) + (let loop ([evs events] [count total]) + (cond + [(null? evs) count] + [else + (let ([ev (car evs)] + [rest (cdr evs)]) + (cond + [(notification-event? ev) + (let ([summary (capture-notification! logdb acct (cadr ev))] + [n (+ count 1)]) + (report n summary) + (loop rest n))] + [(tagged? ev 'error) + (display "jerboa-signal log: signal-cli error: ") + (display (event-detail ev)) + (newline) + (flush-output-port (current-output-port)) + (loop rest count)] + [else (loop rest count)]))]))) + + (def (notification-event? ev) + (and (tagged? ev 'notification) (pair? (cdr ev)))) + + (def (tagged? ev tag) + (and (pair? ev) (eq? (car ev) tag))) + + (def (report n summary) + (display "[") + (display n) + (display "] ") + (display (if (string? summary) summary "event")) + (newline) + (flush-output-port (current-output-port))) + + (def (event-detail ev) + (if (pair? (cdr ev)) + (let ([d (cadr ev)]) (if (string? d) d (object->string d))) + "?")) + + (def (object->string x) + (let ([p (open-output-string)]) (display x p) (get-output-string p))) + + (def (die msg) + (display "jerboa-signal log: " (current-error-port)) + (display msg (current-error-port)) + (newline (current-error-port)) + (exit 1)) + + ) ;; end library new file mode 100644 --- /dev/null +++ b/signal/log_shim.c @@ -0,0 +1,170 @@ +/* signal_log_shim.c -- SQLCipher-backed encrypted message log for jerboa-signal. + * + * Captures every Signal event as an append-only row so messages survive even + * when the sender later deletes them ("delete for everyone" / remote delete): + * a remote-delete is just another row and never touches the original. + * + * The database is a real encrypted SQLite file (SQLCipher, AES-256) -- contents + * AND metadata are unreadable without the key. + * + * Build: + * cc -shared -fPIC \ + * -I$(brew --prefix sqlcipher)/include/sqlcipher \ + * -L$(brew --prefix sqlcipher)/lib -lsqlcipher \ + * -o signal_log_shim.dylib signal/log_shim.c + * + * Self-test: + * cc -DSIGNAL_LOG_TEST -I... -L... -lsqlcipher signal/log_shim.c -o /tmp/logtest + */ + +#include <sqlite3.h> /* SQLCipher's sqlite3.h (note the -I path) */ +#include <string.h> +#include <unistd.h> /* getpass */ + +/* SQLCipher exports these, but sqlite3.h only declares them under + * SQLITE_HAS_CODEC. Declare directly so we link against libsqlcipher's + * symbols without depending on the header guard. */ +extern int sqlite3_key(sqlite3 *db, const void *pKey, int nKey); + +static const char *SCHEMA = + "CREATE TABLE IF NOT EXISTS messages (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " logged_at INTEGER DEFAULT (strftime('%s','now'))," + " account TEXT," + " direction TEXT," /* in | out | system | event */ + " conversation TEXT," /* direct:<target> | group:<id> */ + " sender TEXT," + " timestamp INTEGER,"/* Signal message timestamp (ms), if any */ + " kind TEXT," /* data | remote-delete | edit | receipt | typing | ... */ + " body TEXT," + " raw TEXT NOT NULL" /* full raw JSON of the event */ + ");" + "CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation);" + "CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(timestamp);"; + +/* Open (creating if needed) the encrypted DB at `path`, keyed with `key`. + * Returns a sqlite3* (as void*) or NULL on failure (including a wrong key). */ +void *signal_log_open(const char *path, const char *key) { + sqlite3 *db = NULL; + char *err = NULL; + + if (sqlite3_open(path, &db) != SQLITE_OK) { + if (db) sqlite3_close(db); + return NULL; + } + /* The key must be applied before any other database access. */ + if (key && key[0] != '\0') { + if (sqlite3_key(db, key, (int)strlen(key)) != SQLITE_OK) { + sqlite3_close(db); + return NULL; + } + } + /* Touch the schema so a wrong key fails here, not mid-insert. */ + if (sqlite3_exec(db, "SELECT count(*) FROM sqlite_master;", + NULL, NULL, &err) != SQLITE_OK) { + if (err) sqlite3_free(err); + sqlite3_close(db); + return NULL; + } + if (sqlite3_exec(db, SCHEMA, NULL, NULL, &err) != SQLITE_OK) { + if (err) sqlite3_free(err); + sqlite3_close(db); + return NULL; + } + (void)sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL); + return (void *)db; +} + +int signal_log_close(void *handle) { + if (!handle) return 0; + return sqlite3_close((sqlite3 *)handle); +} + +static void bind_text_or_null(sqlite3_stmt *st, int idx, const char *s) { + if (s && s[0] != '\0') + sqlite3_bind_text(st, idx, s, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, idx); +} + +/* Append one row. Text args may be empty (stored as NULL). Returns 0 on success. */ +int signal_log_put(void *handle, + const char *account, const char *direction, + const char *conversation, const char *sender, + long long timestamp, const char *kind, + const char *body, const char *raw) { + if (!handle) return -1; + sqlite3 *db = (sqlite3 *)handle; + static const char *SQL = + "INSERT INTO messages" + " (account,direction,conversation,sender,timestamp,kind,body,raw)" + " VALUES (?,?,?,?,?,?,?,?);"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, SQL, -1, &st, NULL) != SQLITE_OK) + return sqlite3_errcode(db); + bind_text_or_null(st, 1, account); + bind_text_or_null(st, 2, direction); + bind_text_or_null(st, 3, conversation); + bind_text_or_null(st, 4, sender); + sqlite3_bind_int64(st, 5, (sqlite3_int64)timestamp); + bind_text_or_null(st, 6, kind); + bind_text_or_null(st, 7, body); + sqlite3_bind_text(st, 8, raw ? raw : "", -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + return (rc == SQLITE_DONE) ? 0 : rc; +} + +/* Total rows logged (verification helper). Returns -1 on error. */ +long long signal_log_count(void *handle) { + if (!handle) return -1; + sqlite3 *db = (sqlite3 *)handle; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, "SELECT count(*) FROM messages;", -1, &st, NULL) + != SQLITE_OK) + return -1; + long long n = -1; + if (sqlite3_step(st) == SQLITE_ROW) + n = (long long)sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return n; +} + +/* Read a passphrase from the controlling terminal with echo disabled. + * Returns a pointer to a static buffer (copied by the FFI into a Scheme string). */ +const char *signal_log_getpass(const char *prompt) { + char *p = getpass(prompt ? prompt : "Passphrase: "); + return p ? p : ""; +} + +#ifdef SIGNAL_LOG_TEST +#include <stdio.h> +int main(void) { + const char *path = "/tmp/jerboa-signal-logtest.db"; + unlink(path); + void *db = signal_log_open(path, "correct horse battery staple"); + if (!db) { printf("FAIL open\n"); return 1; } + signal_log_put(db, "+15550001111", "in", "direct:+15550002222", + "Alice", 1717000000000LL, "data", "hello world", + "{\"method\":\"receive\"}"); + signal_log_put(db, "+15550001111", "in", "direct:+15550002222", + "Alice", 1717000005000LL, "remote-delete", "", + "{\"remoteDelete\":{\"timestamp\":1717000000000}}"); + long long n = signal_log_count(db); + signal_log_close(db); + + /* Reopen with correct key: both rows (incl. the original) still present. */ + void *db2 = signal_log_open(path, "correct horse battery staple"); + long long n2 = db2 ? signal_log_count(db2) : -1; + if (db2) signal_log_close(db2); + + /* Wrong key must fail. */ + void *db3 = signal_log_open(path, "wrong key"); + int wrong_rejected = (db3 == NULL); + if (db3) signal_log_close(db3); + + printf("rows=%lld reopened=%lld wrong_key_rejected=%d\n", + n, n2, wrong_rejected); + return (n == 2 && n2 == 2 && wrong_rejected) ? 0 : 2; +} +#endif new file mode 100644 --- /dev/null +++ b/signal/logdb.ss @@ -0,0 +1,93 @@ +#!chezscheme +;;; signal/logdb -- FFI to the SQLCipher encrypted message-log shim. +;;; +;;; Loads signal_log_shim.<dylib|so> (built by `make log-shim`) which links +;;; libsqlcipher. If the shim or sqlcipher is missing, logdb-available? is #f +;;; and every operation is a no-op, so the TUI runs fine without logging. + +(library (signal logdb) + (export logdb-available? + logdb-open logdb-close logdb-put logdb-count + logdb-prompt-passphrase) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (std os path)) + + (def *shim-load-state* (box 'untried)) + + (def (try-load-shim path) + (and (file-exists? path) (load-shared-object path))) + + (def (ensure-shim-loaded!) + (cond + [(eq? (unbox *shim-load-state*) 'loaded) #t] + [(eq? (unbox *shim-load-state*) 'missing) #f] + [else + (let* ([bin-dir (path-directory (car (command-line)))] + [home (or (getenv "HOME") ".")] + [loaded? + (guard (e [#t #f]) + (or (try-load-shim "signal_log_shim.dylib") + (try-load-shim "signal_log_shim.so") + (try-load-shim (path-join bin-dir "signal_log_shim.dylib")) + (try-load-shim (path-join bin-dir "signal_log_shim.so")) + (try-load-shim (path-join home ".local" "bin" + "signal_log_shim.dylib")) + (try-load-shim (path-join home ".local" "bin" + "signal_log_shim.so"))))]) + (set-box! *shim-load-state* (if loaded? 'loaded 'missing)) + loaded?)])) + + (def (entry? name) + (and (ensure-shim-loaded!) (foreign-entry? name))) + + (def (logdb-available?) + (entry? "signal_log_open")) + + ;; Open (creating if needed) the encrypted DB at `path` with passphrase `key`. + ;; Returns an opaque handle (nonzero integer) or #f on failure / wrong key. + (def (logdb-open path key) + (and (entry? "signal_log_open") + (let ([h ((foreign-procedure "signal_log_open" (string string) uptr) + path key)]) + (and (not (= h 0)) h)))) + + (def (logdb-close handle) + (when (and handle (entry? "signal_log_close")) + ((foreign-procedure "signal_log_close" (uptr) int) handle) + (void))) + + ;; Append one event. Strings should be non-#f (use "" for absent). Returns #t + ;; on success. Best-effort: callers should still guard, but this never raises + ;; for a #f handle. + (def (logdb-put handle account direction conversation sender + timestamp kind body raw) + (and handle + (entry? "signal_log_put") + (= 0 ((foreign-procedure "signal_log_put" + (uptr string string string string integer-64 string string string) + int) + handle account direction conversation sender + timestamp kind body raw)))) + + (def (logdb-count handle) + (if (and handle (entry? "signal_log_count")) + ((foreign-procedure "signal_log_count" (uptr) integer-64) handle) + -1)) + + ;; Read a passphrase from the controlling terminal with echo off. + (def (logdb-prompt-passphrase prompt) + (if (entry? "signal_log_getpass") + ((foreign-procedure "signal_log_getpass" (string) string) prompt) + "")) + + ) ;; end library --- a/signal/main.ss +++ b/signal/main.ss @@ -35,7 +35,8 @@ (import (except (jerboa prelude) meta atom?) (signal cmd-send) (signal cmd-listen) - (signal cmd-tui)) + (signal cmd-tui) + (signal cmd-log)) (def *version* "0.1.0") @@ -47,6 +48,7 @@ Subcommands: send [-a +PHONE] RECIPIENT MESSAGE Send a Signal message listen [-a +PHONE] Stream inbound envelopes as NDJSON tui [-a +PHONE] Start the terminal UI + log [-a +PHONE] Capture all messages to an encrypted log version Print version help Print this help @@ -101,6 +103,15 @@ linking once before using this tool: (exit 2)] [else (cmd-tui account)]))] + [(string=? sub "log") + (let-values ([(account args) (parse-account-flag rest)]) + (cond + [(not (null? args)) + (display "jerboa-signal: log takes no positional arguments\n" + (current-error-port)) + (exit 2)] + [else + (cmd-log account)]))] [else (display "jerboa-signal: unknown subcommand: " (current-error-port)) (display sub (current-error-port)) --- a/signal/store.ss +++ b/signal/store.ss @@ -11,7 +11,8 @@ ;;; conversation id per line. It holds no message content. (library (signal store) - (export load-removed-ids save-removed-ids removed-store-path) + (export load-removed-ids save-removed-ids removed-store-path + messages-store-path ensure-store-dir!) (import (except (chezscheme) make-hash-table hash-table? @@ -30,6 +31,9 @@ (def (removed-store-path account) (string-append (store-dir) "/removed-" (sanitize account) ".txt")) + (def (messages-store-path account) + (string-append (store-dir) "/messages-" (sanitize account) ".db")) + ;; Keep the filename safe: phone numbers (+, digits), uuids (-) and ordinary ;; word characters pass through; anything else collapses to _. (def (sanitize s) --- a/signal/tui/main.ss +++ b/signal/tui/main.ss @@ -16,6 +16,8 @@ (except (jerboa prelude) meta atom?) (signal rpc-actor) (signal store) + (signal logdb) + (signal capture) (signal tui ffi)) (defstruct chat-message (direction sender text timestamp kind)) @@ -23,38 +25,67 @@ (defstruct search-hit (conv-id conv-title message)) (defstruct tui-state (account version receive-mode width height input status quit? event-count - conversations selected-index mode picker-query picker-index removed)) + conversations selected-index mode picker-query picker-index removed logdb)) (def (run-tui-terminal account actor version receive-mode) - (with-tui - (tb-set-input-mode! (bitwise-ior TB_INPUT_ESC TB_INPUT_MOUSE)) - (tb-set-output-mode! TB_OUTPUT_TRUECOLOR) - (let* ([acct (or account "default")] - [removed (load-removed-set acct)] - [state (make-tui-state acct - version - receive-mode - (tb-width) - (tb-height) - "" - "Backend connected." - #f - 0 - (list - (make-system-conversation - (list - "TUI connected. Waiting for Signal receive notifications." - "Session history is in memory only."))) - 0 - 'chat - "" - 0 - removed)]) - (tb-hide-cursor!) - (draw! state) - (tb-present!) - (seed-known-conversations! state actor) - (event-loop state actor)))) + ;; Prompt for the log passphrase and open the DB BEFORE termbox grabs the + ;; terminal (getpass needs the normal tty), and close it on the way out. + (let* ([acct (or account "default")] + [removed (load-removed-set acct)] + [logdb (open-message-log acct)]) + (dynamic-wind + (lambda () (void)) + (lambda () + (with-tui + (tb-set-input-mode! (bitwise-ior TB_INPUT_ESC TB_INPUT_MOUSE)) + (tb-set-output-mode! TB_OUTPUT_TRUECOLOR) + (let ([state (make-tui-state acct + version + receive-mode + (tb-width) + (tb-height) + "" + "Backend connected." + #f + 0 + (list + (make-system-conversation + (list + "TUI connected. Waiting for Signal receive notifications." + "Session history is in memory only."))) + 0 + 'chat + "" + 0 + removed + logdb)]) + (append-system-message! + state + (if logdb + "Encrypted logging ON: messages are saved even if deleted later." + "Encrypted logging OFF.")) + (tb-hide-cursor!) + (draw! state) + (tb-present!) + (seed-known-conversations! state actor) + (event-loop state actor)))) + (lambda () (when logdb (logdb-close logdb)))))) + + ;; Open the encrypted message log. Passphrase comes from JERBOA_SIGNAL_DB_KEY + ;; if set (enables headless use), otherwise we prompt. A blank passphrase or a + ;; missing shim disables logging and the TUI runs normally. + (def (open-message-log acct) + (and (logdb-available?) + (let* ([env (getenv "JERBOA_SIGNAL_DB_KEY")] + [key (if (and env (not (string=? env ""))) + env + (logdb-prompt-passphrase + "jerboa-signal: passphrase for encrypted message log (blank to skip): "))]) + (and key + (not (string=? key "")) + (begin + (ensure-store-dir!) + (logdb-open (messages-store-path acct) key)))))) (def (event-loop state actor) (let loop () @@ -77,12 +108,16 @@ (lambda (ev) (cond [(and (pair? ev) (eq? (car ev) 'notification)) - (let ([chat-event (notification->chat-event (cadr ev))]) + (let* ([notif (cadr ev)] + [chat-event (notification->chat-event notif)]) + (capture-notification! (tui-state-logdb state) + (tui-state-account state) + notif) (if chat-event (begin (apply-chat-event! state chat-event) (tui-state-status-set! state "New Signal message received.")) - (let ([line (notification->line (cadr ev))]) + (let ([line (notification->line notif)]) (when line (append-system-message! state line) (tui-state-status-set! state "New Signal event received.")))))] @@ -633,14 +668,22 @@ (tui-state-status-set! state (string-append "Send failed: " (safe-display e)))]) - (actor-call actor "send" (make-send-params-for-conversation conv text)) - (append-message-to-conversation! - conv - (make-chat-message 'out "You" text #f 'data)) + (let* ([result (actor-call actor "send" + (make-send-params-for-conversation conv text))] + [ts (send-result-timestamp result)]) + (append-message-to-conversation! + conv + (make-chat-message 'out "You" text ts 'data)) + (capture-outbound! (tui-state-logdb state) + (tui-state-account state) + (conversation-id conv) text ts)) (tui-state-input-set! state "") (conversation-unread-set! conv 0) (tui-state-status-set! state "Sent."))]))) + (def (send-result-timestamp result) + (and (hashtable? result) (hashtable-ref result "timestamp" #f))) + (def (make-send-params-for-conversation conv text) (let ([p (make-hashtable equal-hash equal?)] [target (conversation-target conv)])