fix(a7-auth): persist dummy key in owner-only sidecar

The store-wide dummy key was regenerated on every credentials_load, so an
unknown user's dummy salt changed across daemon restarts while a real user's
stored salt stayed stable -- a restart-gated username-enumeration oracle.

Persist the 32-byte key in a 0600 <store>.dummykey sidecar next to the
credential store.  An absent sidecar is created with O_EXCL and fsynced; a
present sidecar is read only when it is an owner-only regular file of exactly
32 bytes (otherwise the load fails closed).  If the sidecar cannot be created
(read-only mount, missing directory) fall back to a transient per-run key with
a warning.  A NULL store path keeps the key ephemeral.
This commit is contained in:
2026-09-12 18:40:21 +02:00
parent 2489d422e5
commit 42f01c0968
5 changed files with 359 additions and 19 deletions
+149 -2
View File
@@ -70,8 +70,28 @@ static char* make_tmp_file(const char* contents) {
}
static void rm_temp(const char* path) {
if (path)
unlink(path);
if (!path)
return;
unlink(path);
/* Every successfully loaded store auto-creates an owner-only
* `<store>.dummykey` sidecar; remove it too so tests leave no stray key. */
size_t n = strlen(path) + strlen(".dummykey") + 1;
char* sidecar = malloc(n);
if (sidecar) {
snprintf(sidecar, n, "%s.dummykey", path);
unlink(sidecar);
free(sidecar);
}
}
/* `<store>.dummykey` sidecar path (caller frees). */
static char* dummy_sidecar_path(const char* store_path) {
size_t n = strlen(store_path) + strlen(".dummykey") + 1;
char* out = malloc(n);
if (!out)
return NULL;
snprintf(out, n, "%s.dummykey", store_path);
return out;
}
/* Build a valid new-format line for user/password at iters. */
@@ -745,6 +765,130 @@ static void test_credentials_rejects_group_or_other_accessible() {
free(path);
}
/* Loading a store auto-creates an owner-only `<store>.dummykey` sidecar whose
* key is stable across reloads, so an unknown-user dummy salt is identical
* across two loads (the anti-restart enumeration property). */
static void test_credentials_dummy_key_persisted() {
char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
char contents[CREDENTIAL_MAX_LINE + 2];
snprintf(contents, sizeof(contents), "%s\n", line);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char* sidecar = dummy_sidecar_path(path);
EXPECT_NOT_NULL(sidecar);
char err[512];
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
struct stat st;
EXPECT_EQ_INT(stat(sidecar, &st), 0);
EXPECT_TRUE(S_ISREG(st.st_mode));
EXPECT_TRUE((st.st_mode & (S_IRWXG | S_IRWXO)) == 0);
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0600);
EXPECT_EQ_INT((int)st.st_size, CREDENTIAL_KEY_LEN);
CredentialVerifier v1;
EXPECT_TRUE(credentials_get_verifier(store, "unknown-user", NULL, 0, &v1));
EXPECT_FALSE(v1.found);
credentials_free(store);
store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
CredentialVerifier v2;
EXPECT_TRUE(credentials_get_verifier(store, "unknown-user", NULL, 0, &v2));
EXPECT_FALSE(v2.found);
EXPECT_TRUE(memcmp(v1.salt, v2.salt, sizeof(v1.salt)) == 0);
credentials_free(store);
rm_temp(path);
free(path);
free(sidecar);
}
/* A sidecar that is group/other accessible, the wrong size, or not a regular
* file must fail the load closed. */
static void test_credentials_dummy_key_rejects_bad_sidecar() {
char err[512];
char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
char contents[CREDENTIAL_MAX_LINE + 2];
snprintf(contents, sizeof(contents), "%s\n", line);
/* Group/other permission bits on the sidecar. */
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char* sidecar = dummy_sidecar_path(path);
EXPECT_NOT_NULL(sidecar);
uint8_t key[CREDENTIAL_KEY_LEN];
memset(key, 0x5a, sizeof(key));
FILE* fp = fopen(sidecar, "wb");
EXPECT_NOT_NULL(fp);
EXPECT_TRUE(fwrite(key, 1, sizeof(key), fp) == sizeof(key));
fclose(fp);
EXPECT_EQ_INT(chmod(sidecar, 0640), 0);
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
EXPECT_TRUE(err[0] != '\0');
rm_temp(path);
free(path);
free(sidecar);
/* Wrong size (not exactly 32 bytes). */
path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
sidecar = dummy_sidecar_path(path);
EXPECT_NOT_NULL(sidecar);
fp = fopen(sidecar, "wb");
EXPECT_NOT_NULL(fp);
EXPECT_TRUE(fwrite(key, 1, CREDENTIAL_SALT_LEN, fp) == CREDENTIAL_SALT_LEN);
fclose(fp);
EXPECT_EQ_INT(chmod(sidecar, 0600), 0);
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
EXPECT_TRUE(err[0] != '\0');
rm_temp(path);
free(path);
free(sidecar);
/* Non-regular file (a directory at the sidecar path). */
path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
sidecar = dummy_sidecar_path(path);
EXPECT_NOT_NULL(sidecar);
EXPECT_EQ_INT(mkdir(sidecar, 0700), 0);
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
EXPECT_TRUE(err[0] != '\0');
rmdir(sidecar);
rm_temp(path);
free(path);
free(sidecar);
}
/* A NULL store path has nowhere to persist a key, so each load gets a fresh
* ephemeral key (and creates no sidecar). */
static void test_credentials_dummy_key_null_store_ephemeral() {
char err[512];
CredentialStore* store = credentials_load(NULL, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
CredentialVerifier v1;
CredentialVerifier v1b;
EXPECT_TRUE(credentials_get_verifier(store, "nobody", NULL, 0, &v1));
EXPECT_FALSE(v1.found);
/* Within one store the dummy challenge is still deterministic. */
EXPECT_TRUE(credentials_get_verifier(store, "nobody", NULL, 0, &v1b));
EXPECT_TRUE(memcmp(v1.salt, v1b.salt, sizeof(v1.salt)) == 0);
credentials_free(store);
store = credentials_load(NULL, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
CredentialVerifier v2;
EXPECT_TRUE(credentials_get_verifier(store, "nobody", NULL, 0, &v2));
/* No persistence path, so the second load's random key differs (and with it
* the dummy salt). */
EXPECT_TRUE(memcmp(v1.salt, v2.salt, sizeof(v1.salt)) != 0);
credentials_free(store);
}
static void test_credentials_burn() {
char secret[32];
memcpy(secret, "supersecretvalue", 17);
@@ -777,5 +921,8 @@ void test_credentials(void) {
test_credentials_read_secret_file_bad();
test_credentials_hash_file();
test_credentials_rejects_group_or_other_accessible();
test_credentials_dummy_key_persisted();
test_credentials_dummy_key_rejects_bad_sidecar();
test_credentials_dummy_key_null_store_ephemeral();
test_credentials_burn();
}