summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 15:41:55 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 15:42:40 +0200
commitf3b3f2c4f944c98a341f406abd2e4a6b7b69da36 (patch)
treef7b2e25937c4c10466e2cce7c2c7a9a6eeb6d7c8
parent21057cd2f2ab298300a246b9fba0d38d740c9697 (diff)
downloadbokf-f3b3f2c4f944c98a341f406abd2e4a6b7b69da36.tar.gz
bokf-f3b3f2c4f944c98a341f406abd2e4a6b7b69da36.zip
settings: encrypted secrets and SMTP settings
-rw-r--r--docs/PROTOCOL.md30
-rw-r--r--src/commands.c122
-rw-r--r--src/secret.c194
-rw-r--r--src/secret.h9
-rw-r--r--tests/test_core.c328
5 files changed, 655 insertions, 28 deletions
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index e02d2f7..a4e61ec 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -370,8 +370,8 @@ resolved rows in a dry run.
| Command | Args | Notes |
|---|---|---|
-| `settings.get` | — | effective org settings (defaults included) |
-| `settings.set` | `key`, `value` | known keys: `default_series`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account` |
+| `settings.get` | — | effective org settings (defaults included); secret values replaced by `_set` flags |
+| `settings.set` | `key`, `value?` | known keys: `default_series`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account`, `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security`, `smtp_password` |
`default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when
`voucher.post` carries no `series` and as the default series for new
@@ -385,6 +385,25 @@ characters. Verification ids are the concatenation of series and number
(`V-8`), and series are free-form: only an unbroken numbering per series is
required.
+`smtp_host` (up to 255 characters, no control characters), `smtp_user` (up to
+255), `smtp_from` and `smtp_reply_to` (up to 254), `smtp_port` (digits,
+1–65535) and `smtp_security` (`starttls`, `tls` or `plain`, default
+`starttls` when unset) configure the outgoing mail used when invoices are
+sent.
+
+`smtp_password` is a secret setting. `settings.set` encrypts the value with
+AES-256-GCM under the key in the `BOKFD_SECRET_KEY` environment variable (32
+bytes as 64 hex characters or standard base64, padding optional) and stores
+only the `enc:v1:<nonce>:<ciphertext>` form; a plaintext password is never
+written. `settings.get` never returns the value. When the setting exists it
+returns the boolean `smtp_password_set:true` and omits `smtp_password`;
+when it is absent it returns `smtp_password_set:false`. Setting `value` to
+the empty string deletes the setting. A dry run and the success response
+both report `"value":"[redacted]"`, and the audit entry is
+`{"key":"smtp_password","value":"[redacted]"}`. Setting or clearing the
+password when `BOKFD_SECRET_KEY` is missing or does not decode to 32 bytes
+fails with `INTERNAL`.
+
### 7.5 Attachments (underlag)
| Command | Args | Notes |
@@ -690,7 +709,12 @@ beyond the session and calls nothing but public commands.
- Passwords: Argon2id (vendored reference implementation). Tokens: 256-bit
random, stored hashed, revocable, never logged. Sessions: memory only.
- Audit and logs redact secrets: `session.open` records username and outcome,
- never the password or token value.
+ never the password or token value. `settings.set smtp_password` is audited
+ as `[redacted]`.
+- Settings secrets (`smtp_password`) are encrypted at rest with AES-256-GCM
+ under `BOKFD_SECRET_KEY` (32 bytes, hex or base64, read from the
+ environment); the key itself is never stored in the database, returned by
+ any command or written to a log.
- Socket and database files are `0600`/`0660`; backups inherit the same
discipline.
- For data at rest, prefer LUKS on the host. SQLCipher support is a possible
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 <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <openssl/crypto.h>
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+
+#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
diff --git a/tests/test_core.c b/tests/test_core.c
index fa7b5e4..47b6acf 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -18,6 +18,7 @@
#include "db.h"
#include "invoice.h"
#include "protocol.h"
+#include "secret.h"
#include "sessions.h"
#include "util.h"
#include "yyjson.h"
@@ -282,7 +283,8 @@ static void echo_child(int wfd, int tls)
if (memchr(buf, '\n', got))
break;
}
- (void)write(cfd, "tcp-ok", 6);
+ ssize_t wr = write(cfd, "tcp-ok", 6);
+ (void)wr;
}
close(cfd);
close(sfd);
@@ -567,6 +569,106 @@ static void test_pre_migration_snapshot(const char *tmpdir,
unlink(blocker);
}
+static void test_secret(void)
+{
+ static const char hexkey[] =
+ "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
+ static const char otherkey[] =
+ "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
+ unsigned char raw[32];
+ for (int i = 0; i < 32; i++)
+ raw[i] = (unsigned char)(i * 7 + 1);
+ char *b64key = util_b64(raw, sizeof raw);
+
+ CHECK(secret_key_is_secret("smtp_password"));
+ CHECK(!secret_key_is_secret("smtp_host"));
+ CHECK(!secret_key_is_secret(NULL));
+
+ unsetenv("BOKFD_SECRET_KEY");
+ CHECK(!secret_available());
+ char *out = NULL;
+ CHECK(secret_encrypt("hemlig", &out) == -1);
+ CHECK(out == NULL);
+ CHECK(secret_decrypt("enc:v1:AAAA:BBBB", &out) == -1);
+ CHECK(out == NULL);
+ CHECK(secret_decrypt("plaintext", &out) == -1);
+
+ setenv("BOKFD_SECRET_KEY", hexkey, 1);
+ CHECK(secret_available());
+ out = NULL;
+ CHECK(secret_encrypt("hemligt lösenord", &out) == 0);
+ CHECK(out && strncmp(out, "enc:v1:", 7) == 0);
+ CHECK(out && strstr(out, "hemligt") == NULL);
+ char *dec = NULL;
+ CHECK(secret_decrypt(out, &dec) == 0);
+ CHECK(dec && strcmp(dec, "hemligt lösenord") == 0);
+ free(dec);
+
+ char *tampered = xstrdup(out);
+ char *sep = strchr(tampered + 7, ':');
+ CHECK(sep != NULL);
+ if (sep) {
+ char *ctpart = sep + 1;
+ ctpart[0] = ctpart[0] == 'A' ? 'B' : 'A';
+ }
+ dec = NULL;
+ CHECK(secret_decrypt(tampered, &dec) == -1);
+ CHECK(dec == NULL);
+ free(tampered);
+
+ tampered = xstrdup(out);
+ tampered[7] = tampered[7] == 'A' ? 'B' : 'A';
+ dec = NULL;
+ CHECK(secret_decrypt(tampered, &dec) == -1);
+ free(tampered);
+
+ setenv("BOKFD_SECRET_KEY", otherkey, 1);
+ dec = NULL;
+ CHECK(secret_decrypt(out, &dec) == -1);
+ CHECK(dec == NULL);
+ free(out);
+
+ setenv("BOKFD_SECRET_KEY", b64key, 1);
+ CHECK(secret_available());
+ out = NULL;
+ CHECK(secret_encrypt("kort", &out) == 0);
+ dec = NULL;
+ CHECK(secret_decrypt(out, &dec) == 0);
+ CHECK(dec && strcmp(dec, "kort") == 0);
+ free(dec);
+ free(out);
+
+ char *nopad = xstrdup(b64key);
+ size_t n = strlen(nopad);
+ while (n > 0 && nopad[n - 1] == '=')
+ nopad[--n] = '\0';
+ setenv("BOKFD_SECRET_KEY", nopad, 1);
+ CHECK(secret_available());
+ out = NULL;
+ CHECK(secret_encrypt("utan padding", &out) == 0);
+ dec = NULL;
+ CHECK(secret_decrypt(out, &dec) == 0);
+ CHECK(dec && strcmp(dec, "utan padding") == 0);
+ free(dec);
+ free(out);
+ free(nopad);
+
+ setenv("BOKFD_SECRET_KEY", "abcd", 1);
+ CHECK(!secret_available());
+ out = NULL;
+ CHECK(secret_encrypt("x", &out) == -1);
+ CHECK(secret_decrypt("enc:v1:AAAA:BBBB", &out) == -1);
+
+ setenv("BOKFD_SECRET_KEY", "000102030405060708090a0b0c0d0e0f", 1);
+ CHECK(!secret_available());
+ setenv("BOKFD_SECRET_KEY", "zzzz", 1);
+ CHECK(!secret_available());
+
+ unsetenv("BOKFD_SECRET_KEY");
+ CHECK(!secret_available());
+ free(b64key);
+}
+
int main(void)
{
char tmpdir[] = "/tmp/bokf-test-XXXXXX";
@@ -603,6 +705,8 @@ int main(void)
}
sessions_init(3600);
+ test_secret();
+
yyjson_doc *d;
d = call("{\"v\":1,\"id\":\"1\",\"cmd\":\"health\"}");
@@ -1322,7 +1426,7 @@ int main(void)
CHECK_OK(d);
CHECK_STR(d, "result.org_nr", "559331-2126");
CHECK_STR(d, "result.period", "202601");
- char *b64 = jstr(d, "result.content_base64");
+ const char *b64 = jstr(d, "result.content_base64");
unsigned char *xmlb = NULL;
size_t xmll = 0;
CHECK(b64 && util_b64_decode(b64, strlen(b64), &xmlb, &xmll) == 0);
@@ -1844,14 +1948,14 @@ int main(void)
"#TRANS 2010 {} -1000.00\r\n"
"#TRANS 2010 {} 0.00\r\n"
"}\r\n";
- char *b64 = util_b64((const unsigned char *)sie_crlf,
- strlen(sie_crlf));
- d = call_sie_import(g_session, org2, b64, 0);
+ char *sie_b64a = util_b64((const unsigned char *)sie_crlf,
+ strlen(sie_crlf));
+ d = call_sie_import(g_session, org2, sie_b64a, 0);
CHECK_OK(d);
CHECK(jint(d, "result.vouchers") == 1);
int64_t fy_a = jint(d, "result.fiscal_year_id");
yyjson_doc_free(d);
- free(b64);
+ free(sie_b64a);
d = call(reqf("{\"v\":1,\"id\":\"77a\",\"cmd\":\"fiscal_year.get\","
"\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
@@ -1877,13 +1981,13 @@ int main(void)
"#TRANS 1930 {} 50.00\n"
"#TRANS 2010 {} -50.00\n"
"}\n";
- char *b64 = util_b64((const unsigned char *)sie_year2,
- strlen(sie_year2));
- d = call_sie_import(g_session, org2, b64, 0);
+ char *sie_b64b = util_b64((const unsigned char *)sie_year2,
+ strlen(sie_year2));
+ d = call_sie_import(g_session, org2, sie_b64b, 0);
CHECK_OK(d);
int64_t fy_b = jint(d, "result.fiscal_year_id");
yyjson_doc_free(d);
- free(b64);
+ free(sie_b64b);
d = call(reqf("{\"v\":1,\"id\":\"77b\",\"cmd\":\"report.trial_balance\","
"\"session\":\"%s\",\"org\":%d,\"args\":"
@@ -1972,6 +2076,208 @@ int main(void)
CHECK_STR(d, "result.attachment_dir", "/tmp/bilagor");
yyjson_doc_free(d);
+ /* ---------------- settings: SMTP and encrypted password ----------- */
+
+ d = call(reqf("{\"v\":1,\"id\":\"102\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.smtp_security", "starttls");
+ CHECK(jget(d, "result.smtp_password") == NULL);
+ CHECK(!jbool(d, "result.smtp_password_set"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"103\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_host\",\"value\":\"smtp.example.se\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "smtp.example.se");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"104\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_host\",\"value\":\"bad\\u0001host\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"105\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"587\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "587");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"106\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"0\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"107\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"70000\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"108\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_security\",\"value\":\"ssl\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"109\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_user\",\"value\":\"faktura@example.se\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"110\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_from\",\"value\":\"Bokf AB <a@b.se>\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"111\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_reply_to\",\"value\":\"svar@example.se\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"112\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_security\",\"value\":\"tls\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "tls");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"113\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.smtp_host", "smtp.example.se");
+ CHECK_STR(d, "result.smtp_port", "587");
+ CHECK_STR(d, "result.smtp_user", "faktura@example.se");
+ CHECK_STR(d, "result.smtp_from", "Bokf AB <a@b.se>");
+ CHECK_STR(d, "result.smtp_reply_to", "svar@example.se");
+ CHECK_STR(d, "result.smtp_security", "tls");
+ yyjson_doc_free(d);
+
+ unsetenv("BOKFD_SECRET_KEY");
+ d = call(reqf("{\"v\":1,\"id\":\"114\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_password\",\"value\":\"hunter2\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INTERNAL");
+ CHECK(jstr(d, "error.message") &&
+ strstr(jstr(d, "error.message"), "BOKFD_SECRET_KEY") != NULL);
+ yyjson_doc_free(d);
+
+ char *secret_stored = db_text(g_db, reqf("SELECT value FROM settings WHERE"
+ " org_id=%d AND key='smtp_password'",
+ (int)org_id));
+ CHECK(secret_stored == NULL);
+ free(secret_stored);
+
+ setenv("BOKFD_SECRET_KEY", "abcd", 1);
+ d = call(reqf("{\"v\":1,\"id\":\"115\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_password\",\"value\":\"hunter2\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INTERNAL");
+ yyjson_doc_free(d);
+ unsetenv("BOKFD_SECRET_KEY");
+
+ setenv("BOKFD_SECRET_KEY",
+ "000102030405060708090a0b0c0d0e0f"
+ "101112131415161718191a1b1c1d1e1f",
+ 1);
+ d = call(reqf("{\"v\":1,\"id\":\"116\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_password\",\"value\":\"hunter2\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "[redacted]");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"117\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.smtp_password_set"));
+ CHECK(jget(d, "result.smtp_password") == NULL);
+ yyjson_doc_free(d);
+
+ secret_stored = db_text(g_db, reqf("SELECT value FROM settings WHERE"
+ " org_id=%d AND key='smtp_password'",
+ (int)org_id));
+ CHECK(secret_stored != NULL);
+ CHECK(secret_stored && strncmp(secret_stored, "enc:v1:", 7) == 0);
+ CHECK(secret_stored && strstr(secret_stored, "hunter2") == NULL);
+ char *plain = NULL;
+ CHECK(secret_stored && secret_decrypt(secret_stored, &plain) == 0);
+ CHECK(plain && strcmp(plain, "hunter2") == 0);
+ free(plain);
+ free(secret_stored);
+
+ char *audit = db_text(g_db,
+ "SELECT request_json FROM audit_log"
+ " WHERE action='settings.set'"
+ " ORDER BY seq DESC LIMIT 1");
+ CHECK(audit != NULL);
+ CHECK(audit && strstr(audit, "\"key\":\"smtp_password\"") != NULL);
+ CHECK(audit && strstr(audit, "[redacted]") != NULL);
+ CHECK(audit && strstr(audit, "hunter2") == NULL);
+ free(audit);
+
+ d = call(reqf("{\"v\":1,\"id\":\"118\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_password\",\"value\":\"\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "[redacted]");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"119\",\"cmd\":\"settings.get\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK(!jbool(d, "result.smtp_password_set"));
+ CHECK(jget(d, "result.smtp_password") == NULL);
+ yyjson_doc_free(d);
+
+ secret_stored = db_text(g_db, reqf("SELECT value FROM settings WHERE"
+ " org_id=%d AND key='smtp_password'",
+ (int)org_id));
+ CHECK(secret_stored == NULL);
+ free(secret_stored);
+
+ audit = db_text(g_db,
+ "SELECT request_json FROM audit_log"
+ " WHERE action='settings.set'"
+ " ORDER BY seq DESC LIMIT 1");
+ CHECK(audit && strstr(audit, "\"key\":\"smtp_password\"") != NULL);
+ CHECK(audit && strstr(audit, "[redacted]") != NULL);
+ free(audit);
+
+ unsetenv("BOKFD_SECRET_KEY");
+ d = call(reqf("{\"v\":1,\"id\":\"120\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_password\",\"value\":\"\"}}",
+ g_session, (int)org_id));
+ CHECK_STR(d, "error.code", "INTERNAL");
+ yyjson_doc_free(d);
+ unsetenv("BOKFD_SECRET_KEY");
+
/* ---------------- bank reconciliation (schema v8) ----------------- */
static const char seb_csv[] =
@@ -3042,7 +3348,7 @@ int main(void)
CHECK_OK(d);
CHECK_STR(d, "result.period", "2026P4");
CHECK_STR(d, "result.blankett", "INK2-2026P4");
- char *sb64 = jstr(d, "result.blanketter.content_base64");
+ const char *sb64 = jstr(d, "result.blanketter.content_base64");
unsigned char *srub = NULL;
size_t srul = 0;
CHECK(sb64 && util_b64_decode(sb64, strlen(sb64), &srub, &srul) == 0);