fix(a7-auth): publish dummy-key sidecar atomically and harden reads
Address review findings on the persistent dummy-key sidecar: - Publish atomically: write a private same-directory temp file (<store>.dummykey.tmp.<pid>, 0600), fsync, then link(2) into place; fsync the containing directory and drop the temp name. A concurrent starter can no longer observe a zero/partial sidecar and fail closed. On EEXIST adopt the winner's sidecar; otherwise warn and use a transient ephemeral key. - Harden the read path (initial and EEXIST-adopt) with O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC: reject planted symlinks (ELOOP fails closed) and never block on a planted FIFO. - Require the exact owner-only mode (st_mode & 07777) == 0600 and make the rejection message truthful. - Report a clear "short write" instead of a stale strerror(errno) when write() returns 0. - Document the artifact and its creation-failure caveat (FIFO store path, read-only filesystem, missing directory) in README.md and RSYNC_COMPAT.md. - Tests: known-key sidecar adoption (dummy salt KAT + reload), symlink rejection, and the exact-0600 rule (0400 now rejected).
This commit is contained in:
+118
-1
@@ -74,7 +74,8 @@ static void rm_temp(const char* 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. */
|
||||
* `<store>.dummykey` sidecar; remove it too so tests leave no stray key. The
|
||||
* atomic-publish temp name is also removed defensively. */
|
||||
size_t n = strlen(path) + strlen(".dummykey") + 1;
|
||||
char* sidecar = malloc(n);
|
||||
if (sidecar) {
|
||||
@@ -82,6 +83,13 @@ static void rm_temp(const char* path) {
|
||||
unlink(sidecar);
|
||||
free(sidecar);
|
||||
}
|
||||
n = strlen(path) + strlen(".dummykey.tmp.") + 32;
|
||||
char* tmp = malloc(n);
|
||||
if (tmp) {
|
||||
snprintf(tmp, n, "%s.dummykey.tmp.%ld", path, (long)getpid());
|
||||
unlink(tmp);
|
||||
free(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/* `<store>.dummykey` sidecar path (caller frees). */
|
||||
@@ -862,6 +870,113 @@ static void test_credentials_dummy_key_rejects_bad_sidecar() {
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
free(sidecar);
|
||||
|
||||
/* Exact-mode rule: 0400 has no group/other bits but is not 0600, so it is
|
||||
* rejected now (the mode must be exactly owner read+write). */
|
||||
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, sizeof(key), fp) == sizeof(key));
|
||||
fclose(fp);
|
||||
EXPECT_EQ_INT(chmod(sidecar, 0400), 0);
|
||||
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
|
||||
EXPECT_TRUE(err[0] != '\0');
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
free(sidecar);
|
||||
}
|
||||
|
||||
/* A pre-existing valid sidecar is adopted verbatim (no regeneration): the
|
||||
* unknown-user dummy salt must equal HMAC-SHA256(known key, username), and a
|
||||
* reload must yield the same salt. */
|
||||
static void test_credentials_dummy_key_existing_sidecar_adopted() {
|
||||
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);
|
||||
|
||||
/* Pre-create a valid owner-only sidecar with a known key. */
|
||||
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, 0600), 0);
|
||||
|
||||
char err[512];
|
||||
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
CredentialVerifier v1;
|
||||
EXPECT_TRUE(credentials_get_verifier(store, "unknown-user", NULL, 0, &v1));
|
||||
EXPECT_FALSE(v1.found);
|
||||
/* HMAC-SHA256(0x5a * 32, "unknown-user")[:16], computed independently. */
|
||||
uint8_t expect[CREDENTIAL_SALT_LEN];
|
||||
unhex("4b0d2e6b73025cc2fcb41d0a710ff469", expect, sizeof(expect));
|
||||
EXPECT_TRUE(memcmp(v1.salt, expect, sizeof(expect)) == 0);
|
||||
credentials_free(store);
|
||||
|
||||
/* The adopted sidecar still holds exactly the pre-created key (not a fresh
|
||||
* random one). */
|
||||
uint8_t readback[CREDENTIAL_KEY_LEN];
|
||||
fp = fopen(sidecar, "rb");
|
||||
EXPECT_NOT_NULL(fp);
|
||||
EXPECT_TRUE(fread(readback, 1, sizeof(readback), fp) == sizeof(readback));
|
||||
fclose(fp);
|
||||
EXPECT_TRUE(memcmp(readback, key, sizeof(key)) == 0);
|
||||
|
||||
/* Persisted across a reload. */
|
||||
CredentialVerifier v2;
|
||||
store = credentials_load(path, NULL, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
EXPECT_TRUE(credentials_get_verifier(store, "unknown-user", NULL, 0, &v2));
|
||||
EXPECT_TRUE(memcmp(v1.salt, v2.salt, sizeof(v1.salt)) == 0);
|
||||
credentials_free(store);
|
||||
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
free(sidecar);
|
||||
}
|
||||
|
||||
/* A symlink planted at the sidecar path must fail the load closed (O_NOFOLLOW),
|
||||
* even when it resolves to a valid owner-only key file. */
|
||||
static void test_credentials_dummy_key_symlink_rejected() {
|
||||
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 target[256];
|
||||
snprintf(target, sizeof(target), "/tmp/fs_cred_key_%d_%d", (int)getpid(), g_file_counter++);
|
||||
uint8_t key[CREDENTIAL_KEY_LEN];
|
||||
memset(key, 0x5a, sizeof(key));
|
||||
FILE* fp = fopen(target, "wb");
|
||||
EXPECT_NOT_NULL(fp);
|
||||
EXPECT_TRUE(fwrite(key, 1, sizeof(key), fp) == sizeof(key));
|
||||
fclose(fp);
|
||||
EXPECT_EQ_INT(chmod(target, 0600), 0);
|
||||
EXPECT_EQ_INT(symlink(target, sidecar), 0);
|
||||
|
||||
char err[512];
|
||||
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
|
||||
EXPECT_TRUE(err[0] != '\0');
|
||||
|
||||
unlink(sidecar); /* remove the symlink itself, not its target */
|
||||
unlink(target);
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
free(sidecar);
|
||||
}
|
||||
|
||||
/* A NULL store path has nowhere to persist a key, so each load gets a fresh
|
||||
@@ -923,6 +1038,8 @@ void test_credentials(void) {
|
||||
test_credentials_rejects_group_or_other_accessible();
|
||||
test_credentials_dummy_key_persisted();
|
||||
test_credentials_dummy_key_rejects_bad_sidecar();
|
||||
test_credentials_dummy_key_existing_sidecar_adopted();
|
||||
test_credentials_dummy_key_symlink_rejected();
|
||||
test_credentials_dummy_key_null_store_ephemeral();
|
||||
test_credentials_burn();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user