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
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
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
152
tests/unittests/omemo/test_omemo_crypto.c
Normal file
152
tests/unittests/omemo/test_omemo_crypto.c
Normal 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
|
||||
8
tests/unittests/omemo/test_omemo_crypto.h
Normal file
8
tests/unittests/omemo/test_omemo_crypto.h
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user