Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b8469a6
feat(vault): define dual vault format contract for #140
miso-develop Sep 16, 2026
852bd34
feat(vault): add PT2 and AAD2 codec support for #140
miso-develop Sep 16, 2026
3c677c1
feat(vault): bind AEAD to vault format version for #140
miso-develop Sep 16, 2026
4988070
feat(runtime): model automatic lock policy for #140
miso-develop Sep 16, 2026
edfc811
feat(runtime): enforce continuous unlock lifetime policy for #140
miso-develop Sep 16, 2026
d8549f9
feat(runtime): preserve format and policy across rekey for #140
miso-develop Sep 16, 2026
220a914
feat(storage): persist vault formats 1 and 2 in schema 2
miso-develop Sep 16, 2026
0e73e51
fix(runtime): verify plaintext format while listing credentials
miso-develop Sep 16, 2026
eda2903
refactor(provisioning): carry monotonic unlock time to VMK sink
miso-develop Sep 16, 2026
444756f
refactor(provisioning): preserve existing VMK sink interface
miso-develop Sep 16, 2026
b7307c1
feat(provisioning): start unlock lifetime from monotonic runtime
miso-develop Sep 16, 2026
b43c0a3
refactor(provisioning): share explicit and automatic lock boundary
miso-develop Sep 16, 2026
44804c0
feat(provisioning): enforce automatic lock through explicit lock boun…
miso-develop Sep 16, 2026
444f961
test(vault): cover PT2 AAD2 and legacy PT1 compatibility
miso-develop Sep 16, 2026
92db90a
test(runtime): cover format transitions and automatic lock policy
miso-develop Sep 16, 2026
4ee9659
test(runtime): pin automatic lock security invariants
miso-develop Sep 16, 2026
4a7a4c0
test(protocol): pin automatic lock and vault format contract
miso-develop Sep 16, 2026
b3fb394
ci(security): run automatic lock protocol contract
miso-develop Sep 16, 2026
44d18cb
test(protocol): verify post-commit auto-lock ordering
miso-develop Sep 16, 2026
333539c
test(device): follow consolidated unlock-state cleanup boundary
miso-develop Sep 16, 2026
058dc65
build(provisioning): declare esp_timer monotonic dependency
miso-develop Sep 16, 2026
f3903cd
test(device): preserve literal newline contract escapes
miso-develop Sep 16, 2026
0796518
test(device): keep unrelated StickS3 contracts unchanged
miso-develop Sep 16, 2026
579347e
fix(runtime): preserve recovery reset for known-format corruption
miso-develop Sep 16, 2026
f1b0845
fix(runtime): preserve auto-lock origin across VMK rekey boundary
miso-develop Sep 16, 2026
3966556
fix(runtime): keep unlock lifetime through VMK rekey
miso-develop Sep 16, 2026
9c5df91
test(runtime): keep automatic lock origin across VMK rekey
miso-develop Sep 16, 2026
1e85c8c
test(runtime): align rekey boundary contract with fixed lifetime
miso-develop Sep 16, 2026
953a8e3
fix(protocol): lock immediately when rekey reaches deadline
miso-develop Sep 16, 2026
1375cac
test(protocol): enforce due auto-lock after VMK rekey
miso-develop Sep 16, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
run: python3 --version
- name: Verify V1 release security closeout contract
run: python3 -m unittest tests/security_closeout_contract_test.py
- name: Verify automatic lock Protocol-v2 security contract
run: python3 -m unittest tests/automatic_lock_protocol_contract_test.py
- name: Verify test-only screen snapshot production boundary
run: python3 -m unittest tests/screen_snapshot_security_contract_test.py
- name: Test repository security scanner
Expand Down
1 change: 1 addition & 0 deletions firmware/components/m5auth_provisioning/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ idf_component_register(
m5auth_vault
m5auth_vault_runtime
esp_hw_support
esp_timer
json
)
97 changes: 79 additions & 18 deletions firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ bool read_envelope(cJSON* params, vault::VaultEnvelope* envelope) {
vault::VaultEnvelope parsed{};
if (!read_nonnegative_int(cJSON_GetObjectItemCaseSensitive(params, "vault_format_version"), &vault_format) ||
!read_nonnegative_int(cJSON_GetObjectItemCaseSensitive(params, "storage_schema_version"), &storage_schema) ||
vault_format != vault::kVaultFormatVersion ||
(vault_format != vault::kVaultFormatVersion1 &&
vault_format != vault::kVaultFormatVersion2) ||
storage_schema != vault::kTargetStorageSchemaVersion ||
!read_binary(params, "vault_id", &parsed.vault_id) ||
!read_u64_decimal(cJSON_GetObjectItemCaseSensitive(params, "generation"), &generation) ||
Expand Down Expand Up @@ -260,12 +261,25 @@ std::string hello_success(
}

const std::string device_id = registration::device_id_text(registration_snapshot.device_id);
const std::uint16_t persisted_format = runtime_metadata.has_vault
? runtime_metadata.vault_format_version
: vault::kCurrentVaultFormatVersion;
cJSON_AddStringToObject(data, "device", metadata.device);
cJSON_AddStringToObject(data, "device_id", device_id.c_str());
cJSON_AddStringToObject(data, "firmware", metadata.firmware);
cJSON_AddNumberToObject(data, "protocol", session::protocol_v2::kProtocolVersion);
cJSON_AddNumberToObject(data, "storage_schema", vault::kTargetStorageSchemaVersion);
cJSON_AddNumberToObject(data, "vault_format", vault::kVaultFormatVersion);
// Preserve the existing field while also exposing the specification's
// explicit version name for consumers that adopt the dual-format contract.
cJSON_AddNumberToObject(data, "vault_format", persisted_format);
cJSON_AddNumberToObject(data, "vault_format_version", persisted_format);
cJSON* supported_formats = cJSON_AddArrayToObject(data, "supported_vault_formats");
if (supported_formats == nullptr) {
cJSON_Delete(root);
return serialize(nullptr);
}
cJSON_AddItemToArray(supported_formats, cJSON_CreateNumber(vault::kVaultFormatVersion1));
cJSON_AddItemToArray(supported_formats, cJSON_CreateNumber(vault::kVaultFormatVersion2));
cJSON_AddStringToObject(data, "build_commit", metadata.build_commit);
cJSON_AddStringToObject(data, "state", runtime_state_name(runtime_metadata.state));
cJSON_AddBoolToObject(data, "storage_ready", runtime_metadata.schema_ready);
Expand Down Expand Up @@ -355,6 +369,38 @@ void CanonicalProtocolV2Handler::notify_security_boundary() {
if (security_boundary_clear_) security_boundary_clear_();
}

vault_runtime::Status CanonicalProtocolV2Handler::lock_security_boundary() {
session_handler_.disconnect();
vmk_sink_.cancel_pending();
cancel_recovery_reset();
return time_service_.with_secret_boundary([&]() {
vault_runtime::Status result = vault_runtime::Status::kIo;
{
std::lock_guard<std::recursive_mutex> access(runtime_access_mutex_);
result = runtime_.lock();
}
if (result == vault_runtime::Status::kOk) notify_security_boundary();
return result;
});
}

void CanonicalProtocolV2Handler::housekeeping(std::uint64_t now_ms) {
(void)session_handler_.expire(now_ms);
(void)vmk_sink_.expire_pending(now_ms);
if (recovery_reset_active_ && now_ms >= recovery_reset_deadline_ms_) {
cancel_recovery_reset();
}

bool automatic_lock_due = false;
{
std::lock_guard<std::recursive_mutex> access(runtime_access_mutex_);
automatic_lock_due = runtime_.automatic_lock_due(now_ms);
}
if (automatic_lock_due) {
(void)lock_security_boundary();
}
}

RecoveryResetDecision CanonicalProtocolV2Handler::recovery_reset_decision(
vault_runtime::Metadata* runtime_metadata,
registration::Snapshot* registration_snapshot,
Expand Down Expand Up @@ -625,9 +671,18 @@ std::string CanonicalProtocolV2Handler::handle_line(
response = error_response(id, "invalid_request");
} else {
vault_runtime::Status status = vault_runtime::Status::kIo;
bool automatic_lock_due_after_commit = false;
{
std::lock_guard<std::recursive_mutex> access(runtime_access_mutex_);
status = runtime_.update_encrypted_vault(expected_generation, std::move(envelope));
status = runtime_.update_encrypted_vault(
expected_generation,
std::move(envelope),
now_ms,
&automatic_lock_due_after_commit
);
}
if (status == vault_runtime::Status::kOk && automatic_lock_due_after_commit) {
status = lock_security_boundary();
}
response = status == vault_runtime::Status::kOk
? empty_success(id)
Expand All @@ -641,9 +696,26 @@ std::string CanonicalProtocolV2Handler::handle_line(
vmk_sink_.cancel_pending();
response = error_response(id, "invalid_request");
} else {
response = vmk_sink_.install_rekeyed_vault(expected_generation, std::move(envelope), now_ms)
? empty_success(id)
: error_response(id, "invalid_state");
const bool installed = vmk_sink_.install_rekeyed_vault(
expected_generation,
std::move(envelope),
now_ms
);
if (!installed) {
response = error_response(id, "invalid_state");
} else {
bool automatic_lock_due_after_rekey = false;
{
std::lock_guard<std::recursive_mutex> access(runtime_access_mutex_);
automatic_lock_due_after_rekey = runtime_.automatic_lock_due(now_ms);
}
const vault_runtime::Status lock_status = automatic_lock_due_after_rekey
? lock_security_boundary()
: vault_runtime::Status::kOk;
response = lock_status == vault_runtime::Status::kOk
? empty_success(id)
: error_response(id, vault_runtime::status_code(lock_status));
}
}
} else if (operation == "time.status") {
response = time_status_success(id, time_service_.status());
Expand All @@ -658,18 +730,7 @@ std::string CanonicalProtocolV2Handler::handle_line(
: error_response(id, time::sync_result_code(status));
}
} else if (operation == "device.lock") {
session_handler_.disconnect();
vmk_sink_.cancel_pending();
cancel_recovery_reset();
const vault_runtime::Status status = time_service_.with_secret_boundary([&]() {
vault_runtime::Status result = vault_runtime::Status::kIo;
{
std::lock_guard<std::recursive_mutex> access(runtime_access_mutex_);
result = runtime_.lock();
}
if (result == vault_runtime::Status::kOk) notify_security_boundary();
return result;
});
const vault_runtime::Status status = lock_security_boundary();
response = status == vault_runtime::Status::kOk
? empty_success(id)
: error_response(id, vault_runtime::status_code(status));
Expand Down
31 changes: 24 additions & 7 deletions firmware/components/m5auth_provisioning/canonical_v2_state.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
#include <algorithm>
#include <limits>

#ifdef ESP_PLATFORM
#include "esp_timer.h"
#endif

namespace m5auth::provisioning {
namespace {

Expand All @@ -11,6 +15,17 @@ bool all_zero(const Container& value) {
return std::all_of(value.begin(), value.end(), [](std::uint8_t byte) { return byte == 0; });
}

std::uint64_t runtime_monotonic_ms() {
#ifdef ESP_PLATFORM
const std::int64_t microseconds = esp_timer_get_time();
return microseconds <= 0 ? 0 : static_cast<std::uint64_t>(microseconds / 1'000);
#else
// Native protocol tests have no ESP monotonic source. Runtime unit tests pass
// explicit timestamps directly; production firmware always uses esp_timer.
return 0;
#endif
}

class ScopedVmkWipe final {
public:
explicit ScopedVmkWipe(session::Vmk& vmk) : vmk_(vmk) {}
Expand Down Expand Up @@ -43,7 +58,7 @@ bool envelope_matches_pending(
const session::protocol_v2::BeginContext& context,
std::uint64_t generation
) {
return envelope.vault_format_version == vault::kVaultFormatVersion &&
return vault::is_supported_vault_format(envelope.vault_format_version) &&
envelope.storage_schema_version == vault::kTargetStorageSchemaVersion &&
envelope.vault_id == context.vault_id &&
envelope.generation == generation &&
Expand Down Expand Up @@ -144,10 +159,11 @@ bool CanonicalVmkSink::install_vmk(
) {
std::lock_guard<std::recursive_mutex> access(runtime_access_mutex_);
cancel_pending();
const std::uint64_t now_ms = runtime_monotonic_ms();

switch (context.operation) {
case session::protocol_v2::Operation::kTrustedBrowserUnlock:
return runtime_.unlock(vmk) == vault_runtime::Status::kOk;
return runtime_.unlock(vmk, now_ms) == vault_runtime::Status::kOk;

case session::protocol_v2::Operation::kRecovery: {
vault_runtime::Metadata metadata{};
Expand All @@ -168,7 +184,7 @@ bool CanonicalVmkSink::install_vmk(
return true;
}
if (context.registration_epoch == 0) return false;
if (runtime_.unlock(vmk) != vault_runtime::Status::kOk) return false;
if (runtime_.unlock(vmk, now_ms) != vault_runtime::Status::kOk) return false;
const registration::Status registration_status = registration_.replace(
context.vault_id,
context.registration_epoch - 1,
Expand All @@ -185,7 +201,7 @@ bool CanonicalVmkSink::install_vmk(

case session::protocol_v2::Operation::kBrowserReplacement: {
if (context.registration_epoch == 0) return false;
if (runtime_.unlock(vmk) != vault_runtime::Status::kOk) return false;
if (runtime_.unlock(vmk, now_ms) != vault_runtime::Status::kOk) return false;
const registration::Status registration_status = registration_.replace(
context.vault_id,
context.registration_epoch - 1,
Expand Down Expand Up @@ -270,7 +286,7 @@ bool CanonicalVmkSink::install_initial_vault(
status = runtime_.install_encrypted_vault(envelope, vmk);
}
if (status == vault_runtime::Status::kOk) {
status = runtime_.unlock(vmk);
status = runtime_.unlock(vmk, now_ms);
}
if (status != vault_runtime::Status::kOk) {
(void)runtime_.factory_reset();
Expand Down Expand Up @@ -322,7 +338,7 @@ bool CanonicalVmkSink::install_recovered_vault(
status = runtime_.install_encrypted_vault(envelope, vmk);
}
if (status == vault_runtime::Status::kOk) {
status = runtime_.unlock(vmk);
status = runtime_.unlock(vmk, now_ms);
}
if (status != vault_runtime::Status::kOk) {
(void)runtime_.factory_reset();
Expand Down Expand Up @@ -365,7 +381,8 @@ bool CanonicalVmkSink::install_rekeyed_vault(
const vault_runtime::Status status = runtime_.rekey_encrypted_vault(
expected_generation,
std::move(envelope),
vmk
vmk,
now_ms
);
cancel_pending();
return status == vault_runtime::Status::kOk;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,12 @@ class CanonicalProtocolV2Handler final {

std::string handle_line(std::string_view line, std::uint64_t now_ms);
void disconnect();

void housekeeping(std::uint64_t now_ms) {
(void)session_handler_.expire(now_ms);
(void)vmk_sink_.expire_pending(now_ms);
if (recovery_reset_active_ && now_ms >= recovery_reset_deadline_ms_) {
cancel_recovery_reset();
}
}
void housekeeping(std::uint64_t now_ms);

private:
void cancel_recovery_reset();
void notify_security_boundary();
vault_runtime::Status lock_security_boundary();
RecoveryResetDecision recovery_reset_decision(
vault_runtime::Metadata* runtime_metadata,
registration::Snapshot* registration_snapshot,
Expand Down
43 changes: 35 additions & 8 deletions firmware/components/m5auth_vault/include/m5auth/vault.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@

namespace m5auth::vault {

inline constexpr std::uint16_t kVaultFormatVersion = 1;
// Format 1 remains the shipped v0.1.0 compatibility default for existing native
// helper call sites and known-answer vectors. Format 2 is the current canonical
// format for new writers and carries the authenticated automatic-lock policy.
inline constexpr std::uint16_t kVaultFormatVersion1 = 1;
inline constexpr std::uint16_t kVaultFormatVersion2 = 2;
inline constexpr std::uint16_t kVaultFormatVersion = kVaultFormatVersion1;
inline constexpr std::uint16_t kCurrentVaultFormatVersion = kVaultFormatVersion2;
inline constexpr std::uint16_t kTargetStorageSchemaVersion = 2;
inline constexpr std::uint16_t kRecoveryPackageVersion = 1;
inline constexpr std::uint16_t kVmkWrapVersion = 1;
Expand All @@ -19,6 +25,12 @@ inline constexpr std::size_t kVaultNonceBytes = 12;
inline constexpr std::size_t kVaultTagBytes = 16;
inline constexpr std::size_t kVmkBytes = 32;
inline constexpr std::size_t kMaxCredentials = 32;
inline constexpr std::uint8_t kMinAutoLockDays = 1;
inline constexpr std::uint8_t kMaxAutoLockDays = 31;

inline constexpr bool is_supported_vault_format(std::uint16_t version) {
return version == kVaultFormatVersion1 || version == kVaultFormatVersion2;
}

enum class TotpAlgorithm : std::uint8_t {
kSha1 = 1,
Expand All @@ -44,10 +56,14 @@ struct WifiRecord {
struct VaultPlaintext {
std::vector<CredentialRecord> credentials;
std::optional<WifiRecord> wifi;
// Present only in Vault Format 2. Format 1 always decodes this as unset.
std::optional<std::uint8_t> auto_lock_days;
};

struct VaultEnvelope {
std::uint16_t vault_format_version = kVaultFormatVersion;
// Keep the legacy default so unchanged Format-1 helper/vector call sites keep
// their byte-for-byte behavior. Format-2 writers set this explicitly.
std::uint16_t vault_format_version = kVaultFormatVersion1;
std::uint16_t storage_schema_version = kTargetStorageSchemaVersion;
std::array<std::uint8_t, kVaultIdBytes> vault_id{};
std::uint64_t generation = 0;
Expand All @@ -65,14 +81,22 @@ struct VmkWrapEnvelope {
std::array<std::uint8_t, kVaultTagBytes> tag{};
};

bool encode_plaintext(const VaultPlaintext& value, std::vector<std::uint8_t>& encoded);
bool decode_plaintext(const std::vector<std::uint8_t>& encoded, VaultPlaintext& value);
bool encode_plaintext(
const VaultPlaintext& value,
std::vector<std::uint8_t>& encoded,
std::uint16_t vault_format_version = kVaultFormatVersion1
);
bool decode_plaintext(
const std::vector<std::uint8_t>& encoded,
VaultPlaintext& value,
std::uint16_t* vault_format_version = nullptr
);

bool build_vault_aad(
const std::array<std::uint8_t, kVaultIdBytes>& vault_id,
std::uint64_t generation,
std::vector<std::uint8_t>& aad,
std::uint16_t vault_format_version = kVaultFormatVersion,
std::uint16_t vault_format_version = kVaultFormatVersion1,
std::uint16_t storage_schema_version = kTargetStorageSchemaVersion
);

Expand All @@ -84,13 +108,15 @@ bool build_vmk_wrap_aad(
);

// Production-facing encryption API. A fresh random 96-bit nonce is generated
// for every invocation; it is never derived from generation.
// for every invocation; it is never derived from generation. Existing callers
// default to Format 1 for compatibility; Format-2 writers pass version 2.
bool encrypt_vault(
const std::vector<std::uint8_t>& plaintext,
const std::array<std::uint8_t, kVmkBytes>& vmk,
const std::array<std::uint8_t, kVaultIdBytes>& vault_id,
std::uint64_t generation,
VaultEnvelope& envelope
VaultEnvelope& envelope,
std::uint16_t vault_format_version = kVaultFormatVersion1
);

// Deterministic nonce injection is exposed only so synthetic public
Expand All @@ -101,7 +127,8 @@ bool encrypt_vault_with_nonce(
const std::array<std::uint8_t, kVaultIdBytes>& vault_id,
std::uint64_t generation,
const std::array<std::uint8_t, kVaultNonceBytes>& nonce,
VaultEnvelope& envelope
VaultEnvelope& envelope,
std::uint16_t vault_format_version = kVaultFormatVersion1
);

bool decrypt_vault(
Expand Down
Loading