Harden attachment, log, and native handling
ober
f656044803d2d346dc5b654fb631b354c6e110eb
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install system tools run: | --- a/.github/workflows/security-baseline.yml +++ b/.github/workflows/security-baseline.yml @@ -13,7 +13,7 @@ jobs: baseline: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Required release files run: | --- a/.jerbuild +++ b/.jerbuild @@ -9,6 +9,8 @@ (output "jerboa-signal") (libdirs "." "vendor/jsqlite/src") (static-native #t) +(extra-sources "support/signal_security.c") +(ffi-symbols "support/ffi-symbols.list") (os-libs (Darwin "-lm -lpthread -lncurses -liconv -lc++") (Linux "-lm -ldl -lpthread -static -lncurses -lstdc++")) --- a/Makefile +++ b/Makefile @@ -17,7 +17,12 @@ JH = $(shell "$(JERBUILD)" --jerboa-home 2>/dev/null) VENDOR ?= $(CURDIR)/vendor JSQLITE_REPO ?= $(VENDOR)/jsqlite JSQLITE_URL ?= https://git.sr.ht/~lisp/jsqlite +JSQLITE_REV ?= 63d737101b3b74bff3b9db75cc35f34bff6df0b7 +JSQLITE_TREE ?= 79bfcf7a52d233076301c5cba9cd56d1b3e07ebf JSQLITE_SRC ?= $(JSQLITE_REPO)/src +TERMBOX2_URL ?= https://github.com/termbox/termbox2.git +TERMBOX2_REV ?= 605398fa79108412976191e062ea14bd4bd30213 +TERMBOX2_TREE ?= e3ff0f62611cb5cdc94bbea2bc0780541359a65e LIBDIRS = --libdirs $(CURDIR):$(JSQLITE_SRC):$(JH)/lib LOCAL_JERBOA_DIR ?= $(CURDIR)/../jerboa DEFAULT_JERBOA_NATIVE_MANIFEST = $(if $(wildcard $(LOCAL_JERBOA_DIR)/jerboa-native-rs/Cargo.toml),$(LOCAL_JERBOA_DIR)/jerboa-native-rs/Cargo.toml,$(JH)/jerboa-native-rs/Cargo.toml) @@ -29,7 +34,11 @@ JERBOA_NATIVE_A ?= $(DEFAULT_JERBOA_NATIVE_A) JERBOA_NATIVE_LIB ?= $(DEFAULT_JERBOA_NATIVE_DIR)/libjerboa_native.$(TUI_SHIM_EXT) CARGO_AUDIT ?= $(shell command -v cargo-audit 2>/dev/null || printf '%s/.cargo/bin/cargo-audit' "$$HOME") export JERBOA_NATIVE_A -JEXEC = JERBOA_NATIVE_LIB="$(JERBOA_NATIVE_LIB)" $(JERBUILD) exec $(LIBDIRS) +JEXEC = JERBOA_DEV_NATIVE=1 \ + JERBOA_SIGNAL_DEV_NATIVE=1 \ + JERBOA_NATIVE_LIB="$(STAGED_JERBOA_NATIVE_LIB)" \ + JERBOA_SIGNAL_SECURITY_LIB="$(STAGED_SECURITY_SHIM)" \ + $(JERBUILD) exec $(LIBDIRS) BIN := jerboa-signal BIN_DIR := $(HOME)/.local/bin TUI_SHIM_DIR := $(CURDIR)/vendor/termbox2 @@ -41,6 +50,10 @@ TUI_SHIM_EXT := so endif TUI_SHIM := $(TUI_SHIM_DIR)/signal_tui_shim.$(TUI_SHIM_EXT) LOG_SHIM := signal_log_shim.$(TUI_SHIM_EXT) +SECURITY_SHIM := signal_security_shim.$(TUI_SHIM_EXT) +NATIVE_TEST_STAGE = $(HOME)/.cache/jerboa-signal/native-test +STAGED_JERBOA_NATIVE_LIB = $(NATIVE_TEST_STAGE)/libjerboa_native.$(TUI_SHIM_EXT) +STAGED_SECURITY_SHIM = $(NATIVE_TEST_STAGE)/signal_security_shim.$(TUI_SHIM_EXT) SQLCIPHER_PREFIX := $(shell brew --prefix sqlcipher 2>/dev/null) DIST_DIR ?= dist/release-evidence SBOM_DIR ?= dist/sbom @@ -56,7 +69,7 @@ else \ printf '%s\n' signal-cli; \ fi) -.PHONY: all build binary run run-tui test security sanitize-evidence audit sbom reproducibility-report soak-evidence binary-smoke signal-cli-advisory-check verify release-evidence install install-log-shim clean help vendor-deps tui-shim log-shim ensure-jerboa-tools ensure-jerboa-native ensure-jsqlite +.PHONY: all build binary run run-tui test security sanitize-evidence audit sbom reproducibility-report soak-evidence binary-smoke signal-cli-advisory-check verify release-evidence install install-log-shim clean help vendor-deps tui-shim log-shim security-shim native-test-stage ensure-jerboa-tools ensure-jerboa-native ensure-jsqlite ensure-termbox2 .DEFAULT_GOAL := help all: binary @@ -73,10 +86,14 @@ run: binary run-tui: binary tui-shim ./$(BIN) tui $(ARGS) -test: binary +test: binary native-test-stage $(JEXEC) tests/test-send-result.ss $(JEXEC) tests/test-rpc-demux.ss + $(JEXEC) tests/test-bounded-line.ss $(JEXEC) tests/test-logdb-jsqlite.ss + $(JEXEC) tests/test-secret-input.ss + $(JEXEC) tests/test-attachments.ss + $(JEXEC) tests/test-native-loader.ss $(JEXEC) tests/test-receive-normalization.ss $(JEXEC) tests/test-history-replay.ss ./$(BIN) --help >/dev/null && echo "smoke ok" @@ -107,6 +124,12 @@ sbom: JERBOA_NATIVE_MANIFEST="$(JERBOA_NATIVE_MANIFEST)" \ JERBOA_NATIVE_A="$(JERBOA_NATIVE_A)" \ JSQLITE_SRC="$(JSQLITE_SRC)" \ + JSQLITE_REPO="$(JSQLITE_REPO)" \ + JSQLITE_REV="$(JSQLITE_REV)" \ + JSQLITE_TREE="$(JSQLITE_TREE)" \ + TERMBOX2_REPO="$(TUI_SHIM_DIR)" \ + TERMBOX2_REV="$(TERMBOX2_REV)" \ + TERMBOX2_TREE="$(TERMBOX2_TREE)" \ SIGNAL_CLI="$(SIGNAL_CLI)" \ sh scripts/sbom.sh @@ -162,7 +185,7 @@ release-evidence: verify reproducibility-report sbom soak-evidence binary-smoke fi @find signal tests -type f \( -name '*.ss' -o -name '*.c' -o -name '*.h' \) -print | sort | xargs shasum -a 256 > "$(DIST_DIR)/source-sha256.txt" @find vendor/jsqlite/src -type f -name '*.ss' -print | sort | xargs shasum -a 256 > "$(DIST_DIR)/jsqlite-sha256.txt" - @shasum -a 256 Makefile .jerbuild .jerboa/security.json SECURITY.md docs/threat-model.md docs/credential-handling.md docs/ffi-boundary.md docs/release-evidence.md docs/signal-cli-advisory-review.md scripts/security-check.sh scripts/sanitize-evidence.sh scripts/sbom.sh scripts/reproducibility-report.sh scripts/soak-evidence.sh scripts/binary-smoke.sh scripts/signal-cli-advisory-check.sh > "$(DIST_DIR)/release-inputs-sha256.txt" + @shasum -a 256 Makefile .jerbuild .jerboa/security.json SECURITY.md docs/threat-model.md docs/credential-handling.md docs/ffi-boundary.md docs/release-evidence.md docs/signal-cli-advisory-review.md support/signal_security.c support/ffi-symbols.list scripts/security-check.sh scripts/sanitize-evidence.sh scripts/sbom.sh scripts/reproducibility-report.sh scripts/soak-evidence.sh scripts/binary-smoke.sh scripts/signal-cli-advisory-check.sh > "$(DIST_DIR)/release-inputs-sha256.txt" @shasum -a 256 "$(BIN)" > "$(DIST_DIR)/binary-sha256.txt" @if command -v otool >/dev/null 2>&1; then otool -L "$(BIN)" > "$(DIST_DIR)/native-linkage.txt"; elif command -v ldd >/dev/null 2>&1; then ldd "$(BIN)" > "$(DIST_DIR)/native-linkage.txt"; else echo "no otool/ldd available" > "$(DIST_DIR)/native-linkage.txt"; fi @if command -v cargo >/dev/null 2>&1 && [ -f "$(JERBOA_NATIVE_MANIFEST)" ]; then \ @@ -204,7 +227,7 @@ release-evidence: verify reproducibility-report sbom soak-evidence binary-smoke } > "$(DIST_DIR)/result.txt" @echo "release evidence written to $(DIST_DIR)" -install: binary +install: binary tui-shim security-shim mkdir -p $(BIN_DIR) install -m 0755 $(BIN) $(BIN_DIR)/$(BIN) @if [ -f scripts/signal-cli-jvm ]; then \ @@ -216,6 +239,7 @@ install: binary fi 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) + install -m 0755 $(SECURITY_SHIM) $(BIN_DIR)/$(SECURITY_SHIM) @echo "Installed $(BIN) to $(BIN_DIR)/$(BIN)" install-log-shim: log-shim @@ -227,21 +251,37 @@ clean: rm -f $(BIN) rm -f signal_tui_shim.dylib signal_tui_shim.so rm -f signal_log_shim.dylib signal_log_shim.so + rm -f signal_security_shim.dylib signal_security_shim.so find signal \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true rm -rf dist -vendor-deps: ensure-jsqlite vendor/termbox2 +vendor-deps: ensure-jsqlite ensure-termbox2 ensure-jsqlite: @if [ ! -f "$(JSQLITE_SRC)/jsqlite/api.ss" ]; then \ mkdir -p "$(VENDOR)"; \ - git clone --depth 1 "$(JSQLITE_URL)" "$(JSQLITE_REPO)"; \ + rm -rf "$(JSQLITE_REPO)"; \ + git init "$(JSQLITE_REPO)"; \ + git -C "$(JSQLITE_REPO)" remote add origin "$(JSQLITE_URL)"; \ + git -C "$(JSQLITE_REPO)" fetch --depth 1 origin "$(JSQLITE_REV)"; \ + git -C "$(JSQLITE_REPO)" checkout --detach "$(JSQLITE_REV)"; \ fi @test -f "$(JSQLITE_SRC)/jsqlite/api.ss" + @test "$$(git -C "$(JSQLITE_REPO)" rev-parse HEAD)" = "$(JSQLITE_REV)" || { echo "ERROR: vendor/jsqlite is not at pinned revision $(JSQLITE_REV)"; exit 1; } + @test "$$(git -C "$(JSQLITE_REPO)" rev-parse HEAD^{tree})" = "$(JSQLITE_TREE)" || { echo "ERROR: vendor/jsqlite tree hash mismatch"; exit 1; } -vendor/termbox2: - mkdir -p vendor - git clone --depth 1 https://github.com/termbox/termbox2.git vendor/termbox2 +ensure-termbox2: + @if [ ! -f "$(TUI_SHIM_DIR)/termbox2.h" ]; then \ + mkdir -p "$(VENDOR)"; \ + rm -rf "$(TUI_SHIM_DIR)"; \ + git init "$(TUI_SHIM_DIR)"; \ + git -C "$(TUI_SHIM_DIR)" remote add origin "$(TERMBOX2_URL)"; \ + git -C "$(TUI_SHIM_DIR)" fetch --depth 1 origin "$(TERMBOX2_REV)"; \ + git -C "$(TUI_SHIM_DIR)" checkout --detach "$(TERMBOX2_REV)"; \ + fi + @test -f "$(TUI_SHIM_DIR)/termbox2.h" + @test "$$(git -C "$(TUI_SHIM_DIR)" rev-parse HEAD)" = "$(TERMBOX2_REV)" || { echo "ERROR: vendor/termbox2 is not at pinned revision $(TERMBOX2_REV)"; exit 1; } + @test "$$(git -C "$(TUI_SHIM_DIR)" rev-parse HEAD^{tree})" = "$(TERMBOX2_TREE)" || { echo "ERROR: vendor/termbox2 tree hash mismatch"; exit 1; } ensure-jerboa-tools: @if [ -x ./jerbuild ] && [ -x ./jerboa ]; then \ @@ -281,12 +321,11 @@ ensure-jerboa-native: ensure-jerboa-tools exit 1; \ } -tui-shim: vendor/termbox2 - @test -f $(TUI_SHIM) || \ - cc -shared -fPIC -DTB_OPT_ATTR_W=32 \ - -I$(TUI_SHIM_DIR) \ - -o $(TUI_SHIM) \ - signal/tui/signal_tui_shim.c +tui-shim: ensure-termbox2 + cc -shared -fPIC -DTB_OPT_ATTR_W=32 \ + -I$(TUI_SHIM_DIR) \ + -o $(TUI_SHIM) \ + signal/tui/signal_tui_shim.c cp $(TUI_SHIM) signal_tui_shim.$(TUI_SHIM_EXT) log-shim: @@ -300,6 +339,15 @@ log-shim: -o $(LOG_SHIM) \ signal/log_shim.c +security-shim: + cc -shared -fPIC -o $(SECURITY_SHIM) support/signal_security.c + +native-test-stage: ensure-jerboa-native security-shim + @test -f "$(JERBOA_NATIVE_LIB)" || { echo "ERROR: missing dynamic native test library: $(JERBOA_NATIVE_LIB)"; exit 1; } + install -d -m 0700 "$(NATIVE_TEST_STAGE)" + install -m 0700 "$(JERBOA_NATIVE_LIB)" "$(STAGED_JERBOA_NATIVE_LIB)" + install -m 0700 "$(SECURITY_SHIM)" "$(STAGED_SECURITY_SHIM)" + help: @echo "jerboa-signal -- Signal client over signal-cli" @echo "" --- a/README.md +++ b/README.md @@ -123,11 +123,13 @@ durability issues, use: jerboa-signal --trace-all /tmp/jerboa-signal.raw.trace --log-persist immediate tui ``` -The default is `immediate`, which persists after every logged message so other -readers and future app launches see the newest rows even if the TUI exits -uncleanly. `--log-persist close` defers jsqlite encrypted-container writes until -clean exit, which can reduce pauses with a large encrypted history, but new rows -from that session may be lost if the process crashes. +The default is `batch`: the encrypted container is rewritten after 64 rows, +1 MiB of new retained payload, or two seconds, whichever comes first, and at +clean close. This avoids whole-database work for every message while bounding +the crash window. `--log-persist immediate` forces a rewrite after every row; +`--log-persist close` persists only at clean exit. Operators can lower the +bounded batch thresholds with `JERBOA_SIGNAL_LOG_BATCH_ROWS`, +`JERBOA_SIGNAL_LOG_BATCH_BYTES`, and `JERBOA_SIGNAL_LOG_BATCH_MS`. ## Security @@ -160,9 +162,12 @@ Enable it: ./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. +At startup the TUI asks for a passphrase on the controlling terminal. It fails +closed if the exact terminal state cannot be saved, echo disabled, or the state +restored. Enter one to log; leave it blank to skip logging for that session. +For unattended use, prefer `--log-key-fd FD` with a dedicated inherited FD. +`JERBOA_SIGNAL_DB_KEY` remains available but exposes the secret to the process +environment. The log lives at `~/.local/share/jerboa-signal/messages-<account>.db`. Each inbound notification and outbound send is one append-only row (with the full raw @@ -170,6 +175,12 @@ JSON); remote-deletes, edits, reactions, calls, attachments, and other displayable events are logged as new rows and never overwrite the original. +Retention is bounded by default to 20,000 rows, 128 MiB of retained payload, +and 4 MiB per entry; oldest rows are pruned before an insert exceeds the row or +payload budget. The limits can be lowered with `JERBOA_SIGNAL_LOG_MAX_ROWS`, +`JERBOA_SIGNAL_LOG_MAX_PAYLOAD_BYTES`, and +`JERBOA_SIGNAL_LOG_MAX_ENTRY_BYTES`. + To convert an older SQLCipher log to the current jsqlite container format: ```sh --- a/SECURITY.md +++ b/SECURITY.md @@ -42,8 +42,13 @@ can still reveal account activity. Use local disk encryption and restrict backups for Signal client hosts. `JERBOA_SIGNAL_DB_KEY` is accepted for unattended sessions but exposes the log -passphrase through the process environment. Prefer interactive prompting or a -supervisor secret store. +passphrase through the process environment. Prefer fail-closed controlling-TTY +entry or `--log-key-fd FD` from a supervisor secret pipe. + +Encrypted-container persistence is batched and local history is bounded by row, +payload, and per-entry quotas. Automatic attachment export is disabled by +default and, when enabled, is bounded by count/space quotas and creates +exclusive mode-0600 files under a non-symlink absolute root. ## External Dependencies --- a/docs/credential-handling.md +++ b/docs/credential-handling.md @@ -18,10 +18,12 @@ identity keys, sessions, and Signal service credentials are owned by The encrypted message log prompts for a passphrase before the TUI starts. Leave the prompt blank to disable logging for a session. -`JERBOA_SIGNAL_DB_KEY` is supported for unattended runs. It is convenient but -less private because environment variables may be visible to supervisors, -diagnostics, shell history wrappers, or crash reports. Production deployments -should prefer interactive entry or a protected service secret store. +Interactive entry uses `/dev/tty`, disables echo only after saving the exact +terminal state, and fails closed if disable or restoration fails. For unattended +runs, pass a dedicated inherited descriptor with `--log-key-fd FD` (FD 3 or +higher). `JERBOA_SIGNAL_DB_KEY` is convenient but less private because +environment variables may be visible to supervisors, diagnostics, shell +wrappers, or crash reports. Rotate the log passphrase by creating a new encrypted log and importing only the history you still need. Treat old `.sqlcipher.bak` migration backups as @@ -39,12 +41,17 @@ delete them after debugging. ## Attachments -Attachments are copied from the local `signal-cli` attachment cache to -`~/Downloads/jerboa-signal/` or `JERBOA_SIGNAL_DOWNLOAD_DIR`. +Automatic attachment export is off by default. Set +`JERBOA_SIGNAL_AUTO_EXPORT_ATTACHMENTS=1` to copy from the local `signal-cli` +attachment cache to `~/Downloads/jerboa-signal/` or an absolute +`JERBOA_SIGNAL_DOWNLOAD_DIR`. -Destination names are sanitized and never overwrite existing files, but the -exported content is plaintext. Keep the download root outside synced or shared -directories unless that is intentional. +Destination names are sanitized, files are created exclusively at mode `0600`, +symlinked sources/roots are rejected, and existing files are never overwritten. +Per-message count, per-file, per-conversation, total-tree, and free-space-reserve +limits are controlled by the bounded `JERBOA_SIGNAL_ATTACHMENT_*` variables. +Exported content is plaintext; keep the root outside synced/shared directories +unless that is intentional. ## Release Checklist --- a/docs/ffi-boundary.md +++ b/docs/ffi-boundary.md @@ -10,6 +10,11 @@ The encrypted jsqlite log container uses `jerboa_scrypt`, `jerboa_aead_seal`, archive or dynamic library. - Native loading is lazy through `(jerboa ffi)` `load-shared-object*`. +- Dynamic fallbacks never search the current directory or use a bare soname; + paths must be canonical, absolute, owner-controlled, and non-writable by + group/other. Environment overrides require `JERBOA_SIGNAL_DEV_NATIVE=1`. +- Availability is proven by calling the expected ABI canary, not by merely + constructing a foreign procedure object. - Importing the log module must not load or require a dynamic library. - Scheme passes bytevectors and explicit lengths for key derivation and AEAD. - Output lengths return through Scheme-managed bytevectors, not `foreign-alloc`. @@ -38,6 +43,14 @@ The SQLCipher shim exists only to migrate older logs. - Release candidates with legacy migration support should build the shim on a host with SQLCipher installed and record native linkage. +## Security Helper + +The bundled `support/signal_security.c` boundary validates dynamic-library +paths, obtains filesystem free space, performs fail-closed controlling-terminal +secret input, and pins attachment source/destination descriptors. Attachment +opens use no-follow/inode checks and exclusive mode-0600 creation; Scheme copies +exactly the size observed on the pinned descriptor. + ## Do Not Do - Do not add top-level `load-shared-object` calls. --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -34,8 +34,11 @@ delegated to `signal-cli`. - Environment variables: `JERBOA_SIGNAL_CLI`, `JERBOA_SIGNAL_CLI_LOG`, `JERBOA_SIGNAL_CLI_VERBOSE`, `JERBOA_SIGNAL_TRACE`, `JERBOA_SIGNAL_TRACE_SENSITIVE`, `JERBOA_SIGNAL_DB_KEY`, + `JERBOA_SIGNAL_DB_KEY_FD`, `JERBOA_SIGNAL_LOG_BACKEND`, `JERBOA_SIGNAL_LOG_PERSIST`, - `JERBOA_SIGNAL_DOWNLOAD_DIR`, and `JERBOA_SIGNAL_CLI_DATA_DIR`. + log batch/retention limits, `JERBOA_SIGNAL_AUTO_EXPORT_ATTACHMENTS`, + attachment quotas, `JERBOA_SIGNAL_DOWNLOAD_DIR`, and + `JERBOA_SIGNAL_CLI_DATA_DIR`. - Local files: `signal-cli` account state, attachments, encrypted logs, migration backups, trace files, removed-conversation state, and themes. - Native libraries: bundled Jerboa native crypto, optional termbox2 shim, and @@ -52,10 +55,16 @@ delegated to `signal-cli`. - Write state and trace files through checked paths and safe wrappers. State files created by this app must use mode `0600` when they contain account or preference state. -- Sanitize attachment destination directories and filenames, never overwrite - existing downloads, and keep exports under an explicit user download root. +- Keep automatic attachment export opt-in; enforce count, file, conversation, + total, and free-space quotas. Pin regular sources without following symlinks, + create mode-0600 destinations exclusively, and reject symlinked roots. +- Bound every child stdout/stderr line before JSON parsing or diagnostic output; + terminate the child on a framing violation. +- Batch whole-container encrypted-log persistence and enforce row, aggregate + payload, and per-entry retention limits under a per-handle mutex. - Load native libraries lazily through `(jerboa ffi)` so import and static - binary startup do not crash before user policy can run. + binary startup do not crash before user policy can run. Dynamic fallbacks are + canonical absolute owner-controlled paths and must pass a called ABI canary. - Keep C shim mutable event buffers thread-local or caller-owned. ## Residual Production Blockers --- a/scripts/sbom.sh +++ b/scripts/sbom.sh @@ -11,6 +11,12 @@ BIN=${BIN:-jerboa-signal} JERBOA_NATIVE_MANIFEST=${JERBOA_NATIVE_MANIFEST:-} JERBOA_NATIVE_A=${JERBOA_NATIVE_A:-} JSQLITE_SRC=${JSQLITE_SRC:-vendor/jsqlite/src} +JSQLITE_REPO=${JSQLITE_REPO:-vendor/jsqlite} +JSQLITE_REV=${JSQLITE_REV:-unknown} +JSQLITE_TREE=${JSQLITE_TREE:-unknown} +TERMBOX2_REPO=${TERMBOX2_REPO:-vendor/termbox2} +TERMBOX2_REV=${TERMBOX2_REV:-unknown} +TERMBOX2_TREE=${TERMBOX2_TREE:-unknown} SIGNAL_CLI=${SIGNAL_CLI:-signal-cli} rm -rf "$OUT" @@ -103,6 +109,27 @@ if [ -n "$JERBOA_NATIVE_A" ] && [ -f "$JERBOA_NATIVE_A" ]; then native_staticlib_status=present fi +actual_git_value() { + repo=$1 + expression=$2 + if [ -d "$repo/.git" ]; then + git -C "$repo" rev-parse "$expression" 2>/dev/null || echo unavailable + else + echo unavailable + fi +} + +{ + echo "jsqlite_expected_commit=$JSQLITE_REV" + echo "jsqlite_expected_tree=$JSQLITE_TREE" + echo "jsqlite_actual_commit=$(actual_git_value "$JSQLITE_REPO" HEAD)" + echo "jsqlite_actual_tree=$(actual_git_value "$JSQLITE_REPO" 'HEAD^{tree}')" + echo "termbox2_expected_commit=$TERMBOX2_REV" + echo "termbox2_expected_tree=$TERMBOX2_TREE" + echo "termbox2_actual_commit=$(actual_git_value "$TERMBOX2_REPO" HEAD)" + echo "termbox2_actual_tree=$(actual_git_value "$TERMBOX2_REPO" 'HEAD^{tree}')" +} > "$OUT/vendor-revisions.txt" + { echo "repo=jerboa-signal" echo "sbom_status=present" @@ -123,6 +150,8 @@ fi echo "signal_cli_security_advisory_status=no-published-github-advisories-as-of-2026-06-23" echo "upstream_security_policy_status=no-upstream-security-policy-as-of-2026-06-23" echo "jsqlite_status=$jsqlite_status" + echo "jsqlite_pin_status=full-commit-and-tree-validated" + echo "termbox2_pin_status=full-commit-and-tree-validated" echo "native_dependency=jerboa-native-rs" echo "jerboa_native_manifest_status=$native_manifest_status" echo "jerboa_native_staticlib_status=$native_staticlib_status" --- a/scripts/security-check.sh +++ b/scripts/security-check.sh @@ -29,6 +29,12 @@ require_file scripts/reproducibility-report.sh require_file scripts/binary-smoke.sh require_file scripts/soak-evidence.sh require_file scripts/signal-cli-advisory-check.sh +require_file support/signal_security.c +require_file support/ffi-symbols.list +require_file tests/test-bounded-line.ss +require_file tests/test-secret-input.ss +require_file tests/test-attachments.ss +require_file tests/test-native-loader.ss grep -q 'JSIGNAL_REQUIRE_TARGET_LIVE_PROOF=1' docs/release-evidence.md 2>/dev/null || \ note_fail "release evidence docs must describe required target live proof" @@ -54,6 +60,28 @@ grep -q 'reject_sensitive_proof' scripts/signal-cli-advisory-check.sh 2>/dev/nul note_fail "signal-cli advisory proof must reject sensitive proof material before copy" grep -q 'proofMaterialPolicy' .jerboa/security.json 2>/dev/null || \ note_fail "security policy must record proof material policy" +grep -q 'JERBOA_SIGNAL_AUTO_EXPORT_ATTACHMENTS' signal/attach-save.ss 2>/dev/null || \ + note_fail "attachment export must remain explicitly opt-in" +grep -q 'signal_read_secret_tty' support/signal_security.c 2>/dev/null || \ + note_fail "secret prompt must use the fail-closed controlling-terminal helper" +grep -q 'read-bounded-line' signal/rpc.ss 2>/dev/null || \ + note_fail "one-shot RPC stdout must use bounded framing" +grep -q 'read-bounded-line' signal/rpc-actor.ss 2>/dev/null || \ + note_fail "actor RPC stdout must use bounded framing" +grep -Eq '^JSQLITE_REV \?= [0-9a-f]{40}$' Makefile 2>/dev/null || \ + note_fail "jsqlite must be pinned to a full commit hash" +grep -Eq '^TERMBOX2_REV \?= [0-9a-f]{40}$' Makefile 2>/dev/null || \ + note_fail "termbox2 must be pinned to a full commit hash" +grep -q '^ensure-termbox2:' Makefile 2>/dev/null || \ + note_fail "termbox2 pin/tree validation must run even when vendor exists" + +if grep -n -I 'get-line' signal/rpc.ss signal/rpc-actor.ss 2>/dev/null; then + note_fail "unbounded child-output get-line remains in RPC code" +fi + +if grep -n -I 'stty' signal/logdb.ss 2>/dev/null; then + note_fail "shell-based terminal echo handling remains in log passphrase input" +fi if grep -R -n -I '(chezscheme)' signal tests 2>/dev/null; then note_fail "direct (chezscheme) import remains in runtime/test source" --- a/signal/attach-save.ss +++ b/signal/attach-save.ss @@ -3,7 +3,7 @@ ;;; ;;; signal-cli already downloads every received attachment into its data dir ;;; (<XDG_DATA_HOME or ~/.local/share>/signal-cli/attachments/<id>[.ext]). This -;;; module copies those files, as each message arrives, into +;;; module can copy those files, when explicitly enabled, into ;;; ;;; ~/Downloads/jerboa-signal/<conversation>/<original-name> ;;; @@ -12,7 +12,8 @@ ;;; Only inbound dataMessage attachments are exported -- not our own sync sends. (library (signal attach-save) - (export save-notification-attachments! save-envelope-attachments! download-base) + (export save-notification-attachments! save-envelope-attachments! + attachment-auto-export-enabled? download-base) (import (except (scheme) make-hash-table hash-table? @@ -23,7 +24,28 @@ iota 1+ 1- partition make-date make-time) - (except (jerboa prelude) meta atom?)) + (except (jerboa prelude) meta atom?) + (std os posix) + (signal security-native)) + + (def *export-lock* (make-mutex)) + (def *export-base* (box #f)) + (def *export-total-bytes* (box 0)) + (def *export-folder-bytes* (make-hashtable equal-hash equal?)) + (def *exported-sources* (make-hashtable equal-hash equal?)) + (def *export-scan-entry-limit* 100000) + (def *copy-collision-limit* 128) + + (def (truthy-env? name) + (let ([value (getenv name)]) + (and (string? value) + (or (string-ci=? value "1") + (string-ci=? value "on") + (string-ci=? value "true") + (string-ci=? value "yes"))))) + + (def (attachment-auto-export-enabled?) + (truthy-env? "JERBOA_SIGNAL_AUTO_EXPORT_ATTACHMENTS")) ;; --- entry points ------------------------------------------------------- @@ -31,14 +53,18 @@ ;; list of destination paths written (empty when there's nothing to save). (def (save-notification-attachments! notif) (guard (e [(condition? e) '()]) - (let ([env (notif-envelope notif)]) - (if (hashtable? env) (save-envelope-attachments! env) '())))) + (if (not (attachment-auto-export-enabled?)) + '() + (let ([env (notif-envelope notif)]) + (if (hashtable? env) (save-envelope-attachments! env) '()))))) (def (save-envelope-attachments! envelope) (guard (e [(condition? e) '()]) - (let ([data (htref envelope "dataMessage")]) + (let ([data (and (attachment-auto-export-enabled?) + (htref envelope "dataMessage"))]) (if (hashtable? data) - (let ([atts (->list (htref data "attachments"))]) + (let ([atts (bounded-list (htref data "attachments") + (attachment-max-count))]) (if (pair? atts) (let ([folder (conversation-folder envelope data)]) (let loop ([xs atts] [acc '()]) @@ -55,12 +81,145 @@ (let* ([id (->id-string (htref attach "id"))] [src (find-source-file (attachments-source-dir) id)]) (and src - (let* ([destdir (string-append (download-base) "/" folder)] - [name (dest-filename attach id src)] - [dst (unique-dest destdir name)]) - (mkdir-p destdir) - (copy-file! src dst) - dst)))))) + (with-export-lock + (lambda () + (let ([source (open-pinned-source src)]) + (and source + (dynamic-wind + (lambda () (void)) + (lambda () + (let* ([source-fd (car source)] + [size (cadr source)] + [source-key (caddr source)] + [base (download-base)] + [destdir + (string-append base "/" folder)] + [name (dest-filename attach id src)]) + (mkdir-p-safe base) + (initialize-export-state! base) + (let ([prior + (hashtable-ref + *exported-sources* source-key #f)]) + (if prior + #f + (begin + (mkdir-p-safe destdir) + (and + (export-capacity-available? + destdir folder size) + (let ([dst + (copy-file-exclusive! + source-fd size destdir name)]) + (and dst + (begin + (set-box! + *export-total-bytes* + (+ (unbox *export-total-bytes*) + size)) + (hashtable-set! + *export-folder-bytes* + folder + (+ (hashtable-ref + *export-folder-bytes* + folder 0) + size)) + (hashtable-set! + *exported-sources* + source-key dst) + dst))))))))) + (lambda () + (guard (_ [(condition? _) (void)]) + (posix-close (car source)))))))))))))) + + (def (with-export-lock thunk) + (dynamic-wind + (lambda () (mutex-acquire *export-lock*)) + thunk + (lambda () (mutex-release *export-lock*)))) + + (def (bounded-positive-env name fallback hard-limit) + (let* ([raw (getenv name)] + [value (and raw (string->number raw))]) + (if (and (integer? value) (exact? value) + (> value 0) (<= value hard-limit)) + value + fallback))) + + (def (attachment-max-file-bytes) + (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_MAX_FILE_BYTES" + (* 25 1024 1024) + (* 256 1024 1024))) + + (def (attachment-max-count) + (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_MAX_COUNT" 32 256)) + + (def (attachment-max-conversation-bytes) + (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_MAX_CONVERSATION_BYTES" + (* 250 1024 1024) + (* 2 1024 1024 1024))) + + (def (attachment-max-total-bytes) + (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_MAX_TOTAL_BYTES" + (* 1024 1024 1024) + (* 10 1024 1024 1024))) + + (def (attachment-free-reserve-bytes) + (bounded-positive-env "JERBOA_SIGNAL_ATTACHMENT_FREE_RESERVE_BYTES" + (* 512 1024 1024) + (* 10 1024 1024 1024))) + + (def (initialize-export-state! base) + (unless (and (path-absolute? base) + (file-directory? base) + (safe-directory-tree? base)) + (error 'attachment-export "download base must be an absolute directory" + base)) + (unless (equal? (unbox *export-base*) base) + (set-box! *export-base* base) + (set-box! *export-total-bytes* (bounded-tree-bytes base)) + (hashtable-clear! *export-folder-bytes*) + (hashtable-clear! *exported-sources*))) + + (def (bounded-tree-bytes root) + (let ([seen 0]) + (let walk ([path root]) + (set! seen (+ seen 1)) + (when (> seen *export-scan-entry-limit*) + (error 'attachment-export "attachment tree exceeds scan entry cap" + *export-scan-entry-limit*)) + (cond + [(file-symbolic-link? path) 0] + [(file-regular? path) (file-length path)] + [(file-directory? path) + (let loop ([entries (directory-list path)] [total 0]) + (if (null? entries) + total + (loop (cdr entries) + (+ total + (walk (string-append path "/" (car entries)))))))] + [else 0])))) + + (def (folder-bytes folder-path folder) + (let ([known (hashtable-ref *export-folder-bytes* folder #f)]) + (if known + known + (let ([bytes (if (file-directory? folder-path) + (bounded-tree-bytes folder-path) + 0)]) + (hashtable-set! *export-folder-bytes* folder bytes) + bytes)))) + + (def (export-capacity-available? destdir folder incoming) + (let ([free (filesystem-free-bytes destdir)]) + (and (integer? incoming) + (>= incoming 0) + (<= incoming (attachment-max-file-bytes)) + (<= (+ (folder-bytes destdir folder) incoming) + (attachment-max-conversation-bytes)) + (<= (+ (unbox *export-total-bytes*) incoming) + (attachment-max-total-bytes)) + (>= free 0) + (>= (- free incoming) (attachment-free-reserve-bytes))))) ;; --- locations ---------------------------------------------------------- @@ -69,8 +228,13 @@ (def (download-base) (let ([env (getenv "JERBOA_SIGNAL_DOWNLOAD_DIR")]) (if (nonempty env) - env - (string-append (home) "/Downloads/jerboa-signal")))) + (if (path-absolute? env) + env + (error 'download-base "attachment download root must be absolute")) + (let ([h (home)]) + (if (path-absolute? h) + (string-append h "/Downloads/jerboa-signal") + (error 'download-base "HOME must be absolute for attachment export")))))) (def (attachments-source-dir) (string-append (signal-cli-data-dir) "/attachments")) @@ -78,10 +242,15 @@ (def (signal-cli-data-dir) (let ([ov (getenv "JERBOA_SIGNAL_CLI_DATA_DIR")] [xdg (getenv "XDG_DATA_HOME")]) - (cond - [(nonempty ov) ov] - [(nonempty xdg) (string-append xdg "/signal-cli")] - [else (string-append (home) "/.local/share/signal-cli")]))) + (let ([path + (cond + [(nonempty ov) ov] + [(nonempty xdg) (string-append xdg "/signal-cli")] + [else (string-append (home) "/.local/share/signal-cli")])]) + (if (path-absolute? path) + path + (error 'attachments-source-dir + "signal-cli data directory must be absolute"))))) ;; --- conversation folder ------------------------------------------------ @@ -140,44 +309,98 @@ (string-append (sanitize-filename (or id "attachment")) (file-ext (path-basename src)))))) - ;; Append " (n)" before the extension until the name is free -- never - ;; overwrite an existing file. - (def (unique-dest dir filename) - (let ([full (string-append dir "/" filename)]) - (if (not (file-exists? full)) - full - (let ([stem (file-stem filename)] - [ext (file-ext filename)]) - (let loop ([n 1]) - (let ([cand (string-append dir "/" stem " (" (number->string n) ")" ext)]) - (if (file-exists? cand) (loop (+ n 1)) cand))))))) - ;; --- copy --------------------------------------------------------------- - (def (copy-file! src dst) - (let ([in (open-file-input-port src)]) - (dynamic-wind - (lambda () (void)) - (lambda () - (let ([out (open-file-output-port dst (file-options no-fail))]) - (dynamic-wind - (lambda () (void)) - (lambda () - (let loop () - (let ([chunk (get-bytevector-n in 65536)]) - (unless (eof-object? chunk) - (put-bytevector out chunk) - (loop))))) - (lambda () (close-port out))))) - (lambda () (close-port in))))) - - ;; mkdir each ancestor in turn; the base (~/Downloads) usually exists but the - ;; per-conversation dir won't, and a custom base might be missing entirely. - (def (mkdir-p dir) - (unless (or (string=? dir "") (string=? dir "/") (file-exists? dir)) + (def (open-pinned-source src) + (guard (_ [(condition? _) #f]) + (let ([fd (open-attachment-source src)]) + (and (integer? fd) + (>= fd 0) + (let ([size (attachment-source-size fd)]) + (if (and (integer? size) (>= size 0)) + (list fd size (list src size)) + (begin + (posix-close fd) + #f))))))) + + (def (copy-file-exclusive! source-fd source-size dir filename) + (let ([stem (file-stem filename)] + [ext (file-ext filename)]) + (let loop ([attempt 0]) + (and (< attempt *copy-collision-limit*) + (let* ([name (if (= attempt 0) + filename + (string-append stem " (" (number->string attempt) + ")" ext))] + [dst (string-append dir "/" name)] + [fd (open-attachment-destination dst)]) + (cond + [(= fd -2) (loop (+ attempt 1))] + [(< fd 0) #f] + [else + (guard (e [(condition? e) + (guard (_ [(condition? _) (void)]) + (posix-close fd)) + (guard (_ [(condition? _) (void)]) + (posix-unlink dst)) + #f]) + (posix-lseek source-fd 0 SEEK_SET) + (copy-source-fd! source-fd fd source-size) + (posix-close fd) + dst)])))))) + + (def (copy-source-fd! source-fd destination-fd expected-size) + (let loop ([copied 0]) + (if (= copied expected-size) + (let ([extra (make-bytevector 1 0)]) + (unless (= (posix-read source-fd extra 1) 0) + (error 'attachment-export "attachment grew during export")) + copied) + (let* ([remaining (- expected-size copied)] + [want (min remaining 65536)] + [chunk (make-bytevector want 0)] + [read-count (posix-read source-fd chunk want)]) + (when (= read-count 0) + (error 'attachment-export "attachment shrank during export")) + (write-all-fd! destination-fd chunk read-count) + (loop (+ copied read-count)))))) + + (def (write-all-fd! fd bytes length) + (let loop ([offset 0]) + (when (< offset length) + (let* ([remaining (- length offset)] + [chunk (if (= offset 0) + bytes + (let ([out (make-bytevector remaining 0)]) + (bytevector-copy! bytes offset out 0 remaining) + out))] + [written (posix-write fd chunk remaining)]) + (when (<= written 0) + (error 'attachment-export "short write" written)) + (loop (+ offset written)))))) + + ;; Create each ancestor and reject symlink components. The export lock closes + ;; application-level races; O_NOFOLLOW/O_EXCL protect the file endpoints. + (def (mkdir-p-safe dir) + (unless (path-absolute? dir) + (error 'attachment-export "attachment directory must be absolute" dir)) + (unless (or (string=? dir "/") (file-exists? dir)) (let ([parent (parent-dir dir)]) - (unless (string=? parent dir) (mkdir-p parent))) - (guard (e [(condition? e) (void)]) (mkdir dir)))) + (unless (string=? parent dir) (mkdir-p-safe parent))) + (mkdir dir)) + (unless (and (file-directory? dir) + (not (file-symbolic-link? dir)) + (safe-directory-tree? dir)) + (error 'attachment-export "unsafe attachment directory" dir))) + + (def (safe-directory-tree? dir) + (cond + [(string=? dir "/") #t] + [(or (not (file-directory? dir)) (file-symbolic-link? dir)) #f] + [else + (let ([parent (parent-dir dir)]) + (and (not (string=? parent dir)) + (safe-directory-tree? parent)))])) (def (parent-dir path) (let loop ([i (- (string-length path) 1)]) @@ -199,6 +422,12 @@ (def (->list x) (cond [(list? x) x] [(vector? x) (vector->list x)] [else '()])) + (def (bounded-list x limit) + (let loop ([xs (->list x)] [remaining limit] [acc '()]) + (if (or (null? xs) (= remaining 0)) + (reverse acc) + (loop (cdr xs) (- remaining 1) (cons (car xs) acc))))) + (def (first-string . vs) (let loop ([xs vs]) (cond [(null? xs) "unknown"] new file mode 100644 --- /dev/null +++ b/signal/bounded-line.ss @@ -0,0 +1,61 @@ +#!chezscheme +;;; signal/bounded-line -- bounded textual framing for hostile child output. + +(library (signal bounded-line) + (export read-bounded-line rpc-frame-line-limit rpc-stderr-line-limit) + + (import (except (scheme) + 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?)) + + (def *default-rpc-frame-chars* 262144) + (def *hard-rpc-frame-chars* 2097152) + (def *default-stderr-line-chars* 16384) + (def *hard-stderr-line-chars* 262144) + + (def (configured-limit name fallback hard-limit) + (let* ([raw (getenv name)] + [value (and raw (string->number raw))]) + (if (and (integer? value) (exact? value) + (> value 0) (<= value hard-limit)) + value + fallback))) + + (def (rpc-frame-line-limit) + (configured-limit "JERBOA_SIGNAL_RPC_MAX_FRAME_CHARS" + *default-rpc-frame-chars* + *hard-rpc-frame-chars*)) + + (def (rpc-stderr-line-limit) + (configured-limit "JERBOA_SIGNAL_RPC_MAX_STDERR_CHARS" + *default-stderr-line-chars* + *hard-stderr-line-chars*)) + + ;; Match get-line's EOF behavior while never retaining more than LIMIT + ;; characters. The caller treats a limit error as a protocol violation and + ;; terminates the child instead of attempting to drain an untrusted stream. + (def (read-bounded-line port limit who) + (unless (and (integer? limit) (exact? limit) (> limit 0)) + (error who "line limit must be a positive exact integer" limit)) + (let ([out (open-output-string)]) + (let loop ([count 0]) + (let ([ch (get-char port)]) + (cond + [(eof-object? ch) + (if (= count 0) ch (get-output-string out))] + [(char=? ch #\newline) (get-output-string out)] + [(>= count limit) + (error who "signal-cli line exceeds configured character limit" + limit)] + [else + (write-char ch out) + (loop (+ count 1))]))))) + + ) ;; end library --- a/signal/cmd-tui.ss +++ b/signal/cmd-tui.ss @@ -1,9 +1,8 @@ #!chezscheme ;;; signal/cmd-tui -- terminal UI entry point. ;;; -;;; Starts the long-lived JSON-RPC actor in manual receive mode, subscribes -;;; after the reader is running, and then runs the termbox2 TUI when -;;; stdin/stdout are attached to a terminal. +;;; Starts the long-lived JSON-RPC actor in live receive mode, then runs the +;;; termbox2 TUI when stdin/stdout are attached to a terminal. (library (signal cmd-tui) (export cmd-tui) @@ -24,7 +23,7 @@ (signal tui main)) (def (cmd-tui account) - (let ([receive-mode "manual"]) + (let ([receive-mode "on-connection"]) (if (and (tty? (current-input-port)) (tty? (current-output-port))) (run-interactive-tui account receive-mode) --- a/signal/log_crypto.ss +++ b/signal/log_crypto.ss @@ -18,40 +18,19 @@ partition make-date make-time) (except (jerboa prelude) meta atom?) - (only (jerboa ffi) load-shared-object*) - (only (jerboa core) random-bytes)) + (only (jerboa core) random-bytes) + (signal native-loader) + (signal security-native)) (def *native-load-state* (box 'untried)) - (def (try-load path) - (and path - (not (string=? path "")) - (file-exists? path) - (load-shared-object* path))) - - (def (native-candidates) - (let* ([argv0 (car (command-line))] - [slash (let loop ([i (- (string-length argv0) 1)]) - (cond [(< i 0) #f] - [(char=? (string-ref argv0 i) #\/) i] - [else (loop (- i 1))]))] - [bin-dir (and slash (substring argv0 0 slash))] - [home (or (getenv "HOME") ".")] - [env (getenv "JERBOA_NATIVE_LIB")]) - (append - (if env (list env) '()) - (list "libjerboa_native.dylib" - "libjerboa_native.so" - "lib/libjerboa_native.dylib" - "lib/libjerboa_native.so")