Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Build intermediates
build/*
build-root-reset/
!build/TianShanOS.bin
!build/www.bin
!build/ota_data_initial.bin
Expand Down
38 changes: 38 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ else()
set(TIANSHAN_VERSION_BUILD "")
endif()

if(ROOT_PASSWORD_RESET)
if(ROOT_PASSWORD_RESET_VERSION)
string(REGEX MATCH "^([0-9]+)\\.([0-9]+)\\.([0-9]+)$" ROOT_RESET_VERSION_MATCH "${ROOT_PASSWORD_RESET_VERSION}")
if(NOT ROOT_RESET_VERSION_MATCH)
message(FATAL_ERROR "ROOT_PASSWORD_RESET_VERSION must use MAJOR.MINOR.PATCH format: ${ROOT_PASSWORD_RESET_VERSION}")
endif()
set(TIANSHAN_VERSION_MAJOR ${CMAKE_MATCH_1})
set(TIANSHAN_VERSION_MINOR ${CMAKE_MATCH_2})
set(TIANSHAN_VERSION_PATCH ${CMAKE_MATCH_3})
set(TIANSHAN_VERSION_PRERELEASE "")
set(TIANSHAN_VERSION_BUILD "")
endif()
if(NOT ROOT_PASSWORD_RESET_RUN_ID)
string(TIMESTAMP ROOT_PASSWORD_RESET_RUN_ID "%H%M%S")
endif()
string(REGEX REPLACE "[^a-zA-Z0-9.]" "." ROOT_PASSWORD_RESET_RUN_ID "${ROOT_PASSWORD_RESET_RUN_ID}")
string(SUBSTRING "${ROOT_PASSWORD_RESET_RUN_ID}" 0 13 ROOT_PASSWORD_RESET_RUN_ID)

set(TIANSHAN_VERSION_PRERELEASE "root-reset.${ROOT_PASSWORD_RESET_RUN_ID}")
set(TIANSHAN_VERSION_BUILD "")
set(TIANSHAN_VERSION_DISPLAY "${TIANSHAN_VERSION_MAJOR}.${TIANSHAN_VERSION_MINOR}.${TIANSHAN_VERSION_PATCH}-${TIANSHAN_VERSION_PRERELEASE}")
string(LENGTH "${TIANSHAN_VERSION_DISPLAY}" TIANSHAN_VERSION_DISPLAY_LEN)
if(TIANSHAN_VERSION_DISPLAY_LEN GREATER 31)
message(FATAL_ERROR "Root reset PROJECT_VER is too long for esp_app_desc: ${TIANSHAN_VERSION_DISPLAY}")
endif()
endif()

# 基础版本(不含构建元数据)
set(TIANSHAN_VERSION "${TIANSHAN_VERSION_MAJOR}.${TIANSHAN_VERSION_MINOR}.${TIANSHAN_VERSION_PATCH}")
if(TIANSHAN_VERSION_PRERELEASE)
Expand All @@ -81,6 +108,11 @@ set(PARTITION_TABLE_FILE "${CMAKE_CURRENT_SOURCE_DIR}/partitions.csv" CACHE STRI
# 包含 ESP-IDF
include($ENV{IDF_PATH}/tools/cmake/project.cmake)

if(ROOT_PASSWORD_RESET)
idf_build_set_property(MINIMAL_BUILD ON)
idf_build_set_property(ROOT_PASSWORD_RESET ON)
endif()

# 项目定义
project(TianShanOS VERSION ${TIANSHAN_VERSION_MAJOR}.${TIANSHAN_VERSION_MINOR}.${TIANSHAN_VERSION_PATCH})

Expand All @@ -105,6 +137,12 @@ idf_build_set_property(COMPILE_DEFINITIONS
"TIANSHAN_OS_VERSION_FULL=\"${TIANSHAN_VERSION_DISPLAY}\""
APPEND
)
if(ROOT_PASSWORD_RESET)
idf_build_set_property(COMPILE_DEFINITIONS
"ROOT_PASSWORD_RESET_RUN_ID=\"${ROOT_PASSWORD_RESET_RUN_ID}\""
APPEND
)
endif()

# 打印项目信息
message(STATUS "")
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ idf.py -p /dev/ttyACM0 flash monitor
esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \
0x0 bootloader.bin \
0x8000 partition-table.bin \
0x10000 TianshanOS.bin
0x20000 TianShanOS.bin
```

### VS Code 开发
Expand All @@ -139,6 +139,8 @@ esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \
| 文档 | 描述 |
|-----|------|
| [快速入门](docs/QUICK_START.md) | 环境搭建与首次运行 |
| [串口刷机](docs/FLASH_VIA_SERIAL.md) | 串口烧录与分区偏移说明 |
| [Root/Admin 密码恢复固件](docs/ROOT_PASSWORD_RESET_FIRMWARE.md) | root/admin 用户密码恢复固件的构建与刷写流程 |
| [架构设计](docs/ARCHITECTURE_DESIGN.md) | 系统架构与设计决策 |
| [配置系统设计](docs/CONFIG_SYSTEM_DESIGN.md) | 统一配置系统详细设计 |
| [自动化引擎](docs/AUTOMATION_ENGINE.md) | 触发器-条件-动作系统 |
Expand Down
4 changes: 4 additions & 0 deletions components/ts_auth_layout/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
idf_component_register(
INCLUDE_DIRS
"include"
)
50 changes: 50 additions & 0 deletions components/ts_auth_layout/include/ts_auth_layout.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @file ts_auth_layout.h
* @brief Shared on-flash auth credential layout.
*/

#ifndef TS_AUTH_LAYOUT_H
#define TS_AUTH_LAYOUT_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#define TS_AUTH_NVS_NAMESPACE "ts_auth"
#define TS_AUTH_ROOT_CRED_KEY "cred_root"
#define TS_AUTH_ADMIN_CRED_KEY "cred_admin"
#define TS_AUTH_CONFIG_VERSION_KEY "cfg_version"
#define TS_AUTH_DEFAULT_ROOT_PASSWORD "rm01"
#define TS_AUTH_DEFAULT_ADMIN_PASSWORD "rm01"
#define TS_AUTH_CONFIG_VERSION 3

#define TS_AUTH_SALT_LEN 16
#define TS_AUTH_HASH_LEN 32

typedef struct {
uint8_t salt[TS_AUTH_SALT_LEN];
uint8_t hash[TS_AUTH_HASH_LEN];
bool password_changed;
uint32_t failed_attempts;
uint32_t lockout_until;
} ts_auth_user_credential_t;

#define TS_AUTH_USER_CREDENTIAL_SIZE 60

#if defined(__cplusplus)
static_assert(offsetof(ts_auth_user_credential_t, salt) == 0, "auth salt offset changed");
static_assert(offsetof(ts_auth_user_credential_t, hash) == 16, "auth hash offset changed");
static_assert(offsetof(ts_auth_user_credential_t, password_changed) == 48, "auth password_changed offset changed");
static_assert(offsetof(ts_auth_user_credential_t, failed_attempts) == 52, "auth failed_attempts offset changed");
static_assert(offsetof(ts_auth_user_credential_t, lockout_until) == 56, "auth lockout_until offset changed");
static_assert(sizeof(ts_auth_user_credential_t) == TS_AUTH_USER_CREDENTIAL_SIZE, "auth credential size changed");
#else
_Static_assert(offsetof(ts_auth_user_credential_t, salt) == 0, "auth salt offset changed");
_Static_assert(offsetof(ts_auth_user_credential_t, hash) == 16, "auth hash offset changed");
_Static_assert(offsetof(ts_auth_user_credential_t, password_changed) == 48, "auth password_changed offset changed");
_Static_assert(offsetof(ts_auth_user_credential_t, failed_attempts) == 52, "auth failed_attempts offset changed");
_Static_assert(offsetof(ts_auth_user_credential_t, lockout_until) == 56, "auth lockout_until offset changed");
_Static_assert(sizeof(ts_auth_user_credential_t) == TS_AUTH_USER_CREDENTIAL_SIZE, "auth credential size changed");
#endif

#endif /* TS_AUTH_LAYOUT_H */
1 change: 1 addition & 0 deletions components/ts_security/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ idf_component_register(
REQUIRES
ts_core
ts_automation
ts_auth_layout
mbedtls
nvs_flash
lwip
Expand Down
52 changes: 21 additions & 31 deletions components/ts_security/src/ts_auth.c
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
*
* 安全设计:
* - 密码哈希仅存储在 NVS,不导出到 SD 卡
* - admin 密码可由 root 会话恢复root 密码遗失需恢复出厂
* - admin 密码可由 root 会话恢复root/admin 均可通过受控恢复固件恢复默认密码
*/

#include "ts_security.h"
#include "ts_auth_layout.h"
#include "ts_crypto.h"
#include "ts_log.h"
#include "nvs_flash.h"
Expand All @@ -20,23 +21,10 @@

#define TAG "ts_auth"

#define NVS_AUTH_NAMESPACE "ts_auth"
#define SALT_LEN 16
#define HASH_LEN 32
#define DEFAULT_PASSWORD_ADMIN "rm01"
#define DEFAULT_PASSWORD_ROOT "rm01"
#define MAX_LOGIN_ATTEMPTS 5
#define LOGIN_LOCKOUT_SEC 300 /* 5 分钟锁定 */
#define AUTH_CONFIG_VERSION 3 /* 增加此版本号可强制重置所有用户密码 */

/* 用户信息结构 */
typedef struct {
uint8_t salt[SALT_LEN];
uint8_t hash[HASH_LEN];
bool password_changed; /* 是否已修改过初始密码 */
uint32_t failed_attempts;
uint32_t lockout_until; /* 锁定截止时间 (秒) */
} user_credential_t;
typedef ts_auth_user_credential_t user_credential_t;

static nvs_handle_t s_auth_nvs;
static bool s_auth_initialized = false;
Expand All @@ -53,14 +41,14 @@ static esp_err_t compute_password_hash(const uint8_t *salt, const char *password
{
/* 拼接 salt + password */
size_t pwd_len = strlen(password);
size_t total_len = SALT_LEN + pwd_len;
size_t total_len = TS_AUTH_SALT_LEN + pwd_len;
uint8_t *buf = malloc(total_len);
if (!buf) return ESP_ERR_NO_MEM;

memcpy(buf, salt, SALT_LEN);
memcpy(buf + SALT_LEN, password, pwd_len);
memcpy(buf, salt, TS_AUTH_SALT_LEN);
memcpy(buf + TS_AUTH_SALT_LEN, password, pwd_len);

esp_err_t ret = ts_crypto_hash(TS_HASH_SHA256, buf, total_len, hash_out, HASH_LEN);
esp_err_t ret = ts_crypto_hash(TS_HASH_SHA256, buf, total_len, hash_out, TS_AUTH_HASH_LEN);

/* 清除敏感数据 */
memset(buf, 0, total_len);
Expand Down Expand Up @@ -114,7 +102,7 @@ static esp_err_t write_user_password_credential(const char *username, const char

user_credential_t cred = {0};

esp_fill_random(cred.salt, SALT_LEN);
esp_fill_random(cred.salt, TS_AUTH_SALT_LEN);

esp_err_t ret = compute_password_hash(cred.salt, password, cred.hash);
if (ret != ESP_OK) return ret;
Expand All @@ -132,7 +120,9 @@ static esp_err_t write_user_password_credential(const char *username, const char
static esp_err_t force_create_user(const char *username, ts_perm_level_t level)
{
/* 根据用户类型使用不同默认密码 */
const char *default_pwd = (level == TS_PERM_ROOT) ? DEFAULT_PASSWORD_ROOT : DEFAULT_PASSWORD_ADMIN;
const char *default_pwd = (level == TS_PERM_ROOT)
? TS_AUTH_DEFAULT_ROOT_PASSWORD
: TS_AUTH_DEFAULT_ADMIN_PASSWORD;
TS_LOGI(TAG, "Creating/resetting user '%s'", username);

return write_user_password_credential(username, default_pwd, false);
Expand Down Expand Up @@ -172,24 +162,24 @@ esp_err_t ts_auth_init(void)
{
if (s_auth_initialized) return ESP_OK;

esp_err_t ret = nvs_open(NVS_AUTH_NAMESPACE, NVS_READWRITE, &s_auth_nvs);
esp_err_t ret = nvs_open(TS_AUTH_NVS_NAMESPACE, NVS_READWRITE, &s_auth_nvs);
if (ret != ESP_OK) {
TS_LOGE(TAG, "Failed to open NVS namespace: %s", esp_err_to_name(ret));
return ret;
}

/* 检查配置版本,版本变化时强制重置所有用户 */
uint8_t stored_version = 0;
nvs_get_u8(s_auth_nvs, "cfg_version", &stored_version);
nvs_get_u8(s_auth_nvs, TS_AUTH_CONFIG_VERSION_KEY, &stored_version);

if (stored_version != AUTH_CONFIG_VERSION) {
if (stored_version != TS_AUTH_CONFIG_VERSION) {
TS_LOGW(TAG, "Auth config version changed (%d -> %d), resetting all users",
stored_version, AUTH_CONFIG_VERSION);
stored_version, TS_AUTH_CONFIG_VERSION);
/* 强制重新创建所有用户 */
force_create_user("admin", TS_PERM_ADMIN);
force_create_user("root", TS_PERM_ROOT);
/* 保存新版本号 */
nvs_set_u8(s_auth_nvs, "cfg_version", AUTH_CONFIG_VERSION);
nvs_set_u8(s_auth_nvs, TS_AUTH_CONFIG_VERSION_KEY, TS_AUTH_CONFIG_VERSION);
nvs_commit(s_auth_nvs);
} else {
/* 初始化默认用户(如果不存在) */
Expand All @@ -198,7 +188,7 @@ esp_err_t ts_auth_init(void)
}

s_auth_initialized = true;
TS_LOGI(TAG, "Auth module initialized (version %d)", AUTH_CONFIG_VERSION);
TS_LOGI(TAG, "Auth module initialized (version %d)", TS_AUTH_CONFIG_VERSION);
return ESP_OK;
}

Expand Down Expand Up @@ -237,13 +227,13 @@ esp_err_t ts_auth_verify_password(const char *username, const char *password,
}

/* 计算输入密码的哈希 */
uint8_t computed_hash[HASH_LEN];
uint8_t computed_hash[TS_AUTH_HASH_LEN];
ret = compute_password_hash(cred.salt, password, computed_hash);
if (ret != ESP_OK) return ret;

/* 比较哈希(恒定时间比较防止时序攻击) */
uint8_t diff = 0;
for (int i = 0; i < HASH_LEN; i++) {
for (int i = 0; i < TS_AUTH_HASH_LEN; i++) {
diff |= computed_hash[i] ^ cred.hash[i];
}

Expand Down Expand Up @@ -418,9 +408,9 @@ esp_err_t ts_auth_reset_password(const char *username)
/* 只允许重置 admin 和 root */
const char *default_pwd;
if (strcmp(username, "admin") == 0) {
default_pwd = DEFAULT_PASSWORD_ADMIN;
default_pwd = TS_AUTH_DEFAULT_ADMIN_PASSWORD;
} else if (strcmp(username, "root") == 0) {
default_pwd = DEFAULT_PASSWORD_ROOT;
default_pwd = TS_AUTH_DEFAULT_ROOT_PASSWORD;
} else {
return ESP_ERR_NOT_FOUND;
}
Expand Down
6 changes: 3 additions & 3 deletions docs/FLASH_VIA_SERIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ idf.py build

构建成功后,可在 `build/` 下看到:

- `TianshanOS.bin` — 应用固件(烧录到 ota_0)
- `TianShanOS.bin` — 应用固件(烧录到 ota_0)
- `bootloader.bin` — 引导程序
- `partition-table.bin` — 分区表
- `www.bin` — Web 资源 SPIFFS 镜像(烧录到 www 分区)
Expand Down Expand Up @@ -117,7 +117,7 @@ idf.py -p <PORT> flash monitor
|------------|----------|------|
| bootloader | 0x0 | build/bootloader/bootloader.bin |
| 分区表 | 0x8000 | build/partition_table/partition-table.bin |
| 应用 ota_0 | 0x20000 | build/TianshanOS.bin |
| 应用 ota_0 | 0x20000 | build/TianShanOS.bin |
| www | 0x6A0000 | build/www.bin |

在 `build` 目录下执行(把 `<PORT>` 换成实际串口):
Expand All @@ -128,7 +128,7 @@ cd build
esptool.py --chip esp32s3 -p <PORT> write_flash \
0x0 bootloader/bootloader.bin \
0x8000 partition_table/partition-table.bin \
0x20000 TianshanOS.bin \
0x20000 TianShanOS.bin \
0x6A0000 www.bin
```

Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
| 文档 | 描述 |
|------|------|
| [快速入门](QUICK_START.md) | 5 分钟快速上手指南:环境准备、编译烧录、首次配置 |
| [串口刷机指南](FLASH_VIA_SERIAL.md) | 串口烧录、手动分区偏移和常见刷机问题 |
| [Root/Admin 密码恢复固件](ROOT_PASSWORD_RESET_FIRMWARE.md) | root/admin 用户密码恢复固件的构建、OTA 和串口使用流程 |
| [命令参考](COMMAND_REFERENCE.md) | CLI 命令完整手册:系统、网络、LED、设备、配置等 |
| [配置指南](CONFIGURATION_GUIDE.md) | 板级配置、GPIO 映射、运行时配置说明 |

Expand Down
64 changes: 64 additions & 0 deletions docs/ROOT_PASSWORD_RESET_FIRMWARE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Root/Admin 密码恢复固件

Root/Admin 密码恢复固件是一个 app-only 临时固件,只修改 NVS 中 `ts_auth/cred_root` 和 `ts_auth/cred_admin`,把 `root` 与 `admin` 密码都恢复为默认值 `rm01`。它不会擦除 NVS,也不会主动修改网络配置、证书、SSH key 或其他数据。

该固件不启动正常系统服务,不启动 WebUI、API、网络或控制台。写入并校验 root/admin 凭据后,它会尝试切回另一个有效 OTA app 分区,等待 3 秒后重启。

恢复固件会先检查 `ts_auth/cfg_version` 是否等于当前认证布局版本。若版本缺失或不匹配,它会停机并拒绝写入任何 credential,避免回到正常固件后触发认证模块的全用户重置流程。

## 构建

```bash
./tools/build_root_reset.sh
```

指定恢复固件基础版本号:

```bash
ROOT_PASSWORD_RESET_VERSION=9.9.9 ./tools/build_root_reset.sh
```

构建产物:

- `build-root-reset/TianShanOS.bin`
- `build-root-reset/TianShanOS-RootReset.bin`

人工刷写和归档时统一使用 `build-root-reset/TianShanOS-RootReset.bin`,避免和普通固件混淆。

## 通过 WebUI 手动 OTA

适用条件:当前设备仍可进入 WebUI,且 OTA 所需认证会话可用。

1. 打开 WebUI 的系统升级页面。
2. 手动上传 `build-root-reset/TianShanOS-RootReset.bin`。
3. 取消勾选“包含 WebUI”,不要上传或刷写 `www.bin`。
4. 开始 OTA,随后通过串口日志确认恢复过程。

如果 OTA 需要认证而现有 session 不可用,不能通过 WebUI 完成,只能走串口流程。

## 通过串口强制启动 ota_0

串口恢复用于 WebUI/OTA 不可用的场景。该流程会清除 OTA 选择信息并把恢复 app 写入 `ota_0`,不会擦 NVS。

这个流程只有在 `ota_1` 保留着要返回的正常固件时,才会自动回到那个正常固件。如果设备原本运行在 `ota_0`,下面的命令会覆盖该正常固件;恢复固件随后会尝试切到 `ota_1`,那里可能是旧固件、测试固件或空分区。无法确认另一个 slot 内容时,串口流程应视为“先恢复 root/admin,再手动刷回正常固件”的救援手段。

```bash
esptool.py --chip esp32s3 -p <PORT> erase_region 0x16000 0x2000
esptool.py --chip esp32s3 -p <PORT> write_flash 0x20000 build-root-reset/TianShanOS-RootReset.bin
```

`0x16000` 是 `otadata` 分区地址,`0x20000` 是 `ota_0` app 分区地址。恢复固件启动后会查找另一个 OTA slot 作为返回目标;如果另一个 slot 没有有效正常固件,或目标版本也是 `root-reset`,它会停机并要求手动刷回正常固件。

## 成功标准

串口日志应出现以下信息:

- NVS 初始化完成。
- 写入 `ts_auth/cred_root`。
- 写入 `ts_auth/cred_admin`。
- `nvs_commit()` 完成。
- root/admin 读回校验通过。
- 打印当前分区、目标分区、目标 project/version。
- 等待 3 秒后重启。

重启回同一认证布局的正常固件后,`root/rm01` 和 `admin/rm01` 都应可登录;网络配置、证书、SSH key 和其他 NVS 配置应保持不变。重复刷入同一个恢复固件应再次成功恢复 root/admin。
Loading
Loading