Compare commits

...

2 Commits

Author SHA1 Message Date
b63a9d29f8 security: protect local data at rest (issue #146)
All checks were successful
CI Code / Check spelling (pull_request) Successful in 15s
CI Code / Check coding style (pull_request) Successful in 26s
CI Code / Linux (debian) (pull_request) Successful in 5m3s
CI Code / Linux (arch) (pull_request) Successful in 6m25s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m1s
CI Code / Code Coverage (pull_request) Successful in 9m27s
T03 — keep secrets out of profanity.log:
- new redact_secrets() helper (common.c) masks the content of SASL
  auth/response/challenge/success and <password> elements; applied in
  the libstrophe logger (_xmpp_file_logger) and the stderr-to-log
  bridge (REQ-DAR-03, REQ-LOG-06)
- _add_to_db() no longer logs decrypted message bodies or the full
  INSERT statement (REQ-DAR-03)

T07 — owner-only permissions on key material and history (REQ-AUTH-01):
- chmod 0600 on chatlog.db after open (journal/WAL files inherit)
- chmod 0600 on OTR keys.txt and fingerprints.txt after every write
- parent dirs are already 0700 (create_dir); this is defense in depth

T08 — integrity before trust:
- PRAGMA quick_check(1) gates every chatlog.db open; on failure the
  DB stays closed, the user is warned once in the console and the
  session continues without history (REQ-RES-02)
- OMEMO aesgcm downloads decrypt into a 0600 tempfile next to the
  target and rename it into place only after the GCM tag verifies;
  the open-command hook no longer runs on failed decryption
  (REQ-CRY-06)

Tests: redact_secrets unit tests; AES-256-GCM roundtrip and
tampered-tag/ciphertext unit tests (crypto.c now linked into
unittests under BUILD_OMEMO); functional test planting a corrupt
chatlog.db and asserting graceful degradation.

Closes #146
2026-07-21 10:25:31 +03:00
2be16df905 ci(docker): add Docker publishing workflow and Dockerfile
All checks were successful
CI Code / Check spelling (pull_request) Successful in 13s
CI Code / Check coding style (pull_request) Successful in 24s
CI Code / Code Coverage (pull_request) Successful in 3m42s
CI Code / Linux (debian) (pull_request) Successful in 5m13s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m18s
CI Code / Linux (arch) (pull_request) Successful in 6m59s
CI Code / Check spelling (push) Successful in 13s
CI Code / Check coding style (push) Successful in 23s
CI Code / Code Coverage (push) Successful in 3m38s
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 4m46s
CI Code / Linux (debian) (push) Successful in 5m26s
CI Code / Linux (ubuntu) (push) Successful in 5m33s
CI Code / Linux (arch) (push) Successful in 7m10s
Introduce GitHub Actions workflow for automated Docker image
publishing to Docker Hub, along with a dedicated Dockerfile in
the ci directory to streamline container builds.
2026-07-11 17:43:16 +00:00
19 changed files with 520 additions and 12 deletions

44
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Publish Docker image
on:
push:
branches: [master]
release:
types: [published]
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v7
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
with:
images: cproofdev/cproof
tags: |
type=raw,value=dev,enable={{is_default_branch}}
type=sha,prefix=nightly-,enable={{is_default_branch}}
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@2bd26e71295ee32cbf6a73510d165bf7232460f3
with:
context: .
file: ./ci/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -236,7 +236,10 @@ omemo_sources = \
src/tools/aesgcm_download.h src/tools/aesgcm_download.c
omemo_unittest_sources = \
tests/unittests/omemo/stub_omemo.c
tests/unittests/omemo/stub_omemo.c \
tests/unittests/omemo/test_omemo_crypto.c \
tests/unittests/omemo/test_omemo_crypto.h \
src/omemo/crypto.c
if BUILD_PYTHON_API
core_sources += $(python_sources)

106
ci/Dockerfile Normal file
View File

@@ -0,0 +1,106 @@
# =============================================================================
# Stage 1: Build
# =============================================================================
FROM ubuntu:26.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
autoconf \
automake \
autoconf-archive \
ca-certificates \
libtool \
pkg-config \
git \
wget \
libglib2.0-dev \
libcurl4-openssl-dev \
libsqlite3-dev \
libncurses-dev \
libreadline-dev \
libnotify-dev \
python3-dev \
python3 \
libgpgme-dev \
libotr5-dev \
libgtk-3-dev \
libgdk-pixbuf-2.0-dev \
libsignal-protocol-c-dev \
libgcrypt20-dev \
libqrencode-dev \
libxss-dev \
libcmocka-dev \
&& rm -rf /var/lib/apt/lists/*
# Build libstrophe from source (not available as Ubuntu package)
RUN git clone --depth 1 https://git.jabber.space/devs/libstrophe-gh-mirror.git /tmp/libstrophe \
&& cd /tmp/libstrophe \
&& autoreconf -i \
&& ./configure --prefix=/usr \
&& make -j$(nproc) \
&& make install \
&& rm -rf /tmp/libstrophe
WORKDIR /src
COPY . .
RUN ./bootstrap.sh \
&& ./configure \
--enable-notifications \
--enable-icons-and-clipboard \
--enable-otr \
--enable-pgp \
--enable-omemo \
--enable-plugins \
--enable-c-plugins \
--enable-python-plugins \
--with-xscreensaver \
--enable-omemo-qrcode \
--enable-gdk-pixbuf \
&& make -j$(nproc) \
&& make install
# =============================================================================
# Stage 2: Runtime (minimal)
# =============================================================================
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libglib2.0-0t64 \
libcurl4t64 \
libsqlite3-0 \
libncursesw6 \
libreadline8t64 \
libnotify4 \
python3 \
libpython3.14 \
libgpgme45 \
libotr5t64 \
libgtk-3-0t64 \
libgdk-pixbuf-2.0-0 \
libsignal-protocol-c2.3.2 \
libgcrypt20 \
libqrencode4 \
libxss1 \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY --from=builder /usr/local/bin/profanity /usr/local/bin/profanity
COPY --from=builder /usr/local/lib/libprofanity* /usr/local/lib/
COPY --from=builder /usr/local/include/profapi.h /usr/local/include/
COPY --from=builder /usr/local/share/profanity /usr/local/share/profanity
COPY --from=builder /usr/lib/libstrophe.so* /usr/lib/
RUN ldconfig
RUN ldd /usr/local/bin/profanity | grep 'not found' && exit 1 || true
RUN mkdir -p /root/.config/profanity
ENTRYPOINT ["profanity"]

View File

@@ -455,6 +455,32 @@ str_xml_sanitize(const char* const str)
return g_string_free(sanitized, FALSE);
}
gchar*
redact_secrets(const char* const str)
{
if (str == NULL) {
return NULL;
}
// SASL exchanges and <password> elements carry credentials — strip their content before logging
static gsize init = 0;
static GRegex* secret_regex = NULL;
if (g_once_init_enter(&init)) {
secret_regex = g_regex_new(
"(<(?:auth|response|challenge|success|password|digest)\\b[^>]*>)[^<]+(</(?:auth|response|challenge|success|password|digest)>)",
0, 0, NULL);
g_once_init_leave(&init, 1);
}
if (secret_regex == NULL) {
return g_strdup(str);
}
auto_gchar gchar* valid = g_utf8_make_valid(str, -1); // invalid UTF-8 would make the regex fail open
gchar* redacted = g_regex_replace(secret_regex, valid, -1, 0, "\\1[REDACTED]\\2", 0, NULL);
return redacted ? redacted : g_steal_pointer(&valid);
}
char*
release_get_latest(void)
{

View File

@@ -161,6 +161,7 @@ gboolean strtoi_range(const char* str, int* saveptr, int min, int max, char** er
gsize g_diff_to_gsize(const void* end, const void* start);
int utf8_display_len(const char* const str);
gchar* str_xml_sanitize(const char* const str);
gchar* redact_secrets(const char* const str);
gboolean string_matches_one_of(const char* what, const char* is, gboolean is_can_be_null, const char* first, ...) __attribute__((sentinel));
gboolean valid_tls_policy_option(const char* is);

View File

@@ -39,6 +39,7 @@
#include <sys/statvfs.h>
#include <sqlite3.h>
#include <glib.h>
#include <glib/gstdio.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -124,6 +125,15 @@ _get_db_filename(ProfAccount* account)
return files_file_in_account_data_path(DIR_DATABASE, account->jid, "chatlog.db");
}
static int
_quick_check_cb(void* intact, int argc, char** argv, char** column_names)
{
if (argc > 0 && argv[0] && strcmp(argv[0], "ok") == 0) {
*(gboolean*)intact = TRUE;
}
return 0;
}
static gboolean
_sqlite_init(ProfAccount* account)
{
@@ -149,6 +159,20 @@ _sqlite_init(ProfAccount* account)
return FALSE;
}
g_chmod(filename, S_IRUSR | S_IWUSR); // history holds plaintext; journal/WAL files inherit these perms
// catch corruption before running queries or migrations against the file
char* check_err = NULL;
gboolean intact = FALSE;
ret = sqlite3_exec(g_chatlog_database, "PRAGMA quick_check(1);", _quick_check_cb, &intact, &check_err);
if (ret != SQLITE_OK || !intact) {
log_error("Chat history database failed integrity check (%s): %s", filename,
check_err ? check_err : "quick_check did not return 'ok'");
sqlite3_free(check_err);
_db_teardown("_sqlite_init(quick_check)");
return FALSE;
}
char* err_msg = NULL;
int db_version = _get_db_version();
@@ -691,7 +715,7 @@ _add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, co
original_message_id = tmp ? tmp : original_message_id;
if (g_strcmp0(from_jid_orig, from_jid->barejid) != 0) {
log_error("Mismatch in sender JIDs when trying to do LMC. Corrected message sender: %s. Original message sender: %s. Replace-ID: %s. Message: %s", from_jid->barejid, from_jid_orig, message->replace_id, message->plain);
log_error("Mismatch in sender JIDs when trying to do LMC. Corrected message sender: %s. Original message sender: %s. Replace-ID: %s.", from_jid->barejid, from_jid_orig, message->replace_id);
cons_show_error("%s sent a message correction with mismatched sender. See log for details.", from_jid->barejid);
sqlite3_finalize(lmc_stmt);
return;
@@ -714,7 +738,7 @@ _add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, co
sqlite3_stmt* stmt;
if (_db_prepare_ctx(duplicate_check_query, &stmt, "_add_to_db(duplicate_check)")) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
log_error("Duplicate stanza-id found for the message. stanza_id: %s; archive_id: %s; sender: %s; content: %s", message->id, message->stanzaid, from_jid->barejid, message->plain);
log_error("Duplicate stanza-id found for the message. stanza_id: %s; archive_id: %s; sender: %s", message->id, message->stanzaid, from_jid->barejid);
cons_show_error("Got a message with duplicate (server-generated) stanza-id from %s.", from_jid->fulljid);
}
sqlite3_finalize(stmt);
@@ -745,7 +769,7 @@ _add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, co
return;
}
log_debug("Writing to DB. Query: %s", query);
log_debug("Writing message to DB (id: %s, stanza_id: %s, type: %s)", message->id, message->stanzaid, type); // no query text: it embeds the plaintext body
if (SQLITE_OK != sqlite3_exec(g_chatlog_database, query, NULL, 0, &err_msg)) {
if (err_msg) {
@@ -757,7 +781,7 @@ _add_to_db(ProfMessage* message, const char* type, const Jid* const from_jid, co
} else {
int inserted_rows_count = sqlite3_changes(g_chatlog_database);
if (inserted_rows_count < 1) {
log_error("SQLite did not insert message (rows: %d, id: %s, content: %s)", inserted_rows_count, message->id, message->plain);
log_error("SQLite did not insert message (rows: %d, id: %s)", inserted_rows_count, message->id);
}
}
}

View File

@@ -83,6 +83,7 @@ sv_ev_login_account_success(char* account_name, gboolean secured)
if (!log_database_init(account)) {
log_error("Failed to initialize database for account: %s", account->jid);
cons_show_error("Chat history storage is unavailable for this session, messages will not be saved. See the log for details.");
}
vcard_user_refresh();
avatar_pep_subscribe();

View File

@@ -286,7 +286,8 @@ log_stderr_handler(void)
for (int i = 0; i < size; ++i) {
if (buf[i] == '\n') {
log_msg(stderr_level, "stderr", s->str);
auto_gchar gchar* redacted = redact_secrets(s->str); // third-party libs may echo credentials
log_msg(stderr_level, "stderr", redacted);
g_string_assign(s, "");
} else
g_string_append_c(s, buf[i]);
@@ -294,7 +295,8 @@ log_stderr_handler(void)
} while (1);
if (s->len > 0 && s->str[0] != '\0') {
log_msg(stderr_level, "stderr", s->str);
auto_gchar gchar* redacted = redact_secrets(s->str);
log_msg(stderr_level, "stderr", redacted);
g_string_assign(s, "");
}
}

View File

@@ -14,6 +14,8 @@
#include <libotr/message.h>
#include <libotr/sm.h>
#include <glib.h>
#include <glib/gstdio.h>
#include <sys/stat.h>
#include "log.h"
#include "chatlog.h"
@@ -114,6 +116,8 @@ cb_write_fingerprints(void* opdata)
if (err != GPG_ERR_NO_ERROR) {
log_error("Failed to write fingerprints file");
cons_show_error("Failed to write fingerprints file");
} else {
g_chmod(fpsfilename, S_IRUSR | S_IWUSR);
}
}
@@ -376,6 +380,7 @@ otr_keygen(ProfAccount* account)
cons_show_error("Failed to generate private key");
return;
}
g_chmod(keysfilename->str, S_IRUSR | S_IWUSR);
log_info("Private key generated");
cons_show("");
cons_show("Private key generation complete.");
@@ -390,6 +395,7 @@ otr_keygen(ProfAccount* account)
cons_show_error("Failed to create fingerprints file");
return;
}
g_chmod(fpsfilename->str, S_IRUSR | S_IWUSR);
log_info("Fingerprints file created");
err = otrl_privkey_read(user_state, keysfilename->str);

View File

@@ -13,10 +13,12 @@
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <curl/curl.h>
#include <gio/gio.h>
#include <glib/gstdio.h>
#include <pthread.h>
#include <assert.h>
#include <errno.h>
@@ -62,14 +64,28 @@ aesgcm_file_get(void* userdata)
return NULL;
}
// Open the target file for storing the cleartext.
auto_FILE FILE* outfh = fopen(aesgcm_dl->filename, "wb");
// Decrypt into a temporary file next to the target and rename it into
// place only after the GCM tag verified, so tampered or truncated
// content never appears at the destination path.
auto_gchar gchar* partname = g_strdup_printf("%s.part.XXXXXX", aesgcm_dl->filename);
gint outfd = g_mkstemp(partname);
if (outfd == -1) {
http_print_transfer_update(aesgcm_dl->window, aesgcm_dl->id, THEME_ERROR, ENTRY_ERROR,
"Downloading '%s' failed: Unable to open "
"output file at '%s' for writing (%s).",
https_url, aesgcm_dl->filename,
g_strerror(errno));
return NULL;
}
FILE* outfh = fdopen(outfd, "wb");
if (outfh == NULL) {
http_print_transfer_update(aesgcm_dl->window, aesgcm_dl->id, THEME_ERROR, ENTRY_ERROR,
"Downloading '%s' failed: Unable to open "
"output file at '%s' for writing (%s).",
https_url, aesgcm_dl->filename,
g_strerror(errno));
close(outfd);
remove(partname);
return NULL;
}
@@ -90,6 +106,8 @@ aesgcm_file_get(void* userdata)
ssize_t* p_bytes_received = http_file_get(http_dl);
if (!p_bytes_received) {
fclose(outfh);
remove(partname);
return NULL;
}
ssize_t bytes_received = *p_bytes_received;
@@ -102,6 +120,8 @@ aesgcm_file_get(void* userdata)
"temporary file at '%s' for reading (%s).",
aesgcm_dl->url, tmpname,
g_strerror(errno));
fclose(outfh);
remove(partname);
return NULL;
}
@@ -110,20 +130,32 @@ aesgcm_file_get(void* userdata)
bytes_received, fragment);
fclose(tmpfh);
remove(tmpname);
fclose(outfh);
gboolean saved = FALSE;
if (crypt_res != GPG_ERR_NO_ERROR) {
remove(partname);
http_print_transfer_update(aesgcm_dl->window, aesgcm_dl->id, THEME_ERROR, ENTRY_ERROR,
"Downloading '%s' failed: Failed to decrypt "
"file (%s).",
https_url, gcry_strerror(crypt_res));
} else if (g_rename(partname, aesgcm_dl->filename) != 0) {
remove(partname);
http_print_transfer_update(aesgcm_dl->window, aesgcm_dl->id, THEME_ERROR, ENTRY_ERROR,
"Downloading '%s' failed: Unable to move "
"decrypted file to '%s' (%s).",
https_url, aesgcm_dl->filename,
g_strerror(errno));
} else {
saved = TRUE;
http_print_transfer_update(aesgcm_dl->window, aesgcm_dl->id, THEME_ONLINE, ENTRY_COMPLETED,
"Downloading '%s': done\nSaved to '%s'",
aesgcm_dl->url, aesgcm_dl->filename);
win_mark_received(aesgcm_dl->window, aesgcm_dl->id);
}
if (aesgcm_dl->cmd_template != NULL) {
// never hand an unverified file to the external command
if (saved && aesgcm_dl->cmd_template != NULL) {
gchar** argv = format_call_external_argv(aesgcm_dl->cmd_template,
aesgcm_dl->filename,
aesgcm_dl->filename);
@@ -140,8 +172,8 @@ aesgcm_file_get(void* userdata)
}
g_strfreev(argv);
free(aesgcm_dl->cmd_template);
}
free(aesgcm_dl->cmd_template);
free(aesgcm_dl->id);
free(aesgcm_dl->filename);

View File

@@ -1104,7 +1104,8 @@ _xmpp_file_logger(void* const userdata, const xmpp_log_level_t xmpp_level, const
break;
}
log_msg(prof_level, area, msg);
auto_gchar gchar* redacted = redact_secrets(msg); // raw traffic contains SASL/register credentials
log_msg(prof_level, area, redacted);
if ((g_strcmp0(area, "xmpp") == 0) || (g_strcmp0(area, "conn")) == 0) {
sv_ev_xmpp_stanza(msg);

View File

@@ -255,6 +255,9 @@ main(int argc, char* argv[])
PROF_FUNC_TEST(message_db_history_verify),
PROF_FUNC_TEST(message_db_history_lmc),
PROF_FUNC_TEST(message_db_history_multi_resource),
#ifdef HAVE_SQLITE
PROF_FUNC_TEST(message_db_corrupt_database_degrades_gracefully),
#endif
/* Basic message send/receive */
PROF_FUNC_TEST(message_send),

View File

@@ -536,3 +536,41 @@ message_db_history_multi_resource(void** state)
assert_true(prof_output_regex("Buddy1/laptop"));
assert_true(prof_output_regex("Buddy1/tablet"));
}
/*
* Test: corrupt chatlog.db degrades gracefully (issue #146, REQ-RES-02).
*
* A chatlog.db with a valid SQLite magic but garbage content is planted
* before connecting. Database init must fail cleanly: the user gets a
* console warning, the session stays up, and the client stays responsive.
*/
void
message_db_corrupt_database_degrades_gracefully(void** state)
{
const char* xdg_data = getenv("XDG_DATA_HOME");
assert_non_null(xdg_data);
GString* db_file = g_string_new(xdg_data);
g_string_append(db_file, "/profanity/database/stabber_at_localhost");
assert_int_equal(0, g_mkdir_with_parents(db_file->str, 0700));
g_string_append(db_file, "/chatlog.db");
/* valid 16-byte SQLite header magic followed by garbage: sqlite3_open
* succeeds (lazy open), the integrity gate must catch it */
FILE* db = fopen(db_file->str, "wb");
assert_non_null(db);
assert_int_equal(16, fwrite("SQLite format 3", 1, 16, db));
for (int i = 0; i < 4096; i++) {
fputc(0xA5, db);
}
fclose(db);
g_string_free(db_file, TRUE);
prof_connect();
assert_true(prof_output_exact("Chat history storage is unavailable for this session"));
/* client is still alive and responsive after the failed DB init */
prof_input("/autoping set 60");
assert_true(prof_output_exact("Autoping interval set to 60 seconds."));
}

View File

@@ -11,3 +11,4 @@ void message_db_history_service_chars(void** state);
void message_db_history_verify(void** state);
void message_db_history_lmc(void** state);
void message_db_history_multi_resource(void** state);
void message_db_corrupt_database_degrades_gracefully(void** state);

View File

@@ -0,0 +1,152 @@
/*
* test_omemo_crypto.c
*
* Unit tests for the OMEMO AES-256-GCM file crypto (src/omemo/crypto.c).
* The decrypt direction streams plaintext before the tag is checked, so
* callers rely on the returned error code to discard unverified output —
* these tests pin that contract (issue #146, REQ-CRY-06).
*/
#include "config.h"
#include <glib.h>
#include <stdio.h>
#include <string.h>
#include "prof_cmocka.h"
#ifdef HAVE_OMEMO
#include "omemo/omemo.h"
#include "omemo/crypto.h"
#define TAG_LENGTH 16
static const unsigned char PLAINTEXT[] = "at-rest integrity check payload: 0123456789abcdef";
// gcrypt secure memory must be set up exactly once per process
static int
_crypto_init_once(void)
{
static gboolean done = FALSE;
static int rc = 0;
if (!done) {
rc = omemo_crypto_init();
done = TRUE;
}
return rc;
}
static off_t
_file_size(FILE* fh)
{
fseeko(fh, 0, SEEK_END);
off_t size = ftello(fh);
rewind(fh);
return size;
}
// encrypt PLAINTEXT with a fixed key/nonce into a fresh tmpfile
static FILE*
_encrypted_tmpfile(unsigned char* key, unsigned char* nonce)
{
memset(key, 0x42, OMEMO_AESGCM_KEY_LENGTH);
memset(nonce, 0x24, OMEMO_AESGCM_NONCE_LENGTH);
FILE* plain = tmpfile();
FILE* cipher = tmpfile();
assert_non_null(plain);
assert_non_null(cipher);
assert_int_equal(sizeof(PLAINTEXT), fwrite(PLAINTEXT, 1, sizeof(PLAINTEXT), plain));
rewind(plain);
assert_int_equal(GPG_ERR_NO_ERROR,
aes256gcm_crypt_file(plain, cipher, (off_t)sizeof(PLAINTEXT), key, nonce, TRUE));
fclose(plain);
rewind(cipher);
return cipher;
}
// corrupt one byte at offset (negative counts from the end), return reopened stream
static FILE*
_flip_byte(FILE* cipher, long offset)
{
off_t size = _file_size(cipher);
unsigned char* buf = g_malloc(size);
assert_int_equal(size, fread(buf, 1, size, cipher));
fclose(cipher);
long pos = offset >= 0 ? offset : (long)size + offset;
buf[pos] ^= 0xFF;
FILE* tampered = tmpfile();
assert_non_null(tampered);
assert_int_equal(size, fwrite(buf, 1, size, tampered));
rewind(tampered);
g_free(buf);
return tampered;
}
void
aes256gcm_crypt_file__roundtrip_succeeds(void** state)
{
assert_int_equal(0, _crypto_init_once());
unsigned char key[OMEMO_AESGCM_KEY_LENGTH];
unsigned char nonce[OMEMO_AESGCM_NONCE_LENGTH];
FILE* cipher = _encrypted_tmpfile(key, nonce);
off_t cipher_size = _file_size(cipher);
assert_int_equal((off_t)sizeof(PLAINTEXT) + TAG_LENGTH, cipher_size);
FILE* decrypted = tmpfile();
assert_non_null(decrypted);
assert_int_equal(GPG_ERR_NO_ERROR,
aes256gcm_crypt_file(cipher, decrypted, cipher_size, key, nonce, FALSE));
unsigned char readback[sizeof(PLAINTEXT)];
rewind(decrypted);
assert_int_equal(sizeof(PLAINTEXT), fread(readback, 1, sizeof(readback), decrypted));
assert_memory_equal(PLAINTEXT, readback, sizeof(PLAINTEXT));
fclose(cipher);
fclose(decrypted);
}
void
aes256gcm_crypt_file__rejects_tampered_tag(void** state)
{
assert_int_equal(0, _crypto_init_once());
unsigned char key[OMEMO_AESGCM_KEY_LENGTH];
unsigned char nonce[OMEMO_AESGCM_NONCE_LENGTH];
FILE* cipher = _flip_byte(_encrypted_tmpfile(key, nonce), -1); // last tag byte
FILE* decrypted = tmpfile();
assert_non_null(decrypted);
gcry_error_t res = aes256gcm_crypt_file(cipher, decrypted, _file_size(cipher), key, nonce, FALSE);
assert_int_not_equal(GPG_ERR_NO_ERROR, res);
fclose(cipher);
fclose(decrypted);
}
void
aes256gcm_crypt_file__rejects_tampered_ciphertext(void** state)
{
assert_int_equal(0, _crypto_init_once());
unsigned char key[OMEMO_AESGCM_KEY_LENGTH];
unsigned char nonce[OMEMO_AESGCM_NONCE_LENGTH];
FILE* cipher = _flip_byte(_encrypted_tmpfile(key, nonce), 0); // first payload byte
FILE* decrypted = tmpfile();
assert_non_null(decrypted);
gcry_error_t res = aes256gcm_crypt_file(cipher, decrypted, _file_size(cipher), key, nonce, FALSE);
assert_int_not_equal(GPG_ERR_NO_ERROR, res);
fclose(cipher);
fclose(decrypted);
}
#endif

View File

@@ -0,0 +1,8 @@
/* test_omemo_crypto.h
*
* Unit tests for OMEMO AES-256-GCM file crypto (issue #146, REQ-CRY-06)
*/
void aes256gcm_crypt_file__roundtrip_succeeds(void** state);
void aes256gcm_crypt_file__rejects_tampered_tag(void** state);
void aes256gcm_crypt_file__rejects_tampered_ciphertext(void** state);

View File

@@ -1384,3 +1384,55 @@ str_xml_sanitize__strips_illegal_characters(void** state)
assert_string_equal("UTF-8: üñîçøðé and more", res5);
g_free(res5);
}
void
redact_secrets__masks_credentials(void** state)
{
// NULL input
assert_null(redact_secrets(NULL));
// Plain text and non-secret XML pass through unchanged
gchar* res1 = redact_secrets("hello world");
assert_string_equal("hello world", res1);
g_free(res1);
gchar* res2 = redact_secrets("<message><body>secret-looking text</body></message>");
assert_string_equal("<message><body>secret-looking text</body></message>", res2);
g_free(res2);
// SASL auth payload is redacted, envelope kept
gchar* res3 = redact_secrets("SENT: <auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AGFsaWNlAHBhc3N3b3Jk</auth>");
assert_string_equal("SENT: <auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>[REDACTED]</auth>", res3);
g_free(res3);
// SASL challenge/response round-trip
gchar* res4 = redact_secrets("<challenge xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>cj1abc</challenge>");
assert_string_equal("<challenge xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>[REDACTED]</challenge>", res4);
g_free(res4);
gchar* res5 = redact_secrets("<response xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>Yz1iaXdz</response>");
assert_string_equal("<response xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>[REDACTED]</response>", res5);
g_free(res5);
// Empty SASL response element has no content to redact
gchar* res6 = redact_secrets("<response xmlns='urn:ietf:params:xml:ns:xmpp-sasl'/>");
assert_string_equal("<response xmlns='urn:ietf:params:xml:ns:xmpp-sasl'/>", res6);
g_free(res6);
// XEP-0077 registration: password redacted, username kept
gchar* res7 = redact_secrets("<query xmlns='jabber:iq:register'><username>alice</username><password>hunter2</password></query>");
assert_string_equal("<query xmlns='jabber:iq:register'><username>alice</username><password>[REDACTED]</password></query>", res7);
g_free(res7);
// XEP-0078 legacy auth: password-derived digest redacted
gchar* res8 = redact_secrets("<query xmlns='jabber:iq:auth'><username>alice</username><digest>48fc78be9ec8f86d8ce1c39ebd7a5b4c9d0e2f13</digest><resource>tui</resource></query>");
assert_string_equal("<query xmlns='jabber:iq:auth'><username>alice</username><digest>[REDACTED]</digest><resource>tui</resource></query>", res8);
g_free(res8);
// invalid UTF-8 must not make redaction fail open
gchar* res9 = redact_secrets("\xFF garbage <password>hunter2</password>");
assert_non_null(res9);
assert_null(strstr(res9, "hunter2"));
assert_non_null(strstr(res9, "[REDACTED]"));
g_free(res9);
}

View File

@@ -64,5 +64,6 @@ void valid_tls_policy_option__is__correct_for_various_inputs(void** state);
void get_mentions__tests__various(void** state);
void release_is_new__tests__various(void** state);
void str_xml_sanitize__strips_illegal_characters(void** state);
void redact_secrets__masks_credentials(void** state);
#endif

View File

@@ -48,6 +48,7 @@
#include "test_ai_client.h"
#include "test_database_export.h"
#include "test_database_stress.h"
#include "omemo/test_omemo_crypto.h"
#define muc_unit_test(f) cmocka_unit_test_setup_teardown(f, muc_before_test, muc_after_test)
@@ -687,6 +688,12 @@ main(int argc, char* argv[])
cmocka_unit_test(get_mentions__tests__various),
cmocka_unit_test(release_is_new__tests__various),
cmocka_unit_test(str_xml_sanitize__strips_illegal_characters),
cmocka_unit_test(redact_secrets__masks_credentials),
#ifdef HAVE_OMEMO
cmocka_unit_test(aes256gcm_crypt_file__roundtrip_succeeds),
cmocka_unit_test(aes256gcm_crypt_file__rejects_tampered_tag),
cmocka_unit_test(aes256gcm_crypt_file__rejects_tampered_ciphertext),
#endif
cmocka_unit_test_setup_teardown(plugins_get_command_names__returns__no_commands,
load_preferences,