Decouple user provisioning from HTTPS identity storage while retaining compatible v1 user records and migrating TLS material to the credential-free v2 format. Add focused security regression coverage and update operator documentation.
312 lines
15 KiB
C
312 lines
15 KiB
C
/* SPDX-License-Identifier: GPL-3.0-only */
|
|
#include <assert.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/random.h>
|
|
|
|
/* Include, do not extract or replace: all production crypto/storage paths run. */
|
|
#include "../../src/web_security.c"
|
|
|
|
static uint8_t stored[1600], pending[1600];
|
|
static size_t stored_size, pending_size;
|
|
static int fault, writes, commits, rng_calls, legacy_wipes, groups;
|
|
static bool locked, fail_mutex, fail_rng, fail_mac, alternate_mac, watch_publication;
|
|
static web_security_blob_t expected_live;
|
|
|
|
enum { OPEN_RO = 20, OPEN_RW, QUERY, READ, SET, COMMIT, TYPE, SHORT_READ };
|
|
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return fail_mutex ? NULL : (void *)1; }
|
|
int xSemaphoreTake(SemaphoreHandle_t m, unsigned delay)
|
|
{ (void)delay; assert(m && !locked); locked = true; return 1; }
|
|
int xSemaphoreGive(SemaphoreHandle_t m)
|
|
{ assert(m && locked); locked = false; return 1; }
|
|
esp_err_t esp_read_mac(uint8_t *mac, int type)
|
|
{
|
|
assert(type == ESP_MAC_WIFI_SOFTAP);
|
|
const uint8_t fixed[] = {2, 0, 0, 0x12, 0x34, 0x56};
|
|
memcpy(mac, fixed, sizeof(fixed));
|
|
if (alternate_mac) mac[5] ^= 1;
|
|
return fail_mac ? ESP_FAIL : ESP_OK;
|
|
}
|
|
esp_err_t secure_random_init(void) { return fail_rng ? ESP_FAIL : ESP_OK; }
|
|
esp_err_t secure_random_fill(void *out, size_t length)
|
|
{
|
|
++rng_calls;
|
|
if (fail_rng) return ESP_FAIL;
|
|
return getrandom(out, length, 0) == (ssize_t)length ? ESP_OK : ESP_FAIL;
|
|
}
|
|
int secure_random_mbedtls(void *ctx, unsigned char *out, size_t length)
|
|
{ (void)ctx; return secure_random_fill(out, length) == ESP_OK ? 0 : -1; }
|
|
void secure_wipe(void *data, size_t size)
|
|
{
|
|
volatile uint8_t *p = data;
|
|
for (size_t i = 0; i < size; ++i) p[i] = 0;
|
|
if (size == LEGACY_BLOB_SIZE) {
|
|
++legacy_wipes;
|
|
assert(bytes_are_zero(data, size));
|
|
}
|
|
}
|
|
esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *handle)
|
|
{
|
|
assert(!strcmp(name, "web_sec"));
|
|
if (fault == (mode == NVS_READONLY ? OPEN_RO : OPEN_RW)) return ESP_FAIL;
|
|
*handle = mode;
|
|
return ESP_OK;
|
|
}
|
|
esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *data, size_t *size)
|
|
{
|
|
assert(handle == NVS_READONLY && !strcmp(key, "material"));
|
|
if (fault == TYPE) return ESP_ERR_NVS_TYPE_MISMATCH;
|
|
if (fault == (data ? READ : QUERY)) return ESP_FAIL;
|
|
if (!stored_size) return ESP_ERR_NVS_NOT_FOUND;
|
|
if (!data) { *size = stored_size; return ESP_OK; }
|
|
assert(*size >= stored_size);
|
|
memcpy(data, stored, stored_size);
|
|
*size = stored_size - (fault == SHORT_READ ? 1 : 0);
|
|
return ESP_OK;
|
|
}
|
|
esp_err_t nvs_set_blob(nvs_handle_t handle, const char *key, const void *data, size_t size)
|
|
{
|
|
assert(handle == NVS_READWRITE && !strcmp(key, "material"));
|
|
assert(size == 1340 && locked);
|
|
++writes;
|
|
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
|
|
if (fault == SET) return ESP_FAIL;
|
|
memcpy(pending, data, size); pending_size = size;
|
|
return ESP_OK;
|
|
}
|
|
esp_err_t nvs_commit(nvs_handle_t handle)
|
|
{
|
|
assert(handle == NVS_READWRITE && pending_size);
|
|
++commits;
|
|
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
|
|
if (fault == COMMIT) return ESP_FAIL;
|
|
memcpy(stored, pending, pending_size); stored_size = pending_size;
|
|
return ESP_OK;
|
|
}
|
|
void nvs_close(nvs_handle_t handle) { (void)handle; pending_size = 0; }
|
|
|
|
static void boot(void)
|
|
{
|
|
memset(&s_material, 0, sizeof(s_material));
|
|
s_material_ready = false; s_security_mutex = NULL;
|
|
s_load_result = WEB_SECURITY_LOAD_STORED;
|
|
fault = writes = commits = rng_calls = legacy_wipes = 0;
|
|
fail_mutex = fail_rng = fail_mac = alternate_mac = locked = false;
|
|
expected_live = s_material; watch_publication = true;
|
|
}
|
|
static void put16(uint8_t *p, unsigned v) { p[0] = v; p[1] = v >> 8; }
|
|
static void legacy(const web_security_blob_t *identity)
|
|
{
|
|
memset(stored, 0, sizeof(stored)); stored_size = 1392;
|
|
stored[0] = 1; put16(stored + 4, 1392);
|
|
put16(stored + 8, identity->generation);
|
|
put16(stored + 10, identity->generation >> 16);
|
|
stored[12] = 5; stored[13] = 24;
|
|
put16(stored + 14, identity->private_key_length);
|
|
put16(stored + 16, identity->certificate_length);
|
|
memcpy(stored + 20, "admin", 5);
|
|
memcpy(stored + 36, "Ab09-_Ab09-_Ab09-_Ab09-_", 24);
|
|
memcpy(stored + 68, identity->private_key_der, 256);
|
|
memcpy(stored + 324, identity->certificate_der, 1024);
|
|
memcpy(stored + 1348, identity->certificate_fingerprint, 32);
|
|
}
|
|
static void group(const char *name) { ++groups; printf("PASS %s\n", name); }
|
|
static void rejected(void)
|
|
{
|
|
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
|
|
size_t size = stored_size;
|
|
web_security_load_result_t result = (web_security_load_result_t)99;
|
|
assert(web_security_init(&result) != ESP_OK);
|
|
assert(result == 99 && !s_material_ready);
|
|
assert(bytes_are_zero((uint8_t *)&s_material, sizeof(s_material)));
|
|
assert(size == stored_size && !memcmp(before, stored, sizeof(before)));
|
|
assert(writes == 0 || fault == SET || fault == COMMIT);
|
|
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
|
|
}
|
|
int main(void)
|
|
{
|
|
boot(); stored_size = 0;
|
|
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
|
|
web_security_load_result_t result;
|
|
assert(web_security_init(&result) == ESP_OK);
|
|
assert(result == WEB_SECURITY_LOAD_GENERATED_MISSING);
|
|
assert(stored_size == 1340 && writes == 1 && commits == 1);
|
|
assert(s_material.generation == 1 && validate_blob(&s_material) == ESP_OK);
|
|
web_security_blob_t identity = s_material;
|
|
group("fresh TLS-only generation and commit-before-publication");
|
|
|
|
boot(); assert(web_security_init(&result) == ESP_OK);
|
|
assert(result == WEB_SECURITY_LOAD_STORED && !writes && !commits);
|
|
assert(!memcmp(&identity, &s_material, sizeof(identity)));
|
|
group("stored v2 exact reload without write");
|
|
|
|
identity.generation = 0x12345678;
|
|
boot(); legacy(&identity);
|
|
assert(web_security_init(&result) == ESP_OK);
|
|
assert(result == WEB_SECURITY_LOAD_MIGRATED_V1 && legacy_wipes == 1);
|
|
assert(writes == 1 && commits == 1 && stored_size == 1340);
|
|
assert(!memcmp(&identity, &s_material, sizeof(identity)));
|
|
assert(!memcmp(stored, &identity, sizeof(identity)));
|
|
assert(web_security_init(&result) == ESP_OK && writes == 1);
|
|
assert(result == WEB_SECURITY_LOAD_MIGRATED_V1);
|
|
group("v1 migration exact key/certificate/fingerprint/generation and wipe");
|
|
|
|
uint8_t certificate[1024], key[256]; size_t cn, kn;
|
|
assert(web_security_copy_tls_material(NULL, 0, &cn, NULL, 0, &kn) == ESP_OK);
|
|
memset(certificate, 0xa5, sizeof(certificate));
|
|
assert(web_security_copy_tls_material(certificate, sizeof(certificate), &cn,
|
|
key, 1, &kn) == ESP_ERR_INVALID_SIZE);
|
|
assert(certificate[0] == 0xa5);
|
|
assert(web_security_copy_tls_material(certificate, sizeof(certificate), &cn,
|
|
key, sizeof(key), &kn) == ESP_OK);
|
|
assert(cn == identity.certificate_length && kn == identity.private_key_length);
|
|
assert(!memcmp(certificate, identity.certificate_der, cn));
|
|
assert(!memcmp(key, identity.private_key_der, kn));
|
|
web_security_certificate_metadata_t metadata;
|
|
assert(web_security_get_certificate_metadata(&metadata) == ESP_OK);
|
|
assert(metadata.material_generation == identity.generation);
|
|
assert(!memcmp(metadata.sha256_fingerprint, identity.certificate_fingerprint, 32));
|
|
group("public metadata and atomic pair-copy capacity contract");
|
|
|
|
const int faults[] = {OPEN_RO, QUERY, READ, TYPE, SHORT_READ, OPEN_RW, SET, COMMIT};
|
|
for (size_t i = 0; i < sizeof(faults)/sizeof(faults[0]); ++i) {
|
|
boot(); legacy(&identity); fault = faults[i]; rejected();
|
|
if (fault == READ || fault == SHORT_READ || fault == OPEN_RW ||
|
|
fault == SET || fault == COMMIT) assert(legacy_wipes == 1);
|
|
fault = 0;
|
|
assert(web_security_init(&result) == ESP_OK);
|
|
assert(!memcmp(&identity, &s_material, sizeof(identity)));
|
|
}
|
|
group("migration NVS open/query/read/type/short/set/commit failures and retry");
|
|
|
|
const size_t corrupt[] = {0, 4, 6, 8, 12, 13, 14, 15, 16, 17, 18,
|
|
20, 25, 36, 60, 68, 323, 324, 1347, 1348, 1380};
|
|
for (size_t i = 0; i < sizeof(corrupt)/sizeof(corrupt[0]); ++i) {
|
|
boot(); legacy(&identity);
|
|
if (corrupt[i] == 8) memset(stored + 8, 0, 4);
|
|
else stored[corrupt[i]] ^= 0x80;
|
|
rejected(); assert(legacy_wipes == 1 && writes == 0);
|
|
}
|
|
group("21 legacy schema/reserved/credential/length/DER/fingerprint corruptions");
|
|
|
|
const size_t bad_sizes[] = {1, 1339, 1341, 1391, 1393, 1600};
|
|
for (size_t i = 0; i < sizeof(bad_sizes)/sizeof(bad_sizes[0]); ++i) {
|
|
boot(); legacy(&identity); stored_size = bad_sizes[i]; rejected();
|
|
}
|
|
const size_t v2_corrupt[] = {0, 4, 6, 8, 12, 13, 14, 15, 16, 271, 272, 1295, 1296, 1328};
|
|
for (size_t i = 0; i < sizeof(v2_corrupt)/sizeof(v2_corrupt[0]); ++i) {
|
|
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
|
|
if (v2_corrupt[i] == 8) memset(stored + 8, 0, 4);
|
|
else stored[v2_corrupt[i]] ^= 0x80;
|
|
rejected();
|
|
}
|
|
group("unknown sizes and 14 v2 structural/crypto corruptions fail untouched");
|
|
|
|
/* Recompute fingerprint so signature validation, not just hashing, rejects. */
|
|
web_security_blob_t invalid = identity;
|
|
invalid.certificate_der[invalid.certificate_length - 1] ^= 1;
|
|
assert(mbedtls_sha256(invalid.certificate_der, invalid.certificate_length,
|
|
invalid.certificate_fingerprint, 0) == 0);
|
|
boot(); legacy(&invalid); rejected();
|
|
web_security_blob_t other;
|
|
assert(generate_all(&other, 1) == ESP_OK);
|
|
invalid = identity;
|
|
memcpy(invalid.private_key_der, other.private_key_der, sizeof(invalid.private_key_der));
|
|
invalid.private_key_length = other.private_key_length;
|
|
boot(); legacy(&invalid); rejected();
|
|
boot(); legacy(&identity); fail_mac = true; rejected();
|
|
boot(); alternate_mac = true;
|
|
assert(generate_all(&invalid, 1) == ESP_OK);
|
|
alternate_mac = false; legacy(&invalid); rejected();
|
|
group("real signature, mismatched private key and device identity rejection");
|
|
|
|
for (size_t i = 0; i < 5; ++i) {
|
|
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
|
|
fault = faults[i]; rejected(); assert(writes == 0);
|
|
}
|
|
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
|
|
fault = SHORT_READ; rejected(); assert(writes == 0);
|
|
group("stored v2 read failures remain closed without writes");
|
|
|
|
for (int kind = 0; kind < 3; ++kind) {
|
|
boot(); stored_size = 0;
|
|
fail_rng = kind == 0; fail_mutex = kind == 1; fail_mac = kind == 2;
|
|
rejected(); assert(writes == 0);
|
|
}
|
|
for (size_t i = 5; i < sizeof(faults)/sizeof(faults[0]); ++i) {
|
|
boot(); stored_size = 0; fault = faults[i]; rejected();
|
|
}
|
|
group("fresh entropy/mutex/MAC/persistence failures do not publish");
|
|
|
|
boot(); legacy(&identity); assert(web_security_init(NULL) == ESP_OK);
|
|
expected_live = s_material;
|
|
for (int operation = 0; operation < 2; ++operation) {
|
|
for (int f = OPEN_RW; f <= COMMIT; ++f) {
|
|
if (f != OPEN_RW && f != SET && f != COMMIT) continue;
|
|
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
|
|
fault = f;
|
|
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) != ESP_OK);
|
|
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
|
|
assert(!memcmp(before, stored, sizeof(before)));
|
|
}
|
|
fault = 0; fail_rng = true;
|
|
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) != ESP_OK);
|
|
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
|
|
fail_rng = false;
|
|
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) == ESP_OK);
|
|
assert(s_material.generation == expected_live.generation + 1);
|
|
assert(memcmp(s_material.certificate_fingerprint, expected_live.certificate_fingerprint, 32));
|
|
assert(validate_blob(&s_material) == ESP_OK);
|
|
expected_live = s_material;
|
|
}
|
|
group("rotation/reset transactional failures, identity change and generation increment");
|
|
|
|
for (int kind = 0; kind < 3; ++kind) {
|
|
boot(); legacy(&identity);
|
|
if (kind == 0) stored_size = 0;
|
|
if (kind == 1) stored[0] = 99;
|
|
assert(web_security_reset_all() == ESP_OK);
|
|
assert(s_material_ready && s_material.generation == 1 && stored_size == 1340);
|
|
assert(validate_blob(&s_material) == ESP_OK);
|
|
}
|
|
group("explicit reset replaces missing/unknown/valid storage without prior init");
|
|
|
|
for (int kind = 0; kind < 3; ++kind) {
|
|
for (size_t i = 5; i < sizeof(faults)/sizeof(faults[0]); ++i) {
|
|
boot(); legacy(&identity);
|
|
if (kind == 0) stored_size = 0;
|
|
if (kind == 1) stored[0] = 99;
|
|
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
|
|
size_t size = stored_size;
|
|
fault = faults[i];
|
|
assert(web_security_reset_all() != ESP_OK);
|
|
assert(!s_material_ready && stored_size == size);
|
|
assert(!memcmp(stored, before, sizeof(before)));
|
|
assert(bytes_are_zero((uint8_t *)&s_material, sizeof(s_material)));
|
|
}
|
|
}
|
|
group("uninitialized reset persistence failures preserve missing/invalid/valid storage");
|
|
|
|
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
|
|
assert(web_security_init(NULL) == ESP_OK);
|
|
|
|
s_material.generation = UINT32_MAX; expected_live = s_material;
|
|
int old_writes = writes;
|
|
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
|
|
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
|
|
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)) && writes == old_writes);
|
|
boot(); identity.generation = UINT32_MAX; legacy(&identity);
|
|
assert(web_security_init(&result) == ESP_OK && s_material.generation == UINT32_MAX);
|
|
group("generation exhaustion rejects mutations but preserves migration identity");
|
|
boot(); legacy(&identity);
|
|
assert(web_security_copy_tls_material(NULL, 0, NULL, NULL, 0, &kn) == ESP_ERR_INVALID_ARG);
|
|
assert(web_security_copy_tls_material(NULL, 1, &cn, NULL, 0, &kn) == ESP_ERR_INVALID_ARG);
|
|
assert(web_security_copy_tls_material(NULL, 0, &cn, NULL, 0, &kn) == ESP_ERR_INVALID_STATE);
|
|
assert(web_security_get_certificate_metadata(NULL) == ESP_ERR_INVALID_ARG);
|
|
assert(web_security_get_certificate_metadata(&metadata) == ESP_ERR_INVALID_STATE);
|
|
group("public invalid argument/unavailable contracts");
|
|
printf("PASS %d production security groups\n", groups);
|
|
return 0;
|
|
}
|