Remove Legacy Credential Bootstrap Paths

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.
This commit is contained in:
2026-09-08 19:09:26 +02:00
parent 82f21d6116
commit ac80863d80
26 changed files with 1013 additions and 583 deletions
+2 -2
View File
@@ -538,7 +538,7 @@ static bool remote_command_allowed(const admin_request_t *request)
/* Empty input is handled quietly by esp_console_run(), not UART0 policy. */
bool allowed = true;
if (argc >= 2U && strcmp(argv[0], "user") == 0 &&
(strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) {
strcmp(argv[1], "recover") == 0) {
allowed = false;
}
/* Temporary browser policy until lifecycle acknowledgements/revocation are
@@ -716,7 +716,7 @@ static void worker_task(void *context)
} else if (active) {
(void)worker_write(&request.token,
request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB
? "Command is unavailable from the web console; use UART0 or SSH where permitted. Bootstrap/recovery require UART0.\r\n"
? "Command is unavailable from the web console; use UART0 or SSH where permitted. Recovery requires UART0.\r\n"
: "Command is restricted to physical UART0.\r\n");
}
current = session_is_current(&request.token, &request.principal);
-6
View File
@@ -126,8 +126,6 @@ static const char *const s_completion_candidates[] = {
"user status",
"user list",
"user show",
"user bootstrap",
"user bootstrap --generate",
"user recover --force",
"user add",
"user delete",
@@ -188,10 +186,6 @@ static const char *const s_completion_candidates[] = {
"web diagnostics disable",
"web diagnostics show",
"web diagnostics clear",
"web credentials",
"web credentials show",
"web credentials rotate",
"web credentials rotate --force",
"web certificate",
"web certificate info",
"web certificate rotate",
+10 -27
View File
@@ -1,5 +1,3 @@
#include <string.h>
#include "driver/uart.h"
#include "admin_ssh_console.h"
#include "console_completion.h"
@@ -132,29 +130,16 @@ void app_main(void)
"HTTPS security material unavailable (%s); use UART0 'web reset --force' to replace it",
esp_err_to_name(web_security_error));
} else {
ESP_LOGI(TAG, "Using %s HTTPS identity and legacy recovery credential",
web_security_source == WEB_SECURITY_LOAD_STORED ? "stored" : "newly generated");
ESP_LOGI(TAG, "Using %s HTTPS identity",
web_security_source == WEB_SECURITY_LOAD_STORED
? "stored"
: (web_security_source == WEB_SECURITY_LOAD_MIGRATED_V1
? "migrated v1"
: "newly generated"));
}
user_database_load_result_t user_database_source = USER_DATABASE_LOAD_EMPTY;
web_security_credentials_t legacy_credentials;
memset(&legacy_credentials, 0, sizeof(legacy_credentials));
user_database_legacy_credentials_t legacy = {0};
const user_database_legacy_credentials_t *legacy_pointer = NULL;
if (web_security_error == ESP_OK &&
web_security_show_credentials(&legacy_credentials) == ESP_OK) {
legacy = (user_database_legacy_credentials_t){
.username = (const uint8_t *)legacy_credentials.username,
.username_length = legacy_credentials.username_length,
.password = (const uint8_t *)legacy_credentials.password,
.password_length = legacy_credentials.password_length,
};
legacy_pointer = &legacy;
}
esp_err_t user_database_error =
user_database_init(legacy_pointer, &user_database_source);
secure_wipe(&legacy_credentials, sizeof(legacy_credentials));
secure_wipe(&legacy, sizeof(legacy));
esp_err_t user_database_error = user_database_init(&user_database_source);
if (user_database_error != ESP_OK) {
ESP_LOGE(TAG, "User database unavailable: %s; HTTPS and SSH authentication will fail closed; use UART0 'user recover --force'",
esp_err_to_name(user_database_error));
@@ -162,9 +147,7 @@ void app_main(void)
ESP_LOGI(TAG, "Using %s user database",
user_database_source == USER_DATABASE_LOAD_STORED
? "stored"
: (user_database_source == USER_DATABASE_LOAD_MIGRATED_LEGACY
? "newly migrated user-level"
: "new empty"));
: "new empty");
}
esp_err_t web_runtime_error = web_server_init();
@@ -267,8 +250,8 @@ void app_main(void)
ESP_LOGI(TAG, "Authenticated HTTPS listening on TCP port 443");
}
}
if (wifi_error == ESP_OK && web_security_error == ESP_OK &&
ssh_security_error == ESP_OK && ssh_runtime_error == ESP_OK) {
if (wifi_error == ESP_OK && ssh_security_error == ESP_OK &&
ssh_runtime_error == ESP_OK) {
esp_err_t start_error = ssh_transport_start();
if (start_error != ESP_OK) {
ESP_LOGE(TAG, "SSH startup failed: %s; UART0 recovery remains available",
+8 -62
View File
@@ -15,7 +15,6 @@
#include "secure_random.h"
#include "ssh_transport.h"
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
@@ -28,7 +27,6 @@ static void print_usage(void)
printf("Usage:\n");
printf(" user status|list\n");
printf(" user show <username>\n");
printf(" user bootstrap [--generate]\n");
printf(" user recover --force\n");
printf(" user add <username> <user|admin> [--generate]\n");
printf(" user delete <username> --force\n");
@@ -100,12 +98,11 @@ static int show_users(const char *selected)
return 1;
}
if (selected == NULL) {
printf("User database: generation=%lu users=%u/%u admins=%u bootstrapped=%s\n",
printf("User database: generation=%lu users=%u/%u admins=%u\n",
(unsigned long)s_user_snapshot.generation,
(unsigned int)s_user_snapshot.user_count,
USER_DATABASE_MAX_USERS,
(unsigned int)s_user_snapshot.admin_count,
s_user_snapshot.admin_bootstrapped ? "yes" : "no");
(unsigned int)s_user_snapshot.admin_count);
}
bool found = false;
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
@@ -123,8 +120,8 @@ static int show_users(const char *selected)
printf("User '%s' not found.\n", selected);
return 1;
}
if (!s_user_snapshot.admin_bootstrapped) {
printf("Administrative network access is not bootstrapped; use 'user bootstrap'.\n");
if (s_user_snapshot.admin_count == 0U) {
printf("No administrators; use 'user add <username> admin' on UART0.\n");
}
return 0;
}
@@ -170,52 +167,13 @@ static void show_generated_password(const char *username,
static int recover_database(void)
{
web_security_credentials_t credentials;
memset(&credentials, 0, sizeof(credentials));
esp_err_t error = web_security_show_credentials(&credentials);
if (error == ESP_OK) {
const user_database_legacy_credentials_t legacy = {
.username = (const uint8_t *)credentials.username,
.username_length = credentials.username_length,
.password = (const uint8_t *)credentials.password,
.password_length = credentials.password_length,
};
error = user_database_recover_from_legacy(&legacy);
}
secure_wipe(&credentials, sizeof(credentials));
esp_err_t error = user_database_recover_empty();
if (error != ESP_OK) {
printf("Could not recover user database: %s\n", esp_err_to_name(error));
return 1;
}
printf("User database replaced from the current legacy network credential.\n");
printf("The imported account has role user; run 'user bootstrap' to establish an administrator.\n");
return 0;
}
static int bootstrap(bool generated)
{
esp_err_t error;
if (generated) {
user_database_generated_password_t password;
error = user_database_bootstrap_admin_generated(&password);
if (error == ESP_OK) {
show_generated_password("admin", &password);
}
} else {
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
size_t password_length = 0U;
error = read_password(password, &password_length);
if (error == ESP_OK) {
error = user_database_bootstrap_admin(password, password_length);
}
secure_wipe(password, sizeof(password));
}
if (error != ESP_OK) {
printf("Could not bootstrap administrator: %s\n", esp_err_to_name(error));
return 1;
}
revoke_user_network_sessions("admin");
printf("Administrator account bootstrapped. Role-aware HTTPS and SSH authentication is active.\n");
printf("User database rebuilt empty; no credentials imported.\n");
printf("Use 'user add <username> admin' on UART0 to create an administrator.\n");
return 0;
}
@@ -422,18 +380,6 @@ static int command_user_inner(int argc, char **argv)
}
return recover_database();
}
if ((argc == 2 || argc == 3) && strcmp(argv[1], "bootstrap") == 0) {
bool generated = argc == 3 && strcmp(argv[2], "--generate") == 0;
if (argc == 3 && !generated) {
print_usage();
return 1;
}
if (remote) {
printf("Administrator bootstrap is restricted to physical UART0.\n");
return 1;
}
return bootstrap(generated);
}
if ((argc == 4 || argc == 5) && strcmp(argv[1], "add") == 0) {
bool generated = argc == 5 && strcmp(argv[4], "--generate") == 0;
if (argc == 5 && !generated) {
@@ -449,7 +395,7 @@ static int command_user_inner(int argc, char **argv)
error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
}
if (error != ESP_OK) {
printf("Could not delete user (the migrated or final admin is protected): %s\n",
printf("Could not delete user (the final admin is protected): %s\n",
esp_err_to_name(error));
return 1;
}
+10 -161
View File
@@ -25,7 +25,6 @@
static const uint8_t s_generated_alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
static const uint8_t s_admin_username[] = "admin";
static const uint8_t s_ed25519_type[] = "ssh-ed25519";
static const uint8_t s_ecdsa_type[] = "ecdsa-sha2-nistp256";
static const uint8_t s_ecdsa_curve[] = "nistp256";
@@ -58,7 +57,8 @@ typedef struct {
uint32_t version;
uint32_t size;
uint32_t generation;
uint8_t admin_bootstrapped;
/* Retain the v1 wire byte/layout; derived by recount, never policy state. */
uint8_t v1_admin_marker;
uint8_t user_count;
uint8_t admin_count;
uint8_t reserved;
@@ -351,7 +351,7 @@ static esp_err_t validate_database(const stored_database_t *database)
{
if (database->version != USER_DATABASE_SCHEMA_VERSION ||
database->size != sizeof(*database) || database->generation == 0U ||
database->admin_bootstrapped > 1U || database->reserved != 0U) {
database->v1_admin_marker > 1U || database->reserved != 0U) {
return ESP_ERR_INVALID_VERSION;
}
uint8_t users = 0U;
@@ -428,7 +428,7 @@ static esp_err_t validate_database(const stored_database_t *database)
}
}
if (users != database->user_count || admins != database->admin_count ||
(database->admin_bootstrapped != 0U) != (admins > 0U)) {
(database->v1_admin_marker != 0U) != (admins > 0U)) {
return ESP_ERR_INVALID_RESPONSE;
}
return ESP_OK;
@@ -446,6 +446,7 @@ static void recount(stored_database_t *database)
}
}
}
database->v1_admin_marker = database->admin_count > 0U ? 1U : 0U;
}
static esp_err_t next_generation(uint32_t *generation)
@@ -518,58 +519,7 @@ static esp_err_t initialize_user(stored_user_t *user,
return error;
}
static bool legacy_credentials_valid(const user_database_legacy_credentials_t *legacy)
{
return legacy != NULL && legacy->username != NULL && legacy->password != NULL &&
user_database_username_valid(legacy->username, legacy->username_length) &&
user_database_password_valid(legacy->password, legacy->password_length);
}
static esp_err_t synchronize_legacy_locked(
const user_database_legacy_credentials_t *legacy, bool *synchronized)
{
*synchronized = false;
if (s_database.admin_bootstrapped != 0U) {
return ESP_OK;
}
int index = find_user(&s_database, legacy->username, legacy->username_length);
if (index < 0) {
return ESP_OK;
}
const stored_user_t *stored = &s_database.users[index];
uint8_t derived[USER_DATABASE_PASSWORD_HASH_LENGTH] = {0};
esp_err_t error = derive_password(legacy->password, legacy->password_length,
stored->password_salt,
stored->password_iterations, derived);
bool already_current = error == ESP_OK &&
constant_time_equal(derived, stored->password_hash,
sizeof(derived));
secure_wipe(derived, sizeof(derived));
if (error != ESP_OK || already_current) {
*synchronized = already_current;
return error;
}
*s_candidate = s_database;
stored_user_t *candidate_user = &s_candidate->users[index];
error = set_record_password(candidate_user, legacy->password,
legacy->password_length);
if (error == ESP_OK) {
error = next_generation(&candidate_user->auth_generation);
}
if (error == ESP_OK) {
error = commit_candidate_locked();
} else {
discard_candidate();
}
*synchronized = error == ESP_OK;
return error;
}
esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
user_database_load_result_t *load_result)
esp_err_t user_database_init(user_database_load_result_t *load_result)
{
if (load_result == NULL || s_mutex != NULL) {
return ESP_ERR_INVALID_ARG;
@@ -609,10 +559,6 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
if (error == ESP_OK && !storage_missing) {
error = validate_database(&s_database);
}
if (error == ESP_OK && !storage_missing && legacy_credentials_valid(legacy)) {
bool synchronized = false;
error = synchronize_legacy_locked(legacy, &synchronized);
}
if (error == ESP_OK && !storage_missing) {
error = initialize_dummy_verifier();
if (error == ESP_OK) {
@@ -630,7 +576,7 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
goto init_failed;
}
if (!storage_missing || !legacy_credentials_valid(legacy)) {
if (!storage_missing) {
error = ESP_ERR_INVALID_STATE;
goto init_failed;
}
@@ -639,13 +585,6 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
s_database.version = USER_DATABASE_SCHEMA_VERSION;
s_database.size = sizeof(s_database);
s_database.generation = 1U;
error = initialize_user(&s_database.users[0], legacy->username,
legacy->username_length, USER_ROLE_USER,
legacy->password, legacy->password_length);
if (error != ESP_OK) {
goto init_failed;
}
*load_result = USER_DATABASE_LOAD_MIGRATED_LEGACY;
recount(&s_database);
*s_candidate = s_database;
error = commit_candidate_locked();
@@ -669,28 +608,8 @@ init_failed:
return error;
}
esp_err_t user_database_sync_legacy_credentials(
const user_database_legacy_credentials_t *legacy, bool *synchronized)
esp_err_t user_database_recover_empty(void)
{
if (synchronized == NULL || !legacy_credentials_valid(legacy)) {
return ESP_ERR_INVALID_ARG;
}
*synchronized = false;
if (!s_initialized || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
esp_err_t error = synchronize_legacy_locked(legacy, synchronized);
xSemaphoreGive(s_mutex);
return error;
}
esp_err_t user_database_recover_from_legacy(
const user_database_legacy_credentials_t *legacy)
{
if (!legacy_credentials_valid(legacy)) {
return ESP_ERR_INVALID_ARG;
}
if (s_initialized || s_mutex != NULL) {
return ESP_ERR_INVALID_STATE;
}
@@ -715,14 +634,7 @@ esp_err_t user_database_recover_from_legacy(
s_candidate->version = USER_DATABASE_SCHEMA_VERSION;
s_candidate->size = sizeof(*s_candidate);
s_candidate->generation = 1U;
error = initialize_user(&s_candidate->users[0], legacy->username,
legacy->username_length, USER_ROLE_USER,
legacy->password, legacy->password_length);
if (error == ESP_OK) {
error = commit_candidate_locked();
} else {
discard_candidate();
}
error = commit_candidate_locked();
if (error == ESP_OK) {
error = initialize_dummy_verifier();
}
@@ -752,7 +664,6 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot)
memset(snapshot, 0, sizeof(*snapshot));
xSemaphoreTake(s_mutex, portMAX_DELAY);
snapshot->initialized = true;
snapshot->admin_bootstrapped = s_database.admin_bootstrapped != 0U;
snapshot->generation = s_database.generation;
snapshot->user_count = s_database.user_count;
snapshot->admin_count = s_database.admin_count;
@@ -972,9 +883,6 @@ static esp_err_t create_locked(const uint8_t *username, size_t username_length,
*s_candidate = s_database;
esp_err_t error = initialize_user(&s_candidate->users[free_index], username,
username_length, role, password, password_length);
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
s_candidate->admin_bootstrapped = 1U;
}
if (error == ESP_OK) {
return commit_candidate_locked();
}
@@ -1017,56 +925,6 @@ esp_err_t user_database_create_generated(
return error;
}
esp_err_t user_database_bootstrap_admin(const uint8_t *password,
size_t password_length)
{
if (!s_initialized || s_mutex == NULL ||
!user_database_password_valid(password, password_length)) {
return ESP_ERR_INVALID_ARG;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (s_database.admin_bootstrapped != 0U) {
xSemaphoreGive(s_mutex);
return ESP_ERR_INVALID_STATE;
}
int index = find_user(&s_database, s_admin_username, sizeof(s_admin_username) - 1U);
esp_err_t error;
if (index < 0) {
error = create_locked(s_admin_username, sizeof(s_admin_username) - 1U,
USER_ROLE_ADMIN, password, password_length);
} else {
*s_candidate = s_database;
stored_user_t *user = &s_candidate->users[index];
error = set_record_password(user, password, password_length);
if (error == ESP_OK) {
user->role = USER_ROLE_ADMIN;
error = next_generation(&user->auth_generation);
}
if (error == ESP_OK) {
s_candidate->admin_bootstrapped = 1U;
error = commit_candidate_locked();
} else {
discard_candidate();
}
}
xSemaphoreGive(s_mutex);
return error;
}
esp_err_t user_database_bootstrap_admin_generated(
user_database_generated_password_t *generated_password)
{
esp_err_t error = user_database_generate_password_value(generated_password);
if (error == ESP_OK) {
error = user_database_bootstrap_admin(generated_password->password,
generated_password->password_length);
}
if (error != ESP_OK && generated_password != NULL) {
secure_wipe(generated_password, sizeof(*generated_password));
}
return error;
}
static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_length,
int *index)
{
@@ -1141,13 +999,7 @@ static esp_err_t delete_user(const uint8_t *username, size_t username_length,
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
const stored_user_t *user = &s_database.users[index];
bool protected_migrated_admin =
s_database.admin_bootstrapped == 0U &&
user->username_length == sizeof(s_admin_username) - 1U &&
memcmp(user->username, s_admin_username,
sizeof(s_admin_username) - 1U) == 0;
if (protected_migrated_admin ||
(user->role == USER_ROLE_ADMIN && s_database.admin_count <= 1U)) {
if (user->role == USER_ROLE_ADMIN && s_database.admin_count <= 1U) {
error = ESP_ERR_INVALID_STATE;
discard_candidate();
} else {
@@ -1181,9 +1033,6 @@ static esp_err_t set_role(const uint8_t *username, size_t username_length,
} else {
user->role = (uint8_t)role;
error = next_generation(&user->auth_generation);
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
s_candidate->admin_bootstrapped = 1U;
}
if (error == ESP_OK) {
error = commit_candidate_locked();
} else {
+6 -25
View File
@@ -38,17 +38,9 @@ typedef enum {
typedef enum {
USER_DATABASE_LOAD_STORED = 0,
USER_DATABASE_LOAD_MIGRATED_LEGACY,
USER_DATABASE_LOAD_EMPTY,
} user_database_load_result_t;
typedef struct {
const uint8_t *username;
size_t username_length;
const uint8_t *password;
size_t password_length;
} user_database_legacy_credentials_t;
typedef struct {
uint32_t user_id;
uint32_t auth_generation;
@@ -84,25 +76,18 @@ typedef struct {
typedef struct {
bool initialized;
bool admin_bootstrapped;
uint32_t generation;
uint8_t user_count;
uint8_t admin_count;
user_database_user_snapshot_t users[USER_DATABASE_MAX_USERS];
} user_database_snapshot_t;
esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
user_database_load_result_t *load_result);
/*
* Before the first administrator is established, keep the migrated account in
* sync with the legacy recovery credential. Once bootstrapped, that credential
* remains independent and no longer authenticates Phase 8B network services.
*/
esp_err_t user_database_sync_legacy_credentials(
const user_database_legacy_credentials_t *legacy, bool *synchronized);
/* Explicit UART0 recovery: replace unavailable user storage with one legacy user. */
esp_err_t user_database_recover_from_legacy(
const user_database_legacy_credentials_t *legacy);
/* Missing storage is persisted empty; valid v1 records load unchanged.
* Corrupt/unsupported storage fails closed and is never automatically replaced. */
esp_err_t user_database_init(user_database_load_result_t *load_result);
/* Explicit UART0 recovery only; refuses an initialized database. No credentials
* are imported or created. Caller enforces physical-console authorization. */
esp_err_t user_database_recover_empty(void);
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot);
/* Compact secret-free list, zero-wait mutex acquisition; no key material. */
@@ -149,10 +134,6 @@ esp_err_t user_database_authorize_ssh_public_key(
esp_err_t user_database_principal_is_current(const user_principal_t *principal,
bool *current);
esp_err_t user_database_bootstrap_admin(const uint8_t *password,
size_t password_length);
esp_err_t user_database_bootstrap_admin_generated(
user_database_generated_password_t *generated_password);
esp_err_t user_database_create(const uint8_t *username, size_t username_length,
user_role_t role,
const uint8_t *password, size_t password_length);
+6 -101
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* UART0 HTTPS lifecycle, legacy recovery credential, and certificate commands. */
/* HTTPS lifecycle and TLS certificate commands. */
#include "web_console.h"
#include "admin_ssh_console.h"
@@ -9,8 +9,6 @@
#include <string.h>
#include "esp_console.h"
#include "secure_random.h"
#include "ssh_transport.h"
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
@@ -26,11 +24,9 @@ static void print_usage(void)
printf(" web status|start|stop\n");
printf(" web counters|clear-counters\n");
printf(" web diagnostics enable|disable|show|clear\n");
printf(" web credentials show\n");
printf(" web credentials rotate --force\n");
printf(" web certificate info\n");
printf(" web certificate rotate --force\n");
printf(" web reset --force\n");
printf(" web reset --force (TLS certificate and private key only)\n");
}
static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH])
@@ -207,25 +203,6 @@ static int show_counters(void)
return 0;
}
static int show_credentials(void)
{
web_security_credentials_t credentials;
esp_err_t error = web_security_show_credentials(&credentials);
if (error != ESP_OK) {
printf("Could not read web credentials: %s\n", esp_err_to_name(error));
return 1;
}
printf("Username: %.*s\n", (int)credentials.username_length,
credentials.username);
printf("Password: %.*s\n", (int)credentials.password_length,
credentials.password);
printf("Phase 8B uses the user database for HTTPS and SSH authentication.\n");
printf("This legacy credential is retained only for migration and physical recovery.\n");
secure_wipe(&credentials, sizeof(credentials));
return 0;
}
static int show_certificate(void)
{
web_security_certificate_metadata_t metadata;
@@ -276,60 +253,6 @@ static int restart_if_running(bool was_running)
return 0;
}
static void synchronize_migrated_user(
const web_security_credentials_t *credentials)
{
const user_database_legacy_credentials_t legacy = {
.username = (const uint8_t *)credentials->username,
.username_length = credentials->username_length,
.password = (const uint8_t *)credentials->password,
.password_length = credentials->password_length,
};
bool synchronized = false;
esp_err_t error = user_database_sync_legacy_credentials(&legacy, &synchronized);
if (error != ESP_OK) {
printf("Warning: migrated user synchronization failed: %s. Boot will retry a valid stored database; otherwise use 'user recover --force'.\n",
esp_err_to_name(error));
return;
}
if (synchronized) {
(void)web_serial_transport_revoke_user(
(const uint8_t *)credentials->username,
credentials->username_length);
(void)ssh_transport_revoke_user(
(const uint8_t *)credentials->username,
credentials->username_length);
printf("The pre-bootstrap migrated user credential was synchronized.\n");
return;
}
user_database_snapshot_t snapshot;
if (user_database_get_snapshot(&snapshot) == ESP_OK &&
snapshot.admin_bootstrapped) {
printf("This legacy recovery credential is separate from role-based user passwords.\n");
} else {
printf("Warning: no matching pre-bootstrap migrated user was synchronized; establish an administrator with 'user bootstrap'.\n");
}
}
static int rotate_credentials(void)
{
web_security_credentials_t credentials;
esp_err_t error = web_security_rotate_credentials(&credentials);
if (error != ESP_OK) {
printf("Could not rotate web credentials: %s\n", esp_err_to_name(error));
return 1;
}
synchronize_migrated_user(&credentials);
printf("Legacy migration/recovery credential rotated and persisted.\n");
printf("Username: %.*s\nPassword: %.*s\n",
(int)credentials.username_length, credentials.username,
(int)credentials.password_length, credentials.password);
secure_wipe(&credentials, sizeof(credentials));
return 0;
}
static int rotate_certificate(void)
{
web_server_snapshot_t snapshot;
@@ -351,19 +274,13 @@ static int reset_material(void)
{
web_server_snapshot_t snapshot;
bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running;
web_security_credentials_t credentials;
esp_err_t error = web_security_reset_all(&credentials);
esp_err_t error = web_security_reset_all();
if (error != ESP_OK) {
printf("Could not reset web security material: %s\n", esp_err_to_name(error));
return 1;
}
synchronize_migrated_user(&credentials);
printf("Legacy recovery credential, HTTPS certificate, and HTTPS private key replaced and persisted.\n");
printf("Username: %.*s\nPassword: %.*s\n",
(int)credentials.username_length, credentials.username,
(int)credentials.password_length, credentials.password);
secure_wipe(&credentials, sizeof(credentials));
printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n");
if (was_running) {
return restart_if_running(true);
}
@@ -434,18 +351,6 @@ static int command_web(int argc, char **argv)
printf("HTTPS and WebSocket counters cleared.\n");
return 0;
}
if (argc == 3 && strcmp(argv[1], "credentials") == 0 &&
strcmp(argv[2], "show") == 0) {
return show_credentials();
}
if (strcmp(argv[1], "credentials") == 0 && argc >= 3 &&
strcmp(argv[2], "rotate") == 0) {
if (!force_is_present(argc, argv, 4)) {
printf("Credential rotation requires: web credentials rotate --force\n");
return 1;
}
return rotate_credentials();
}
if (argc == 3 && strcmp(argv[1], "certificate") == 0 &&
strcmp(argv[2], "info") == 0) {
return show_certificate();
@@ -470,7 +375,7 @@ static int command_web(int argc, char **argv)
}
if (strcmp(argv[1], "reset") == 0) {
if (!force_is_present(argc, argv, 3)) {
printf("Full material replacement requires: web reset --force\n");
printf("TLS-only certificate/private-key replacement requires: web reset --force\n");
return 1;
}
return reset_material();
@@ -484,7 +389,7 @@ esp_err_t web_console_register_commands(void)
{
const esp_console_cmd_t command = {
.command = "web",
.help = "Manage authenticated HTTPS and recover web credentials/certificate",
.help = "Manage authenticated HTTPS and recover TLS certificate/private key",
.hint = NULL,
.func = &command_web,
.argtable = NULL,
+94 -129
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Canonical NVS storage for HTTPS identity and legacy recovery credentials. */
/* Canonical NVS storage for HTTPS identity with private v1 storage compatibility. */
#include "web_security.h"
@@ -20,12 +20,10 @@
#include "nvs.h"
#include "secure_random.h"
#define WEB_SECURITY_SCHEMA_VERSION 1U
#define WEB_SECURITY_BLOB_SIZE 1392U
#define WEB_SECURITY_SCHEMA_VERSION 2U
#define WEB_SECURITY_BLOB_SIZE 1340U
#define LEGACY_BLOB_SIZE 1392U
static const uint8_t s_admin_username[] = "admin";
static const char s_password_alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
static const uint8_t s_ap_ipv4_address[4] = {192U, 168U, 4U, 1U};
typedef struct {
@@ -33,25 +31,20 @@ typedef struct {
uint16_t blob_size;
uint16_t reserved_header;
uint32_t generation;
uint8_t username_length;
uint8_t password_length;
uint16_t private_key_length;
uint16_t certificate_length;
uint16_t reserved_lengths;
uint8_t username[WEB_SECURITY_USERNAME_CAPACITY];
uint8_t password[WEB_SECURITY_PASSWORD_CAPACITY];
uint8_t private_key_der[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY];
uint8_t certificate_der[WEB_SECURITY_CERTIFICATE_DER_CAPACITY];
uint8_t certificate_fingerprint[WEB_SECURITY_SHA256_LENGTH];
uint8_t reserved[12];
} web_security_blob_t;
_Static_assert(offsetof(web_security_blob_t, username) == 20U,
"web security schema offsets changed");
_Static_assert(offsetof(web_security_blob_t, private_key_der) == 68U,
_Static_assert(offsetof(web_security_blob_t, private_key_der) == 16U,
"web security key offset changed");
_Static_assert(offsetof(web_security_blob_t, certificate_der) == 324U,
_Static_assert(offsetof(web_security_blob_t, certificate_der) == 272U,
"web security certificate offset changed");
_Static_assert(offsetof(web_security_blob_t, certificate_fingerprint) == 1296U,
"web security fingerprint offset changed");
_Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE,
"web security schema size changed");
@@ -116,28 +109,6 @@ static esp_err_t build_device_names(char *common_name, size_t common_name_size,
return ESP_OK;
}
static esp_err_t generate_credentials(web_security_blob_t *blob)
{
uint8_t random_bytes[WEB_SECURITY_PASSWORD_LENGTH] = {0};
memset(blob->username, 0, sizeof(blob->username));
memset(blob->password, 0, sizeof(blob->password));
memcpy(blob->username, s_admin_username, sizeof(s_admin_username) - 1U);
blob->username_length = sizeof(s_admin_username) - 1U;
blob->password_length = WEB_SECURITY_PASSWORD_LENGTH;
esp_err_t error = secure_random_fill(random_bytes, sizeof(random_bytes));
if (error == ESP_OK) {
/* Sixty-four symbols consume six random bits exactly, without modulo bias. */
for (size_t i = 0U; i < sizeof(random_bytes); ++i) {
blob->password[i] =
(uint8_t)s_password_alphabet[random_bytes[i] & 0x3fU];
}
}
secure_wipe(random_bytes, sizeof(random_bytes));
return error;
}
static esp_err_t normalize_der(unsigned char *buffer, size_t capacity,
int written, uint16_t *output_length)
{
@@ -396,6 +367,16 @@ cleanup:
return valid;
}
static bool der_is_exact_sequence(const uint8_t *der, size_t size)
{
unsigned char *cursor = (unsigned char *)der;
const unsigned char *end = der + size;
size_t length = 0U;
return mbedtls_asn1_get_tag(&cursor, end, &length,
MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) == 0 &&
length == (size_t)(end - cursor);
}
static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
{
char common_name[WEB_SECURITY_COMMON_NAME_CAPACITY] = {0};
@@ -419,6 +400,10 @@ static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
sizeof(fingerprint))) {
goto cleanup;
}
if (!der_is_exact_sequence(blob->private_key_der, blob->private_key_length) ||
!der_is_exact_sequence(blob->certificate_der, blob->certificate_length)) {
goto cleanup;
}
if (mbedtls_pk_parse_key(&private_key,
blob->private_key_der, blob->private_key_length,
NULL, 0U, secure_random_mbedtls, NULL) != 0 ||
@@ -492,16 +477,7 @@ static esp_err_t validate_blob(const web_security_blob_t *blob)
return ESP_ERR_INVALID_VERSION;
}
if (blob->generation == 0U || blob->reserved_header != 0U ||
blob->reserved_lengths != 0U ||
!bytes_are_zero(blob->reserved, sizeof(blob->reserved)) ||
blob->username_length != sizeof(s_admin_username) - 1U ||
memcmp(blob->username, s_admin_username,
sizeof(s_admin_username) - 1U) != 0 ||
!unused_bytes_are_zero(blob->username, blob->username_length,
sizeof(blob->username)) ||
blob->password_length != WEB_SECURITY_PASSWORD_LENGTH ||
!unused_bytes_are_zero(blob->password, blob->password_length,
sizeof(blob->password)) ||
blob->private_key_length == 0U ||
blob->private_key_length > sizeof(blob->private_key_der) ||
!unused_bytes_are_zero(blob->private_key_der, blob->private_key_length,
@@ -513,16 +489,6 @@ static esp_err_t validate_blob(const web_security_blob_t *blob)
return ESP_ERR_INVALID_RESPONSE;
}
for (size_t i = 0U; i < blob->password_length; ++i) {
const uint8_t value = blob->password[i];
bool valid = (value >= 'A' && value <= 'Z') ||
(value >= 'a' && value <= 'z') ||
(value >= '0' && value <= '9') ||
value == '-' || value == '_';
if (!valid) {
return ESP_ERR_INVALID_RESPONSE;
}
}
return validate_certificate_and_key(blob);
}
@@ -533,10 +499,7 @@ static esp_err_t generate_all(web_security_blob_t *blob, uint32_t generation)
blob->blob_size = WEB_SECURITY_BLOB_SIZE;
blob->generation = generation;
esp_err_t error = generate_credentials(blob);
if (error == ESP_OK) {
error = generate_certificate(blob);
}
esp_err_t error = generate_certificate(blob);
if (error == ESP_OK) {
error = validate_blob(blob);
}
@@ -556,7 +519,7 @@ static esp_err_t save_blob(const web_security_blob_t *blob)
return error;
}
/* NVS append semantics retain the committed predecessor until commit succeeds. */
/* Publish only after commit. NVS replacement is not secure flash erasure. */
error = nvs_set_blob(handle, WEB_SECURITY_NVS_BLOB_KEY,
blob, sizeof(*blob));
if (error == ESP_OK) {
@@ -566,9 +529,57 @@ static esp_err_t save_blob(const web_security_blob_t *blob)
return error;
}
static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
/* The shipped ESP32 v1 wire layout is little-endian, independent of host ABI.
* Credentials exist only in this transient decoder input, never live state. */
static uint16_t legacy_u16(const uint8_t *p)
{
return (uint16_t)p[0] | (uint16_t)((uint16_t)p[1] << 8);
}
static uint32_t legacy_u32(const uint8_t *p)
{
return (uint32_t)legacy_u16(p) | ((uint32_t)legacy_u16(p + 2) << 16);
}
static esp_err_t decode_legacy(const uint8_t raw[LEGACY_BLOB_SIZE],
web_security_blob_t *blob)
{
if (legacy_u32(raw) != 1U || legacy_u16(raw + 4) != LEGACY_BLOB_SIZE) {
return ESP_ERR_INVALID_VERSION;
}
if (legacy_u16(raw + 6) != 0U || legacy_u16(raw + 18) != 0U ||
raw[12] != 5U || raw[13] != 24U ||
memcmp(raw + 20, "admin", 5U) != 0 ||
!bytes_are_zero(raw + 25, 11U) ||
!bytes_are_zero(raw + 60, 8U) ||
!bytes_are_zero(raw + 1380, 12U)) {
return ESP_ERR_INVALID_RESPONSE;
}
for (size_t i = 36U; i < 60U; ++i) {
uint8_t c = raw[i];
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_')) {
return ESP_ERR_INVALID_RESPONSE;
}
}
memset(blob, 0, sizeof(*blob));
blob->schema_version = WEB_SECURITY_SCHEMA_VERSION;
blob->blob_size = WEB_SECURITY_BLOB_SIZE;
blob->generation = legacy_u32(raw + 8);
blob->private_key_length = legacy_u16(raw + 14);
blob->certificate_length = legacy_u16(raw + 16);
memcpy(blob->private_key_der, raw + 68, sizeof(blob->private_key_der));
memcpy(blob->certificate_der, raw + 324, sizeof(blob->certificate_der));
memcpy(blob->certificate_fingerprint, raw + 1348,
sizeof(blob->certificate_fingerprint));
return validate_blob(blob);
}
static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing,
bool *migrated)
{
*missing = false;
*migrated = false;
nvs_handle_t handle;
esp_err_t error = nvs_open(WEB_SECURITY_NVS_NAMESPACE, NVS_READONLY, &handle);
if (error == ESP_ERR_NVS_NOT_FOUND) {
@@ -594,6 +605,21 @@ static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
nvs_close(handle);
return error;
}
if (size == LEGACY_BLOB_SIZE) {
uint8_t legacy[LEGACY_BLOB_SIZE] = {0};
error = nvs_get_blob(handle, WEB_SECURITY_NVS_BLOB_KEY, legacy, &size);
nvs_close(handle);
if (error == ESP_OK) {
error = size == LEGACY_BLOB_SIZE ? decode_legacy(legacy, blob)
: ESP_ERR_INVALID_VERSION;
}
secure_wipe(legacy, sizeof(legacy));
if (error == ESP_OK) {
error = save_blob(blob);
*migrated = error == ESP_OK;
}
return error == ESP_ERR_NVS_INVALID_LENGTH ? ESP_ERR_INVALID_VERSION : error;
}
if (size != sizeof(*blob)) {
nvs_close(handle);
return ESP_ERR_INVALID_VERSION;
@@ -608,7 +634,7 @@ static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
if (error != ESP_OK) {
return error;
}
return validate_blob(blob);
return size == sizeof(*blob) ? validate_blob(blob) : ESP_ERR_INVALID_VERSION;
}
esp_err_t web_security_init(web_security_load_result_t *load_result)
@@ -633,7 +659,8 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
web_security_blob_t candidate;
bool missing = false;
error = load_stored_blob(&candidate, &missing);
bool migrated = false;
error = load_stored_blob(&candidate, &missing, &migrated);
if (error == ESP_OK && missing) {
error = generate_all(&candidate, 1U);
if (error == ESP_OK) {
@@ -644,7 +671,8 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
s_material = candidate;
s_material_ready = true;
s_load_result = missing ? WEB_SECURITY_LOAD_GENERATED_MISSING
: WEB_SECURITY_LOAD_STORED;
: migrated ? WEB_SECURITY_LOAD_MIGRATED_V1
: WEB_SECURITY_LOAD_STORED;
if (load_result != NULL) {
*load_result = s_load_result;
}
@@ -700,35 +728,6 @@ esp_err_t web_security_copy_tls_material(
}
static void copy_credentials_locked(web_security_credentials_t *credentials,
const web_security_blob_t *blob)
{
memset(credentials, 0, sizeof(*credentials));
credentials->username_length = blob->username_length;
credentials->password_length = blob->password_length;
memcpy(credentials->username, blob->username, blob->username_length);
memcpy(credentials->password, blob->password, blob->password_length);
}
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials)
{
if (credentials == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
if (s_material_ready) {
copy_credentials_locked(credentials, &s_material);
error = ESP_OK;
}
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t web_security_get_certificate_metadata(
web_security_certificate_metadata_t *metadata)
{
@@ -782,37 +781,6 @@ static void install_committed_blob(const web_security_blob_t *candidate)
s_load_result = WEB_SECURITY_LOAD_STORED;
}
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials)
{
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
web_security_blob_t candidate;
memset(&candidate, 0, sizeof(candidate));
if (s_material_ready) {
candidate = s_material;
error = increment_generation(&candidate);
if (error == ESP_OK) {
error = generate_credentials(&candidate);
}
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
if (new_credentials != NULL) {
copy_credentials_locked(new_credentials, &s_material);
}
}
}
secure_wipe(&candidate, sizeof(candidate));
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t web_security_rotate_certificate(void)
{
if (s_security_mutex == NULL) {
@@ -841,7 +809,7 @@ esp_err_t web_security_rotate_certificate(void)
return error;
}
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials)
esp_err_t web_security_reset_all(void)
{
esp_err_t error = secure_random_init();
if (error != ESP_OK) {
@@ -869,9 +837,6 @@ esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials)
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
if (new_credentials != NULL) {
copy_credentials_locked(new_credentials, &s_material);
}
}
secure_wipe(&candidate, sizeof(candidate));
xSemaphoreGive(s_security_mutex);
+14 -20
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Persistent HTTPS identity and legacy migration/recovery credentials. */
/* Persistent HTTPS identity; authentication belongs to the user database. */
#pragma once
@@ -16,9 +16,7 @@ extern "C" {
#define WEB_SECURITY_NVS_NAMESPACE "web_sec"
#define WEB_SECURITY_NVS_BLOB_KEY "material"
#define WEB_SECURITY_USERNAME_CAPACITY 16U
#define WEB_SECURITY_PASSWORD_CAPACITY 32U
#define WEB_SECURITY_PASSWORD_LENGTH 24U
#define WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY 256U
#define WEB_SECURITY_CERTIFICATE_DER_CAPACITY 1024U
#define WEB_SECURITY_SHA256_LENGTH 32U
@@ -31,18 +29,9 @@ extern "C" {
typedef enum {
WEB_SECURITY_LOAD_STORED = 0,
WEB_SECURITY_LOAD_GENERATED_MISSING = 1,
WEB_SECURITY_LOAD_MIGRATED_V1 = 2,
} web_security_load_result_t;
/*
* This intentionally contains a displayable secret. UART callers should call
* secure_wipe() on it immediately after rendering the length-delimited fields.
*/
typedef struct {
size_t username_length;
size_t password_length;
char username[WEB_SECURITY_USERNAME_CAPACITY + 1U];
char password[WEB_SECURITY_PASSWORD_CAPACITY + 1U];
} web_security_credentials_t;
typedef struct {
uint32_t material_generation;
@@ -55,7 +44,11 @@ typedef struct {
} web_security_certificate_metadata_t;
/*
* NVS must already be initialized. Missing material is generated and saved;
* NVS must already be initialized. Missing TLS material is generated and saved.
* Valid v1 material is migrated to certificate-only v2 before publication,
* preserving exact TLS identity and generation. Replacement is logical NVS
* deletion of legacy fields, not secure flash erasure. Migration failure never
* triggers regeneration or fallback overwrite;
* an existing wrong-version blob returns ESP_ERR_INVALID_VERSION, while any
* malformed or cryptographically inconsistent blob returns
* ESP_ERR_INVALID_RESPONSE and is never overwritten. Call before radio startup
@@ -73,17 +66,18 @@ esp_err_t web_security_copy_tls_material(
uint8_t *private_key, size_t private_key_capacity,
size_t *private_key_length);
/* Explicit secret-bearing API intended for a physically attached UART CLI. */
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials);
esp_err_t web_security_get_certificate_metadata(
web_security_certificate_metadata_t *metadata);
/* Mutations become visible only after a complete blob has committed to NVS. */
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials);
esp_err_t web_security_rotate_certificate(void);
/* Explicitly replaces missing, valid, or incompatible stored material. */
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials);
/* TLS ONLY: explicitly replaces missing, valid, or incompatible material.
* Generation increments from live state, or starts at one if unavailable.
* No user database mutation. */
esp_err_t web_security_reset_all(void);
#ifdef __cplusplus
}