Phase 8D.1 implemented and validated.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Focused web session store host tests
|
||||
|
||||
From the repository root, run:
|
||||
|
||||
```sh
|
||||
python3 tests/web_session_store/run.py
|
||||
```
|
||||
|
||||
Requires Python 3 and `cc`. Builds the actual `src/web_session_store.c` against
|
||||
its production headers; generated dependency stubs and binaries live only in an
|
||||
automatically removed temporary directory. Compilation and execution have finite
|
||||
timeouts. No ESP-IDF build, device, network, or general test framework is used.
|
||||
|
||||
The runner probes OpenSSL headers/linking and uses real SHA-256 when available.
|
||||
Otherwise it explicitly reports a deterministic **non-cryptographic digest stub**;
|
||||
that fallback verifies store behavior, not cryptography. RNG is always a deterministic
|
||||
test double, never a test of secure randomness. RNG/SHA/DB callbacks assert they
|
||||
run outside the simulated state lock. DB callbacks inject invalidate/user-invalidate/
|
||||
stop operations to exercise revalidation, but do not model real concurrent threads.
|
||||
|
||||
Tests use only the public API: output wiping and record retirement are checked,
|
||||
but private record bytes, allocator failures, hardware locking, and timing under
|
||||
real concurrency are not inspected. Rejections without a specified error contract
|
||||
are checked as non-success; absent tokens, readiness, and capacity use exact errors.
|
||||
Counters are checked with deltas where lifecycle reset semantics are unspecified.
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finite host build/run; all generated headers and binaries are temporary."""
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
# Compiler wrappers must not write caches outside the temporary build directory.
|
||||
os.environ["CCACHE_DISABLE"] = "1"
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
HEADERS = {
|
||||
"esp_err.h": """#pragma once
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL -1
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_INVALID_ARG 0x102
|
||||
#define ESP_ERR_INVALID_STATE 0x103
|
||||
#define ESP_ERR_NOT_FOUND 0x105
|
||||
#define ESP_ERR_NOT_SUPPORTED 0x106
|
||||
#define ESP_ERR_NOT_ALLOWED 0x10d
|
||||
""",
|
||||
"freertos/FreeRTOS.h": """#pragma once
|
||||
#include <assert.h>
|
||||
typedef int portMUX_TYPE;
|
||||
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||
extern int host_lock_depth;
|
||||
#define taskENTER_CRITICAL(m) do { (void)(m); assert(host_lock_depth++ == 0); } while (0)
|
||||
#define taskEXIT_CRITICAL(m) do { (void)(m); assert(--host_lock_depth == 0); } while (0)
|
||||
""",
|
||||
"esp_timer.h": "#pragma once\n#include <stdint.h>\nint64_t esp_timer_get_time(void);\n",
|
||||
"mbedtls/sha256.h": "#pragma once\n#include <stddef.h>\nint mbedtls_sha256(const unsigned char *, size_t, unsigned char *, int);\n",
|
||||
}
|
||||
|
||||
|
||||
def run():
|
||||
source = ROOT / "src/web_session_store.c"
|
||||
if not source.exists():
|
||||
raise SystemExit("Cannot run: src/web_session_store.c has not been created yet")
|
||||
with tempfile.TemporaryDirectory(prefix="web-session-store-") as directory:
|
||||
tmp = pathlib.Path(directory)
|
||||
for name, text in HEADERS.items():
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
probe = subprocess.run(
|
||||
["cc", "-x", "c", "-", "-lcrypto", "-o", str(tmp / "probe")],
|
||||
input="#include <openssl/sha.h>\nint main(void) { unsigned char d[32]; return !SHA256(d, 0, d); }\n",
|
||||
text=True, capture_output=True, timeout=20)
|
||||
crypto = ["-DHOST_OPENSSL", "-lcrypto"] if probe.returncode == 0 else []
|
||||
print("SHA256: " + ("OpenSSL" if crypto else "NON-CRYPTOGRAPHIC STUB (not crypto verification)"), flush=True)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"),
|
||||
str(HERE / "test.c"), str(source), *crypto,
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,278 @@
|
||||
/* Public-API host tests; deliberately no access to private session records. */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "web_session_store.h"
|
||||
#include "secure_random.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#ifdef HOST_OPENSSL
|
||||
#include <openssl/sha.h>
|
||||
#endif
|
||||
|
||||
int host_lock_depth;
|
||||
static int64_t now = 1000000;
|
||||
static unsigned rng_sequence, rng_calls, sha_calls, sha_fail_at;
|
||||
static const char *sha_expected_token;
|
||||
static void (*rng_hook)(void);
|
||||
static bool rng_fail, sha_fail, db_fail;
|
||||
static uint32_t stale_user;
|
||||
static void (*db_hook)(void);
|
||||
static web_session_id_t hook_id;
|
||||
static const char origin[] = "https://device.example";
|
||||
static user_principal_t alice = { .user_id = 1, .auth_generation = 7,
|
||||
.role = USER_ROLE_ADMIN, .method = USER_AUTH_METHOD_PASSWORD,
|
||||
.username_length = 5, .username = "alice" };
|
||||
static user_principal_t bob = { .user_id = 2, .auth_generation = 3,
|
||||
.role = USER_ROLE_USER, .method = USER_AUTH_METHOD_PASSWORD,
|
||||
.username_length = 3, .username = "bob" };
|
||||
typedef struct { char token[65]; web_session_view_t view; } issued_t;
|
||||
static issued_t replacement;
|
||||
|
||||
int64_t esp_timer_get_time(void) { return now; }
|
||||
void secure_wipe(void *data, size_t size) {
|
||||
volatile unsigned char *p = data;
|
||||
while (size--) *p++ = 0;
|
||||
}
|
||||
esp_err_t secure_random_fill(void *output, size_t length) {
|
||||
assert(!host_lock_depth);
|
||||
++rng_calls;
|
||||
void (*hook)(void) = rng_hook;
|
||||
rng_hook = NULL;
|
||||
if (hook) hook();
|
||||
unsigned char *p = output;
|
||||
/* Unique per call; partial dirty output on failure exercises caller cleanup. */
|
||||
unsigned seed = ++rng_sequence;
|
||||
for (size_t i = 0; i < length; ++i) p[i] = (unsigned char)(seed + i * 17);
|
||||
return rng_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
int mbedtls_sha256(const unsigned char *data, size_t length,
|
||||
unsigned char *out, int is224) {
|
||||
assert(!host_lock_depth && !is224);
|
||||
memset(out, 0xa5, 32);
|
||||
++sha_calls;
|
||||
if (sha_fail_at && sha_calls == sha_fail_at) {
|
||||
assert(data == (const unsigned char *)sha_expected_token);
|
||||
assert(length == 64 && strlen(sha_expected_token) == 64);
|
||||
return -1;
|
||||
}
|
||||
if (sha_fail) return -1;
|
||||
#ifdef HOST_OPENSSL
|
||||
return SHA256(data, length, out) ? 0 : -1;
|
||||
#else
|
||||
/* Only a deterministic digest double; NOT cryptographic verification. */
|
||||
memset(out, 0, 32);
|
||||
for (size_t i = 0; i < length; ++i)
|
||||
out[i % 32] = (unsigned char)((out[i % 32] * 33U) ^ data[i] ^ i);
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *current) {
|
||||
assert(!host_lock_depth);
|
||||
void (*hook)(void) = db_hook;
|
||||
db_hook = NULL;
|
||||
if (hook) hook();
|
||||
*current = !db_fail && p->user_id != stale_user;
|
||||
return db_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static void zero(const void *data, size_t length) {
|
||||
const unsigned char *p = data;
|
||||
for (size_t i = 0; i < length; ++i) assert(p[i] == 0);
|
||||
}
|
||||
static web_session_store_snapshot_t snapshot(void) {
|
||||
web_session_store_snapshot_t s;
|
||||
assert(web_session_store_get_snapshot(&s) == ESP_OK);
|
||||
return s;
|
||||
}
|
||||
static esp_err_t issue(const user_principal_t *p, issued_t *s) {
|
||||
memset(s, 0xa5, sizeof(*s));
|
||||
return web_session_store_issue(p, origin, strlen(origin), s->token, &s->view);
|
||||
}
|
||||
static issued_t mint(const user_principal_t *p) {
|
||||
issued_t s;
|
||||
assert(issue(p, &s) == ESP_OK);
|
||||
assert(s.view.id && strlen(s.token) == 64 && strlen(s.view.csrf) == 64);
|
||||
assert(s.view.expires_at_us == now + WEB_SESSION_STORE_LIFETIME_US);
|
||||
assert(s.view.principal.user_id == p->user_id);
|
||||
assert(s.view.principal.auth_generation == p->auth_generation);
|
||||
assert(s.view.principal.role == p->role && s.view.principal.method == p->method);
|
||||
assert(s.view.principal.username_length == p->username_length);
|
||||
assert(!strcmp(s.view.principal.username, p->username));
|
||||
return s;
|
||||
}
|
||||
static esp_err_t lookup(const issued_t *s, web_session_view_t *v) {
|
||||
memset(v, 0xa5, sizeof(*v));
|
||||
return web_session_store_lookup(s->token, strlen(s->token), origin, strlen(origin), v);
|
||||
}
|
||||
static void present(const issued_t *s) {
|
||||
web_session_view_t v;
|
||||
assert(lookup(s, &v) == ESP_OK);
|
||||
assert(v.id == s->view.id && v.expires_at_us == s->view.expires_at_us);
|
||||
assert(!strcmp(v.csrf, s->view.csrf));
|
||||
assert(!memcmp(&v.principal, &s->view.principal, sizeof(v.principal)));
|
||||
bool current = false;
|
||||
assert(web_session_store_is_current(v.id, ¤t) == ESP_OK && current);
|
||||
}
|
||||
static void absent(const issued_t *s) {
|
||||
web_session_view_t v;
|
||||
assert(lookup(s, &v) == ESP_ERR_NOT_FOUND);
|
||||
zero(&v, sizeof(v));
|
||||
bool current = true;
|
||||
(void)web_session_store_is_current(s->view.id, ¤t);
|
||||
assert(!current);
|
||||
}
|
||||
static void reset(void) {
|
||||
web_session_store_stop();
|
||||
rng_fail = sha_fail = db_fail = false;
|
||||
stale_user = 0;
|
||||
db_hook = rng_hook = NULL;
|
||||
sha_fail_at = 0;
|
||||
sha_expected_token = NULL;
|
||||
assert(web_session_store_init() == ESP_OK);
|
||||
assert(snapshot().active == 0);
|
||||
}
|
||||
static void invalidate_hook(void) { web_session_store_invalidate(hook_id); }
|
||||
static void user_hook(void) { web_session_store_invalidate_user(alice.user_id); }
|
||||
static void stop_hook(void) { web_session_store_stop(); }
|
||||
static void replace_hook(void) {
|
||||
web_session_store_invalidate(hook_id);
|
||||
replacement = mint(&bob);
|
||||
}
|
||||
static void expire_hook(void) { ++now; }
|
||||
|
||||
int main(void) {
|
||||
issued_t s, a, b, c, slots[4];
|
||||
web_session_view_t v;
|
||||
bool current = true;
|
||||
assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
memset(&s, 0, sizeof(s)); memset(s.token, 'a', 64);
|
||||
assert(lookup(&s, &v) == ESP_ERR_INVALID_STATE); zero(&v, sizeof(v));
|
||||
assert(web_session_store_is_current(1, ¤t) == ESP_ERR_INVALID_STATE && !current);
|
||||
rng_fail = true;
|
||||
assert(web_session_store_init() != ESP_OK);
|
||||
assert(!snapshot().initialized && snapshot().init_failures == 1);
|
||||
rng_fail = false;
|
||||
assert(web_session_store_init() == ESP_OK);
|
||||
a = mint(&alice);
|
||||
unsigned calls = rng_calls;
|
||||
rng_fail = true;
|
||||
assert(web_session_store_init() == ESP_OK && calls == rng_calls);
|
||||
rng_fail = false; present(&a);
|
||||
|
||||
reset();
|
||||
web_session_store_snapshot_t before = snapshot();
|
||||
for (unsigned i = 0; i < 4; ++i) slots[i] = mint(&alice);
|
||||
assert(issue(&bob, &s) == ESP_ERR_NO_MEM);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
for (unsigned i = 0; i < 4; ++i) present(&slots[i]);
|
||||
assert(snapshot().active == 4 && snapshot().issued == before.issued + 4);
|
||||
assert(snapshot().capacity_rejections == before.capacity_rejections + 1);
|
||||
assert(snapshot().storage_bytes >= 4 * snapshot().slot_bytes && snapshot().slot_bytes > 0);
|
||||
a = slots[0];
|
||||
char bad[65]; strcpy(bad, a.token); bad[0] = bad[0] == 'a' ? 'b' : 'a';
|
||||
memset(&v, 0xa5, sizeof(v));
|
||||
assert(web_session_store_lookup(bad, 64, origin, strlen(origin), &v) == ESP_ERR_NOT_FOUND);
|
||||
zero(&v, sizeof(v));
|
||||
strcpy(bad, a.token); bad[0] = '!';
|
||||
assert(web_session_store_lookup(bad, 64, origin, strlen(origin), &v) != ESP_OK);
|
||||
assert(web_session_store_lookup(a.token, 63, origin, strlen(origin), &v) != ESP_OK);
|
||||
assert(web_session_store_lookup(a.token, 64, "https://other", 13, &v) == ESP_ERR_NOT_FOUND);
|
||||
zero(&v, sizeof(v)); present(&a);
|
||||
now = a.view.expires_at_us - 1; present(&a);
|
||||
now++; absent(&a);
|
||||
assert(snapshot().active == 0 && snapshot().expired == before.expired + 4);
|
||||
|
||||
reset(); a = mint(&alice); b = mint(&alice); c = mint(&bob);
|
||||
before = snapshot();
|
||||
web_session_store_invalidate(a.view.id); absent(&a); present(&b); present(&c);
|
||||
s = mint(&alice); assert(s.view.id != a.view.id);
|
||||
web_session_store_invalidate(a.view.id); present(&s);
|
||||
web_session_store_invalidate_user(alice.user_id);
|
||||
absent(&b); absent(&s); present(&c);
|
||||
assert(snapshot().invalidated == before.invalidated + 3);
|
||||
web_session_store_stop(); assert(!snapshot().initialized && snapshot().active == 0);
|
||||
assert(web_session_store_init() == ESP_OK);
|
||||
s = mint(&bob); assert(s.view.id != c.view.id); absent(&c); present(&s);
|
||||
|
||||
reset(); stale_user = alice.user_id;
|
||||
assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
stale_user = 0; a = mint(&alice); b = mint(&bob);
|
||||
stale_user = alice.user_id;
|
||||
assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v));
|
||||
stale_user = 0; absent(&a); present(&b);
|
||||
db_fail = true;
|
||||
assert(lookup(&b, &v) != ESP_OK); zero(&v, sizeof(v));
|
||||
db_fail = false; absent(&b); assert(snapshot().active == 0);
|
||||
a = mint(&alice); b = mint(&bob); stale_user = alice.user_id;
|
||||
web_session_store_prune(); stale_user = 0; absent(&a); present(&b);
|
||||
db_fail = true; web_session_store_prune(); db_fail = false; absent(&b);
|
||||
|
||||
reset();
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
rng_fail = mode == 0; sha_fail = mode == 1;
|
||||
assert(issue(&alice, &s) != ESP_OK);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
assert(snapshot().active == 0);
|
||||
rng_fail = sha_fail = false;
|
||||
}
|
||||
a = mint(&alice); sha_fail = true;
|
||||
assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v));
|
||||
sha_fail = false; present(&a);
|
||||
user_principal_t key = alice; key.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY;
|
||||
assert(issue(&key, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
|
||||
reset(); a = mint(&alice); hook_id = a.view.id; db_hook = invalidate_hook;
|
||||
assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v)); absent(&a);
|
||||
a = mint(&alice); hook_id = a.view.id; db_hook = invalidate_hook; current = true;
|
||||
(void)web_session_store_is_current(a.view.id, ¤t); assert(!current);
|
||||
b = mint(&bob); db_hook = user_hook;
|
||||
assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
present(&b);
|
||||
db_hook = stop_hook;
|
||||
assert(lookup(&b, &v) != ESP_OK); zero(&v, sizeof(v));
|
||||
reset(); db_hook = stop_hook;
|
||||
assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
assert(!snapshot().initialized && snapshot().active == 0);
|
||||
/* Empty prune cannot consume this hook: invalidate during candidate validation. */
|
||||
reset(); db_hook = user_hook;
|
||||
assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE && db_hook == NULL);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
assert(snapshot().active == 0);
|
||||
a = mint(&alice); present(&a);
|
||||
|
||||
reset(); a = mint(&alice); hook_id = a.view.id; db_hook = replace_hook;
|
||||
assert(lookup(&a, &v) == ESP_ERR_NOT_FOUND && db_hook == NULL);
|
||||
zero(&v, sizeof(v));
|
||||
assert(replacement.view.id != a.view.id && snapshot().active == 1);
|
||||
absent(&a); present(&replacement);
|
||||
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
reset(); a = mint(&alice); before = snapshot();
|
||||
now = a.view.expires_at_us - 1; db_hook = expire_hook;
|
||||
if (mode == 0) {
|
||||
assert(lookup(&a, &v) == ESP_ERR_NOT_FOUND); zero(&v, sizeof(v));
|
||||
} else {
|
||||
current = true;
|
||||
assert(web_session_store_is_current(a.view.id, ¤t) == ESP_ERR_NOT_FOUND);
|
||||
assert(!current);
|
||||
}
|
||||
assert(db_hook == NULL && now == a.view.expires_at_us);
|
||||
assert(snapshot().expired == before.expired + 1); absent(&a);
|
||||
}
|
||||
|
||||
reset(); web_session_store_stop(); before = snapshot(); rng_hook = stop_hook;
|
||||
assert(web_session_store_init() == ESP_ERR_INVALID_STATE && rng_hook == NULL);
|
||||
assert(!snapshot().initialized && snapshot().active == 0);
|
||||
assert(snapshot().init_failures == before.init_failures + 1);
|
||||
assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_session_store_init() == ESP_OK); a = mint(&alice); present(&a);
|
||||
|
||||
reset(); sha_calls = 0; sha_fail_at = 2; sha_expected_token = s.token;
|
||||
assert(issue(&alice, &s) != ESP_OK && sha_calls == 2);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
assert(snapshot().active == 0);
|
||||
sha_fail_at = 0; sha_expected_token = NULL; a = mint(&alice); present(&a);
|
||||
assert(!host_lock_depth);
|
||||
puts("PASS: web_session_store public API, failure cleanup, and callback races");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user