From f3b3f2c4f944c98a341f406abd2e4a6b7b69da36 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sun, 20 Sep 2026 15:41:55 +0200 Subject: settings: encrypted secrets and SMTP settings --- src/commands.c | 122 +++++++++++++++++++++++++++++++----- src/secret.c | 194 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/secret.h | 9 +++ 3 files changed, 311 insertions(+), 14 deletions(-) create mode 100644 src/secret.c create mode 100644 src/secret.h (limited to 'src') diff --git a/src/commands.c b/src/commands.c index a587ce3..6100d6a 100644 --- a/src/commands.c +++ b/src/commands.c @@ -19,12 +19,16 @@ #include "ledger.h" #include "log.h" #include "reports.h" +#include "secret.h" #include "seed.h" #include "sie.h" #include "sru.h" #include "util.h" #include "version.h" +/* src/secret.c is not part of CORE_SRC; compile it as part of this unit. */ +#include "secret.c" + /* ------------------------------------------------------------------ */ /* small helpers */ /* ------------------------------------------------------------------ */ @@ -2612,6 +2616,7 @@ static yyjson_mut_val *h_settings_get(struct req *r) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); int have_default = 0, have_bank = 0, have_receivable = 0, have_revenue = 0; + int have_password = 0, have_security = 0; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT key,value FROM settings WHERE org_id=?1", -1, &st, @@ -2621,6 +2626,10 @@ static yyjson_mut_val *h_settings_get(struct req *r) const char *k = (const char *)sqlite3_column_text(st, 0); const char *v = (const char *)sqlite3_column_text(st, 1); if (k) { + if (secret_key_is_secret(k)) { + have_password = 1; + continue; + } yyjson_mut_obj_add(o, yyjson_mut_strcpy(r->rdoc, k), yyjson_mut_strcpy(r->rdoc, v ? v : "")); if (strcmp(k, "default_series") == 0) @@ -2631,6 +2640,8 @@ static yyjson_mut_val *h_settings_get(struct req *r) have_receivable = 1; if (strcmp(k, "invoice_revenue_account") == 0) have_revenue = 1; + if (strcmp(k, "smtp_security") == 0) + have_security = 1; } } sqlite3_finalize(st); @@ -2645,24 +2656,100 @@ static yyjson_mut_val *h_settings_get(struct req *r) if (!have_revenue) yyjson_mut_obj_add_strcpy(r->rdoc, o, "invoice_revenue_account", "3001"); + if (!have_security) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "smtp_security", "starttls"); + yyjson_mut_obj_add_bool(r->rdoc, o, "smtp_password_set", have_password); + return o; +} + +static yyjson_mut_val *settings_result(struct req *r, const char *key, + const char *value, int dry) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); + if (dry) + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } +static char *settings_secret_audit_json(const char *key) +{ + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, o); + yyjson_mut_obj_add_strcpy(doc, o, "key", key); + yyjson_mut_obj_add_strcpy(doc, o, "value", "[redacted]"); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json ? json : xstrdup("{}"); +} + static yyjson_mut_val *h_settings_set(struct req *r) { const char *key = arg_str(r->args, "key"); const char *value = arg_str(r->args, "value"); if (!key || !value) return fail(r, "INVALID_ARGS", "key and value are required"); + if (secret_key_is_secret(key)) { + if (!secret_available()) + return fail(r, "INTERNAL", + "BOKFD_SECRET_KEY is missing or invalid"); + char *stored = NULL; + if (*value && secret_encrypt(value, &stored) != 0) + return fail(r, "INTERNAL", + "BOKFD_SECRET_KEY is missing or invalid"); + if (r->dry_run) { + free(stored); + return settings_result(r, key, "[redacted]", 1); + } + sqlite3_stmt *st = NULL; + const char *sql = *value + ? "INSERT INTO settings(org_id,key,value)" + " VALUES(?1,?2,?3)" + " ON CONFLICT(org_id,key) DO UPDATE SET" + " value=excluded.value" + : "DELETE FROM settings WHERE org_id=?1" + " AND key=?2"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) { + free(stored); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); + if (*value) + sqlite3_bind_text(st, 3, stored, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + free(stored); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + char *reqjson = settings_secret_audit_json(key); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "settings.set", reqjson, "OK", NULL); + free(reqjson); + return settings_result(r, key, "[redacted]", 0); + } size_t maxlen; - int digits_only = 0, bankgiro = 0; + int digits_only = 0, bankgiro = 0, port = 0, security = 0; if (strcmp(key, "default_series") == 0) maxlen = 8; - else if (strcmp(key, "attachment_dir") == 0) + else if (strcmp(key, "attachment_dir") == 0 || + strcmp(key, "smtp_host") == 0 || + strcmp(key, "smtp_user") == 0) maxlen = 255; - else if (strcmp(key, "bank_account") == 0 || - strcmp(key, "invoice_receivable_account") == 0 || - strcmp(key, "invoice_revenue_account") == 0) { + else if (strcmp(key, "smtp_from") == 0 || + strcmp(key, "smtp_reply_to") == 0) + maxlen = 254; + else if (strcmp(key, "smtp_port") == 0) { + maxlen = 5; + port = 1; + } else if (strcmp(key, "smtp_security") == 0) { + maxlen = 8; + security = 1; + } else if (strcmp(key, "bank_account") == 0 || + strcmp(key, "invoice_receivable_account") == 0 || + strcmp(key, "invoice_revenue_account") == 0) { maxlen = 10; digits_only = 1; } else if (strcmp(key, "invoice_bankgiro") == 0) { @@ -2675,7 +2762,7 @@ static yyjson_mut_val *h_settings_set(struct req *r) return failf(r, "INVALID_ARGS", "%s must be 1-%zu characters", key, maxlen); for (const char *p = value; *p; p++) { - if (digits_only && (*p < '0' || *p > '9')) + if ((digits_only || port) && (*p < '0' || *p > '9')) return failf(r, "INVALID_ARGS", "%s must be digits only", key); if (bankgiro && !((*p >= '0' && *p <= '9') || *p == '-')) return failf(r, "INVALID_ARGS", @@ -2684,13 +2771,17 @@ static yyjson_mut_val *h_settings_set(struct req *r) return failf(r, "INVALID_ARGS", "%s must not contain control characters", key); } - if (r->dry_run) { - yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "key", key); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "value", value); - yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); - return o; + if (port) { + long v = strtol(value, NULL, 10); + if (v < 1 || v > 65535) + return failf(r, "INVALID_ARGS", "%s must be 1-65535", key); } + if (security && strcmp(value, "starttls") != 0 && + strcmp(value, "tls") != 0 && strcmp(value, "plain") != 0) + return failf(r, "INVALID_ARGS", "%s must be starttls, tls or plain", + key); + if (r->dry_run) + return settings_result(r, key, value, 1); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, @@ -7644,8 +7735,11 @@ static const struct cmd_arg args_bokslut_post[] = { static const struct cmd_arg args_settings_set[] = { { "key", ARG_STR, 1, NULL, NULL, "default_series, attachment_dir, bank_account," - " invoice_receivable_account or invoice_revenue_account" }, - { "value", ARG_STR, 1, NULL, NULL, "Setting value" }, + " invoice_receivable_account, invoice_revenue_account, smtp_host," + " smtp_port, smtp_user, smtp_from, smtp_reply_to, smtp_security or" + " smtp_password" }, + { "value", ARG_STR, 0, NULL, NULL, + "Setting value; an empty value clears smtp_password" }, }; static const struct cmd_arg args_bank_import[] = { diff --git a/src/secret.c b/src/secret.c new file mode 100644 index 0000000..7222ddd --- /dev/null +++ b/src/secret.c @@ -0,0 +1,194 @@ +#include "secret.h" + +#include +#include +#include +#include + +#include +#include +#include + +#include "util.h" + +#define SECRET_PREFIX "enc:v1:" +#define SECRET_KEY_LEN 32 +#define SECRET_NONCE_LEN 12 +#define SECRET_TAG_LEN 16 + +static int hex_val(int c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +static int secret_key(unsigned char out[SECRET_KEY_LEN]) +{ + const char *s = getenv("BOKFD_SECRET_KEY"); + if (!s || !*s) + return -1; + size_t n = strlen(s); + if (n == 2 * SECRET_KEY_LEN) { + int hex = 1; + for (size_t i = 0; i < n && hex; i++) + if (hex_val((unsigned char)s[i]) < 0) + hex = 0; + if (hex) { + for (size_t i = 0; i < SECRET_KEY_LEN; i++) + out[i] = (unsigned char)((hex_val(s[2 * i]) << 4) | + hex_val(s[2 * i + 1])); + return 0; + } + } + if (n % 4 == 1 || n > 172) + return -1; + char padded[176]; + memcpy(padded, s, n); + while (n % 4) + padded[n++] = '='; + padded[n] = '\0'; + unsigned char *raw = NULL; + size_t raw_n = 0; + if (util_b64_decode(padded, n, &raw, &raw_n) != 0) + return -1; + int ok = raw_n == SECRET_KEY_LEN; + if (ok) + memcpy(out, raw, SECRET_KEY_LEN); + free(raw); + return ok ? 0 : -1; +} + +int secret_available(void) +{ + unsigned char key[SECRET_KEY_LEN] = { 0 }; + int ok = secret_key(key) == 0; + OPENSSL_cleanse(key, sizeof key); + return ok; +} + +int secret_encrypt(const char *plain, char **out) +{ + if (out) + *out = NULL; + if (!plain || !out) + return -1; + size_t plen = strlen(plain); + if (plen > (size_t)INT_MAX) + return -1; + unsigned char key[SECRET_KEY_LEN], nonce[SECRET_NONCE_LEN]; + if (secret_key(key) != 0) + return -1; + if (RAND_bytes(nonce, SECRET_NONCE_LEN) != 1) { + OPENSSL_cleanse(key, sizeof key); + return -1; + } + size_t clen = plen + SECRET_TAG_LEN; + unsigned char *ct = xmalloc(clen); + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + int len = 0, total = 0, ok = 0; + if (ctx && + EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, SECRET_NONCE_LEN, + NULL) == 1 && + EVP_EncryptInit_ex(ctx, NULL, NULL, key, nonce) == 1 && + EVP_EncryptUpdate(ctx, ct, &len, (const unsigned char *)plain, + (int)plen) == 1) { + total = len; + ok = EVP_EncryptFinal_ex(ctx, ct + total, &len) == 1; + total += len; + } + if (ok) + ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, SECRET_TAG_LEN, + ct + total) == 1; + EVP_CIPHER_CTX_free(ctx); + OPENSSL_cleanse(key, sizeof key); + if (!ok || total != (int)plen) { + free(ct); + return -1; + } + total += SECRET_TAG_LEN; + char *nonce_b64 = util_b64(nonce, sizeof nonce); + char *ct_b64 = util_b64(ct, (size_t)total); + free(ct); + size_t olen = + strlen(SECRET_PREFIX) + strlen(nonce_b64) + 1 + strlen(ct_b64) + 1; + char *s = xmalloc(olen); + snprintf(s, olen, "%s%s:%s", SECRET_PREFIX, nonce_b64, ct_b64); + free(nonce_b64); + free(ct_b64); + *out = s; + return 0; +} + +int secret_decrypt(const char *stored, char **out) +{ + if (out) + *out = NULL; + if (!stored || !out) + return -1; + if (strncmp(stored, SECRET_PREFIX, strlen(SECRET_PREFIX)) != 0) + return -1; + const char *p = stored + strlen(SECRET_PREFIX); + const char *sep = strchr(p, ':'); + if (!sep || sep == p || !sep[1]) + return -1; + unsigned char key[SECRET_KEY_LEN]; + if (secret_key(key) != 0) + return -1; + unsigned char *nonce = NULL, *ct = NULL; + size_t nonce_n = 0, ct_n = 0; + if (util_b64_decode(p, (size_t)(sep - p), &nonce, &nonce_n) != 0) { + OPENSSL_cleanse(key, sizeof key); + return -1; + } + if (util_b64_decode(sep + 1, strlen(sep + 1), &ct, &ct_n) != 0) { + free(nonce); + OPENSSL_cleanse(key, sizeof key); + return -1; + } + if (nonce_n != SECRET_NONCE_LEN || ct_n < SECRET_TAG_LEN || + ct_n - SECRET_TAG_LEN > (size_t)INT_MAX) { + free(nonce); + free(ct); + OPENSSL_cleanse(key, sizeof key); + return -1; + } + size_t plen = ct_n - SECRET_TAG_LEN; + unsigned char *pt = xmalloc(plen + 1); + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + int len = 0, total = 0, ok = 0; + if (ctx && + EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, SECRET_NONCE_LEN, + NULL) == 1 && + EVP_DecryptInit_ex(ctx, NULL, NULL, key, nonce) == 1 && + EVP_DecryptUpdate(ctx, pt, &len, ct, (int)plen) == 1) { + total = len; + if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, SECRET_TAG_LEN, + ct + plen) == 1) + ok = EVP_DecryptFinal_ex(ctx, pt + total, &len) == 1; + total += len; + } + EVP_CIPHER_CTX_free(ctx); + OPENSSL_cleanse(key, sizeof key); + free(nonce); + free(ct); + if (!ok || total != (int)plen) { + OPENSSL_cleanse(pt, plen + 1); + free(pt); + return -1; + } + pt[total] = '\0'; + *out = (char *)pt; + return 0; +} + +int secret_key_is_secret(const char *key) +{ + return key && strcmp(key, "smtp_password") == 0; +} diff --git a/src/secret.h b/src/secret.h new file mode 100644 index 0000000..3dd03a4 --- /dev/null +++ b/src/secret.h @@ -0,0 +1,9 @@ +#ifndef BOKF_SECRET_H +#define BOKF_SECRET_H + +int secret_available(void); +int secret_encrypt(const char *plain, char **out); +int secret_decrypt(const char *stored, char **out); +int secret_key_is_secret(const char *key); + +#endif -- cgit v1.3