/* SPDX-License-Identifier: GPL-3.0-only */ #include #include #include #include #include /* Include unmodified production C to inspect wipes, ABA and exhaustion without * adding firmware-only test hooks. Platform/user/session headers remain real. */ #include "web_admin_tickets.c" int host_lock_depth; static int64_t clock_us; static unsigned random_sequence; static bool rng_fail, sha_fail, session_fail, live[4]; static user_principal_t principals[4]; static void (*rng_hook)(void), (*sha_hook)(void), (*check_hook)(void); static unsigned check_calls, hook_at; static unsigned tests; int64_t esp_timer_get_time(void) { assert(!host_lock_depth); return clock_us; } void secure_wipe(void *p, size_t n) { volatile unsigned char *v = p; while (n--) *v++ = 0; } static void fire(void (**hook)(void)) { void (*call)(void) = *hook; *hook = NULL; if (call) call(); } esp_err_t secure_random_fill(void *p, size_t n) { assert(!host_lock_depth && n == 32); memset(p, ++random_sequence, n); fire(&rng_hook); return rng_fail ? ESP_FAIL : ESP_OK; } int mbedtls_sha256(const unsigned char *p, size_t n, unsigned char *out, int mode) { assert(!host_lock_depth && n == 32 && mode == 0); assert(SHA256(p, n, out)); fire(&sha_hook); return sha_fail ? -1 : 0; } esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *p, bool *valid) { assert(!host_lock_depth); ++check_calls; *valid = id < 4 && live[id] && same_principal(&principals[id], p); if (check_calls == hook_at) fire(&check_hook); /* Like the real session resolver, recheck liveness before returning; * never upgrade an already-failed check after a slot replacement. */ *valid = *valid && id < 4 && live[id] && same_principal(&principals[id], p); return session_fail ? ESP_FAIL : ESP_OK; } static void zero(const void *p, size_t n) { const unsigned char *v = p; while (n--) assert(*v++ == 0); } static void reset(void) { /* Test isolation only; production never resets these generations. */ memset(&s_state, 0, sizeof(s_state)); clock_us = 100; random_sequence = 0; rng_fail = sha_fail = session_fail = false; rng_hook = sha_hook = check_hook = NULL; check_calls = hook_at = 0; for (unsigned i = 1; i < 4; ++i) { live[i] = true; principals[i] = (user_principal_t) { .user_id = i, .auth_generation = 1, .role = USER_ROLE_ADMIN, .method = USER_AUTH_METHOD_PASSWORD, .username_length = 1, .username = {(char)('a' + i - 1), 0}, }; } web_admin_tickets_start(); } static void passed(const char *name) { ++tests; printf("PASS %s\n", name); } static void issue(unsigned id, char *token) { assert(web_admin_tickets_issue(id, &principals[id], token) == ESP_OK); } static void restart(void) { web_admin_tickets_stop(); web_admin_tickets_start(); } static void revoke_all(void) { web_admin_tickets_revoke(0, NULL, 0); } static void revoke_other(void) { web_admin_tickets_revoke(99, NULL, 0); } static void stale(void) { live[1] = false; } static void expire(void) { clock_us += WEB_ADMIN_TICKET_LIFETIME_US; } static void fail_issue(void) { char token[65]; memset(token, 'x', sizeof(token)); assert(web_admin_tickets_issue(1, &principals[1], token) != ESP_OK); zero(token, sizeof(token)); zero(s_state.tickets, sizeof(s_state.tickets)); } static char replacement[65]; static void replace_stale(void) { revoke_all(); live[1] = true; random_sequence = 0; /* Same digest, ID and deadline: only generation differs. */ issue(1, replacement); } static char nested_token[65]; static esp_err_t nested_result; static void nested_issue(void) { issue(2, nested_token); } static void nested_consume(void) { nested_result = web_admin_tickets_consume(nested_token, 1, &principals[1]); } int main(void) { char a[65], b[65], c[65]; web_admin_tickets_snapshot_t snap; reset(); web_admin_tickets_stop(); fail_issue(); web_admin_tickets_start(); issue(1, a); uint64_t epoch = s_state.epoch; web_admin_tickets_start(); assert(s_state.epoch == epoch); assert(strlen(a) == 64); for (unsigned i = 0; i < 64; ++i) assert(isxdigit((unsigned char)a[i])); uint8_t raw[32]; memset(raw, 1, sizeof(raw)); uint8_t expected[32]; assert(SHA256(raw, sizeof(raw), expected)); assert(equal_digest(s_state.tickets[1].digest, expected)); assert(memcmp(s_state.tickets[1].digest, raw, 32)); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND); zero(s_state.tickets, sizeof(s_state.tickets)); passed("lifecycle, hex/digest storage, single use and wipe"); reset(); issue(1, a); issue(2, b); assert(web_admin_tickets_issue(3, &principals[3], c) == ESP_ERR_NO_MEM); zero(c, sizeof(c)); web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2 && snap.issued == 2 && snap.rejected == 1 && snap.capacity_rejections == 1 && snap.ready); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK); assert(web_admin_tickets_consume(b, 2, &principals[2]) == ESP_OK); reset(); issue(3, c); rng_hook = nested_issue; assert(web_admin_tickets_issue(1, &principals[1], a) == ESP_ERR_NO_MEM); zero(a, sizeof(a)); assert(web_admin_tickets_consume(nested_token, 2, &principals[2]) == ESP_OK); assert(web_admin_tickets_consume(c, 3, &principals[3]) == ESP_OK); passed("capacity rejects without live eviction, competing issue and exact counters"); reset(); principals[1].role = USER_ROLE_USER; fail_issue(); principals[1].role = USER_ROLE_ADMIN; principals[1].method = USER_AUTH_METHOD_SSH_PUBLIC_KEY; fail_issue(); principals[1].method = USER_AUTH_METHOD_PASSWORD; user_principal_t bad = principals[1]; bad.auth_generation++; assert(web_admin_tickets_issue(1, &bad, a) == ESP_ERR_INVALID_STATE); assert(web_admin_tickets_issue(0, &principals[1], a) == ESP_ERR_INVALID_ARG); assert(web_admin_tickets_issue(1, NULL, a) == ESP_ERR_INVALID_ARG); assert(web_admin_tickets_issue(1, &principals[1], NULL) == ESP_ERR_INVALID_ARG); live[1] = false; fail_issue(); live[1] = true; session_fail = true; fail_issue(); passed("issue role, password, session/principal binding and errors"); for (unsigned mode = 0; mode < 8; ++mode) { reset(); issue(1, a); bad = principals[1]; unsigned id = 1; if (mode == 0) id = 2; if (mode == 1) bad.role = USER_ROLE_USER; if (mode == 2) bad.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY; if (mode == 3) bad.auth_generation++; if (mode == 4) live[1] = false; if (mode == 5) session_fail = true; if (mode == 6) id = 0; assert(web_admin_tickets_consume(a, id, mode == 7 ? NULL : &bad) == ESP_ERR_INVALID_STATE); zero(s_state.tickets, sizeof(s_state.tickets)); assert(s_state.consumed == 1); } reset(); principals[2] = principals[1]; issue(1, a); assert(web_admin_tickets_consume(a, 2, &principals[2]) == ESP_ERR_INVALID_STATE); zero(s_state.tickets, sizeof(s_state.tickets)); passed("consume burns before wrong identity/role/currentness results, same-account session binding"); reset(); random_sequence = 170; issue(1, a); strcpy(b, a); b[63] = 0; assert(web_admin_tickets_consume(b, 1, &principals[1]) == ESP_ERR_INVALID_ARG); char long_token[66]; memcpy(long_token, a, 64); long_token[64] = 'a'; long_token[65] = 0; assert(web_admin_tickets_consume(long_token, 1, &principals[1]) == ESP_ERR_INVALID_ARG); assert(web_admin_tickets_consume("", 1, &principals[1]) == ESP_ERR_INVALID_ARG); assert(web_admin_tickets_consume(NULL, 1, &principals[1]) == ESP_ERR_INVALID_ARG); strcpy(b, a); b[30] = 'g'; assert(web_admin_tickets_consume(b, 1, &principals[1]) == ESP_ERR_INVALID_ARG); for (unsigned i = 0; i < 64; ++i) a[i] = (char)toupper((unsigned char)a[i]); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK); passed("exact bounded hex validation and uppercase equivalence"); reset(); issue(1, a); clock_us += WEB_ADMIN_TICKET_LIFETIME_US - 1; assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK); issue(1, a); expire(); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND); issue(1, a); issue(2, b); live[1] = false; issue(3, c); web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2); live[2] = false; web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1); expire(); web_admin_tickets_get_snapshot(&snap); assert(snap.active == 0); clock_us = INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US + 1; fail_issue(); passed("absolute expiry boundary, stale cleanup and time overflow"); reset(); issue(1, a); issue(2, b); web_admin_tickets_revoke(1, (const uint8_t *)"b", 1); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND); assert(web_admin_tickets_consume(b, 2, &principals[2]) == ESP_OK); issue(1, a); issue(2, b); web_admin_tickets_revoke(0, (const uint8_t *)"a", 0); web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2); web_admin_tickets_revoke(0, (const uint8_t *)"a", 1); web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1); revoke_all(); zero(s_state.tickets, sizeof(s_state.tickets)); assert(live[1] && live[2]); passed("revoke ID precedence, exact username, all; no session invalidation"); reset(); rng_fail = true; fail_issue(); reset(); sha_fail = true; fail_issue(); reset(); issue(1, a); sha_fail = true; assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_FAIL); sha_fail = false; assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK); reset(); issue(1, a); random_sequence = 0; assert(web_admin_tickets_issue(2, &principals[2], b) == ESP_FAIL); zero(b, sizeof(b)); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK); passed("RNG/SHA failure, output wipe and live digest collision rejection"); void (*actions[])(void) = {restart, revoke_all, revoke_other, stale}; for (unsigned i = 0; i < sizeof(actions) / sizeof(actions[0]); ++i) { reset(); rng_hook = actions[i]; fail_issue(); reset(); sha_hook = actions[i]; fail_issue(); } reset(); hook_at = 1; check_hook = restart; fail_issue(); passed("issue stop/restart, revoke and stale races across RNG/SHA/currentness"); for (unsigned i = 0; i < sizeof(actions) / sizeof(actions[0]); ++i) { reset(); issue(1, a); sha_hook = actions[i]; assert(web_admin_tickets_consume(a, 1, &principals[1]) != ESP_OK); reset(); issue(1, a); hook_at = check_calls + 2; check_hook = actions[i]; assert(web_admin_tickets_consume(a, 1, &principals[1]) != ESP_OK); assert(s_state.consumed == 1); } reset(); issue(1, a); sha_hook = expire; assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND); reset(); issue(1, a); hook_at = check_calls + 2; check_hook = expire; assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_INVALID_STATE); reset(); issue(1, nested_token); sha_hook = nested_consume; assert(web_admin_tickets_consume(nested_token, 1, &principals[1]) == ESP_ERR_NOT_FOUND); assert(nested_result == ESP_OK && s_state.consumed == 1); passed("consume crypto/postcheck lifecycle/expiry races and competing consume"); reset(); issue(1, a); live[1] = false; hook_at = check_calls + 1; check_hook = replace_stale; web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1); assert(web_admin_tickets_consume(replacement, 1, &principals[1]) == ESP_OK); passed("stale-prune slot replacement ABA"); reset(); issue(1, a); s_state.epoch = UINT64_MAX - 1; revoke_all(); web_admin_tickets_start(); assert(s_state.epoch == UINT64_MAX && !s_state.ready); fail_issue(); restart(); assert(s_state.epoch == UINT64_MAX && !s_state.ready); reset(); s_state.generation = UINT64_MAX - 1; issue(1, a); assert(web_admin_tickets_issue(1, &principals[1], b) == ESP_ERR_INVALID_STATE); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK); restart(); assert(!s_state.ready); reset(); s_state.issued = s_state.consumed = s_state.rejected = UINT32_MAX; s_state.capacity_rejections = UINT32_MAX; issue(1, a); issue(2, b); assert(web_admin_tickets_issue(3, &principals[3], c) == ESP_ERR_NO_MEM); assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK); web_admin_tickets_get_snapshot(&snap); assert(snap.issued == UINT32_MAX && snap.consumed == UINT32_MAX && snap.rejected == UINT32_MAX && snap.capacity_rejections == UINT32_MAX); web_admin_tickets_get_snapshot(NULL); printf("Host sizes: ticket=%zu state=%zu lock=%zu snapshot=%zu bytes\n", sizeof(ticket_t), sizeof(s_state), sizeof(s_lock), sizeof(snap)); passed("nonwrapping epoch/generation, saturating counters, count-only snapshot"); printf("%u test groups passed\n", tests); }