From b8469a60ebecac7cd518778225f38d3d422c2ec0 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:39:17 +0900 Subject: [PATCH 01/30] feat(vault): define dual vault format contract for #140 --- .../m5auth_vault/include/m5auth/vault.hpp | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/firmware/components/m5auth_vault/include/m5auth/vault.hpp b/firmware/components/m5auth_vault/include/m5auth/vault.hpp index dba8222d..da3dad90 100644 --- a/firmware/components/m5auth_vault/include/m5auth/vault.hpp +++ b/firmware/components/m5auth_vault/include/m5auth/vault.hpp @@ -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; @@ -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, @@ -44,10 +56,14 @@ struct WifiRecord { struct VaultPlaintext { std::vector credentials; std::optional wifi; + // Present only in Vault Format 2. Format 1 always decodes this as unset. + std::optional 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 vault_id{}; std::uint64_t generation = 0; @@ -65,14 +81,22 @@ struct VmkWrapEnvelope { std::array tag{}; }; -bool encode_plaintext(const VaultPlaintext& value, std::vector& encoded); -bool decode_plaintext(const std::vector& encoded, VaultPlaintext& value); +bool encode_plaintext( + const VaultPlaintext& value, + std::vector& encoded, + std::uint16_t vault_format_version = kVaultFormatVersion1 +); +bool decode_plaintext( + const std::vector& encoded, + VaultPlaintext& value, + std::uint16_t* vault_format_version = nullptr +); bool build_vault_aad( const std::array& vault_id, std::uint64_t generation, std::vector& aad, - std::uint16_t vault_format_version = kVaultFormatVersion, + std::uint16_t vault_format_version = kVaultFormatVersion1, std::uint16_t storage_schema_version = kTargetStorageSchemaVersion ); @@ -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& plaintext, const std::array& vmk, const std::array& 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 @@ -101,7 +127,8 @@ bool encrypt_vault_with_nonce( const std::array& vault_id, std::uint64_t generation, const std::array& nonce, - VaultEnvelope& envelope + VaultEnvelope& envelope, + std::uint16_t vault_format_version = kVaultFormatVersion1 ); bool decrypt_vault( From 852bd34205b2393774f3d87e9ea3b9fc3ba48844 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:40:05 +0900 Subject: [PATCH 02/30] feat(vault): add PT2 and AAD2 codec support for #140 --- .../components/m5auth_vault/vault_format.cpp | 195 ++++++++++++------ 1 file changed, 136 insertions(+), 59 deletions(-) diff --git a/firmware/components/m5auth_vault/vault_format.cpp b/firmware/components/m5auth_vault/vault_format.cpp index a1ca25c3..788f2f47 100644 --- a/firmware/components/m5auth_vault/vault_format.cpp +++ b/firmware/components/m5auth_vault/vault_format.cpp @@ -10,8 +10,10 @@ namespace { constexpr std::size_t kMaxFieldBytes = 1024; constexpr std::size_t kMaxSecretBytes = 512; -constexpr char kPlaintextMagic[] = "M5AUTH-VLT-PT1"; -constexpr char kVaultAadMagic[] = "M5AUTH-VLT-AAD1"; +constexpr char kPlaintextMagicV1[] = "M5AUTH-VLT-PT1"; +constexpr char kPlaintextMagicV2[] = "M5AUTH-VLT-PT2"; +constexpr char kVaultAadMagicV1[] = "M5AUTH-VLT-AAD1"; +constexpr char kVaultAadMagicV2[] = "M5AUTH-VLT-AAD2"; constexpr char kVmkWrapAadMagic[] = "M5AUTH-VMK-WRAP1"; void secure_zero_memory(void* data, std::size_t size) { @@ -51,6 +53,7 @@ void wipe_plaintext_candidate(VaultPlaintext* value) { wipe_string(&value->wifi->password); value->wifi.reset(); } + value->auto_lock_days.reset(); } class ByteVectorWipeGuard final { @@ -167,7 +170,7 @@ class Reader { explicit Reader(const std::vector& input) : input_(input) {} bool read_bytes(std::size_t length, std::uint8_t* output) { - if (length > input_.size() - offset_) return false; + if (offset_ > input_.size() || length > input_.size() - offset_) return false; if (length != 0) { std::copy_n(input_.data() + offset_, length, output); } @@ -190,7 +193,8 @@ class Reader { bool read_sized_bytes(std::vector& value, std::size_t maximum) { std::uint16_t length = 0; - if (!read_u16(length) || length > maximum || length > input_.size() - offset_) { + if (!read_u16(length) || offset_ > input_.size() || + length > maximum || length > input_.size() - offset_) { return false; } value.assign(input_.begin() + static_cast(offset_), @@ -201,8 +205,8 @@ class Reader { bool read_sized_text(std::string& value) { std::uint16_t length = 0; - if (!read_u16(length) || length > kMaxFieldBytes || - length > input_.size() - offset_) { + if (!read_u16(length) || offset_ > input_.size() || + length > kMaxFieldBytes || length > input_.size() - offset_) { return false; } if (length == 0) { @@ -239,19 +243,92 @@ bool valid_credential(const CredentialRecord& record) { record.period_seconds >= 1; } +bool valid_auto_lock_days(const std::optional& days) { + return !days.has_value() || + (*days >= kMinAutoLockDays && *days <= kMaxAutoLockDays); +} + +const char* plaintext_magic(std::uint16_t format) { + if (format == kVaultFormatVersion1) return kPlaintextMagicV1; + if (format == kVaultFormatVersion2) return kPlaintextMagicV2; + return nullptr; +} + +const char* aad_magic(std::uint16_t format) { + if (format == kVaultFormatVersion1) return kVaultAadMagicV1; + if (format == kVaultFormatVersion2) return kVaultAadMagicV2; + return nullptr; +} + +bool decode_common_body(Reader& reader, VaultPlaintext& candidate) { + std::uint16_t count = 0; + if (!reader.read_u16(count) || count > kMaxCredentials) return false; + + candidate.credentials.reserve(count); + std::set> ids; + for (std::uint16_t index = 0; index < count; ++index) { + candidate.credentials.emplace_back(); + auto& record = candidate.credentials.back(); + if (!reader.read_bytes(record.credential_id.size(), record.credential_id.data()) || + !ids.insert(record.credential_id).second || + !reader.read_sized_bytes(record.secret, kMaxSecretBytes) || + record.secret.empty() || + !reader.read_sized_text(record.issuer) || + !reader.read_sized_text(record.account) || + !reader.read_sized_text(record.display_name)) { + return false; + } + + std::uint8_t algorithm = 0; + if (!reader.read_u8(algorithm) || + algorithm != static_cast(TotpAlgorithm::kSha1) || + !reader.read_u8(record.digits) || + !reader.read_u16(record.period_seconds) || + !reader.read_u16(record.manual_order)) { + return false; + } + record.algorithm = TotpAlgorithm::kSha1; + if (!valid_credential(record)) return false; + } + + std::uint8_t wifi_present = 0; + if (!reader.read_u8(wifi_present) || wifi_present > 1) return false; + if (wifi_present == 1) { + candidate.wifi.emplace(); + if (!reader.read_sized_text(candidate.wifi->ssid) || + !reader.read_sized_text(candidate.wifi->password)) { + return false; + } + } + return true; +} + } // namespace -bool encode_plaintext(const VaultPlaintext& value, std::vector& encoded) { - if (value.credentials.size() > kMaxCredentials) return false; +bool encode_plaintext( + const VaultPlaintext& value, + std::vector& encoded, + std::uint16_t vault_format_version +) { + if (!is_supported_vault_format(vault_format_version) || + value.credentials.size() > kMaxCredentials || + !valid_auto_lock_days(value.auto_lock_days) || + (vault_format_version == kVaultFormatVersion1 && value.auto_lock_days.has_value())) { + return false; + } + + const char* magic = plaintext_magic(vault_format_version); + if (magic == nullptr) return false; std::vector candidate; ByteVectorWipeGuard candidate_wipe(candidate); + const std::size_t magic_size = sizeof(kPlaintextMagicV1); candidate.insert( candidate.end(), - reinterpret_cast(kPlaintextMagic), - reinterpret_cast(kPlaintextMagic) + sizeof(kPlaintextMagic) + reinterpret_cast(magic), + reinterpret_cast(magic) + magic_size ); - append_u16(candidate, kVaultFormatVersion); + append_u16(candidate, vault_format_version); append_u16(candidate, static_cast(value.credentials.size())); std::set> ids; @@ -284,71 +361,67 @@ bool encode_plaintext(const VaultPlaintext& value, std::vector& en return false; } + if (vault_format_version == kVaultFormatVersion2) { + candidate.push_back(value.auto_lock_days.has_value() ? 1 : 0); + if (value.auto_lock_days.has_value()) candidate.push_back(*value.auto_lock_days); + } + encoded.swap(candidate); return true; } -bool decode_plaintext(const std::vector& encoded, VaultPlaintext& value) { +bool decode_plaintext( + const std::vector& encoded, + VaultPlaintext& value, + std::uint16_t* vault_format_version +) { Reader reader(encoded); - std::array magic{}; - if (!reader.read_bytes(magic.size(), magic.data()) || - !std::equal( + std::array magic{}; + if (!reader.read_bytes(magic.size(), magic.data())) return false; + + std::uint16_t detected_format = 0; + if (std::equal( magic.begin(), magic.end(), - reinterpret_cast(kPlaintextMagic) + reinterpret_cast(kPlaintextMagicV1) )) { + detected_format = kVaultFormatVersion1; + } else if (std::equal( + magic.begin(), + magic.end(), + reinterpret_cast(kPlaintextMagicV2) + )) { + detected_format = kVaultFormatVersion2; + } else { return false; } - std::uint16_t version = 0; - std::uint16_t count = 0; - if (!reader.read_u16(version) || version != kVaultFormatVersion || - !reader.read_u16(count) || count > kMaxCredentials) { - return false; - } + std::uint16_t encoded_version = 0; + if (!reader.read_u16(encoded_version) || encoded_version != detected_format) return false; VaultPlaintext candidate; PlaintextWipeGuard candidate_wipe(candidate); - candidate.credentials.reserve(count); - std::set> ids; - for (std::uint16_t index = 0; index < count; ++index) { - candidate.credentials.emplace_back(); - auto& record = candidate.credentials.back(); - if (!reader.read_bytes(record.credential_id.size(), record.credential_id.data()) || - !ids.insert(record.credential_id).second || - !reader.read_sized_bytes(record.secret, kMaxSecretBytes) || - record.secret.empty() || - !reader.read_sized_text(record.issuer) || - !reader.read_sized_text(record.account) || - !reader.read_sized_text(record.display_name)) { - return false; - } - - std::uint8_t algorithm = 0; - if (!reader.read_u8(algorithm) || - algorithm != static_cast(TotpAlgorithm::kSha1) || - !reader.read_u8(record.digits) || - !reader.read_u16(record.period_seconds) || - !reader.read_u16(record.manual_order)) { - return false; - } - record.algorithm = TotpAlgorithm::kSha1; - if (!valid_credential(record)) return false; - } - - std::uint8_t wifi_present = 0; - if (!reader.read_u8(wifi_present) || wifi_present > 1) return false; - if (wifi_present == 1) { - candidate.wifi.emplace(); - if (!reader.read_sized_text(candidate.wifi->ssid) || - !reader.read_sized_text(candidate.wifi->password)) { - return false; + if (!decode_common_body(reader, candidate)) return false; + + if (detected_format == kVaultFormatVersion1) { + candidate.auto_lock_days.reset(); + } else { + std::uint8_t present = 0; + if (!reader.read_u8(present) || present > 1) return false; + if (present == 1) { + std::uint8_t days = 0; + if (!reader.read_u8(days) || + days < kMinAutoLockDays || days > kMaxAutoLockDays) { + return false; + } + candidate.auto_lock_days = days; } } if (!reader.at_end()) return false; wipe_plaintext_candidate(&value); value = std::move(candidate); + if (vault_format_version != nullptr) *vault_format_version = detected_format; return true; } @@ -359,16 +432,20 @@ bool build_vault_aad( std::uint16_t vault_format_version, std::uint16_t storage_schema_version ) { - if (vault_format_version != kVaultFormatVersion || + if (!is_supported_vault_format(vault_format_version) || storage_schema_version != kTargetStorageSchemaVersion) { return false; } + const char* magic = aad_magic(vault_format_version); + if (magic == nullptr) return false; + const std::size_t magic_size = sizeof(kVaultAadMagicV1); + std::vector candidate; candidate.insert( candidate.end(), - reinterpret_cast(kVaultAadMagic), - reinterpret_cast(kVaultAadMagic) + sizeof(kVaultAadMagic) + reinterpret_cast(magic), + reinterpret_cast(magic) + magic_size ); append_u16(candidate, vault_format_version); append_u16(candidate, storage_schema_version); From 3c677c1fb80407bc8a6d3085eea04377b94a52e3 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:40:55 +0900 Subject: [PATCH 03/30] feat(vault): bind AEAD to vault format version for #140 --- .../components/m5auth_vault/vault_crypto.cpp | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/firmware/components/m5auth_vault/vault_crypto.cpp b/firmware/components/m5auth_vault/vault_crypto.cpp index 2c786713..2098effd 100644 --- a/firmware/components/m5auth_vault/vault_crypto.cpp +++ b/firmware/components/m5auth_vault/vault_crypto.cpp @@ -22,9 +22,7 @@ void secure_zero_memory(void* data, std::size_t size) { } void clear_bytes(std::vector& value) { - if (!value.empty()) { - secure_zero_memory(value.data(), value.size()); - } + if (!value.empty()) secure_zero_memory(value.data(), value.size()); } bool fill_random_nonce(std::array& nonce) { @@ -254,11 +252,20 @@ bool encrypt_vault( const std::array& vmk, const std::array& vault_id, std::uint64_t generation, - VaultEnvelope& envelope + VaultEnvelope& envelope, + std::uint16_t vault_format_version ) { std::array nonce{}; if (!fill_random_nonce(nonce)) return false; - return encrypt_vault_with_nonce(plaintext, vmk, vault_id, generation, nonce, envelope); + return encrypt_vault_with_nonce( + plaintext, + vmk, + vault_id, + generation, + nonce, + envelope, + vault_format_version + ); } bool encrypt_vault_with_nonce( @@ -267,12 +274,25 @@ bool encrypt_vault_with_nonce( const std::array& vault_id, std::uint64_t generation, const std::array& nonce, - VaultEnvelope& envelope + VaultEnvelope& envelope, + std::uint16_t vault_format_version ) { + if (!is_supported_vault_format(vault_format_version)) return false; + std::vector aad; - if (!build_vault_aad(vault_id, generation, aad)) return false; + if (!build_vault_aad( + vault_id, + generation, + aad, + vault_format_version, + kTargetStorageSchemaVersion + )) { + return false; + } VaultEnvelope candidate; + candidate.vault_format_version = vault_format_version; + candidate.storage_schema_version = kTargetStorageSchemaVersion; candidate.vault_id = vault_id; candidate.generation = generation; candidate.nonce = nonce; @@ -296,7 +316,7 @@ bool decrypt_vault( const std::array& vmk, std::vector& plaintext ) { - if (envelope.vault_format_version != kVaultFormatVersion || + if (!is_supported_vault_format(envelope.vault_format_version) || envelope.storage_schema_version != kTargetStorageSchemaVersion) { return false; } From 4988070e9d8581e452bf708fde35f2baf2e3ad9e Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:42:18 +0900 Subject: [PATCH 04/30] feat(runtime): model automatic lock policy for #140 --- .../include/m5auth/vault_runtime/runtime.hpp | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp b/firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp index 89e681a9..0001f306 100644 --- a/firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp +++ b/firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp @@ -14,6 +14,7 @@ namespace m5auth::vault_runtime { inline constexpr std::uint32_t kStorageSchemaVersion = 2; inline constexpr std::size_t kMaxPersistedCiphertextBytes = 64 * 1024; +inline constexpr std::uint64_t kMillisecondsPerDay = 86'400'000ULL; using CredentialId = std::array; using Vmk = std::array; @@ -28,6 +29,7 @@ enum class Status { kNotFound, kReprovisionRequired, kUnsupportedSchema, + kUnsupportedVaultFormat, kGenerationMismatch, kAuthenticationFailed, kCorrupt, @@ -57,7 +59,7 @@ class Persistence { // Returns kOk for a readable Schema 2 partition, kUnprovisioned when no // schema exists, kReprovisionRequired for known development Schema 1, and - // kUnsupportedSchema for a newer schema. + // kUnsupportedSchema/kUnsupportedVaultFormat for newer unsupported data. virtual Status load(PersistedSnapshot* snapshot) = 0; // Explicitly destructive reprovision/Factory-Reset preparation. This is @@ -107,9 +109,9 @@ struct Metadata { bool schema_ready{false}; bool has_vault{false}; // True only when Runtime classified the fail-closed persisted state as a - // structural/unsupported/reprovision condition for which an explicitly - // user-confirmed destructive reset is a valid recovery. Generic I/O does - // not set this bit and must not become erase-authorizing metadata. + // structural/reprovision condition for which an explicitly user-confirmed + // destructive reset is a valid recovery. An unknown newer Vault format is + // deliberately excluded: firmware must not erase data it cannot interpret. bool recovery_reset_allowed{false}; std::uint32_t storage_schema_version{0}; std::uint16_t vault_format_version{0}; @@ -142,7 +144,7 @@ class Runtime final { Status initialize(); Status format_for_schema2(); Status install_encrypted_vault(vault::VaultEnvelope envelope, Vmk vmk); - Status unlock(Vmk vmk); + Status unlock(Vmk vmk, std::uint64_t now_ms = 0); Status lock(); Status enter_recovery_boundary(); @@ -156,13 +158,16 @@ class Runtime final { Status update_encrypted_vault( std::uint64_t expected_generation, - vault::VaultEnvelope envelope + vault::VaultEnvelope envelope, + std::uint64_t now_ms = 0, + bool* automatic_lock_due_after_commit = nullptr ); Status rekey_encrypted_vault( std::uint64_t expected_generation, vault::VaultEnvelope envelope, - Vmk vmk + Vmk vmk, + std::uint64_t now_ms = 0 ); Status metadata(Metadata* metadata) const; @@ -178,14 +183,23 @@ class Runtime final { Status factory_reset(); bool unlocked() const; + bool automatic_lock_due(std::uint64_t now_ms) const; + std::optional auto_lock_days() const { return auto_lock_days_; } + std::uint64_t unlocked_since_ms() const { return unlocked_since_ms_; } private: Status reload_after_persistence(); Status validate_envelope_with_key( const vault::VaultEnvelope& envelope, - const Vmk& vmk + const Vmk& vmk, + std::optional* auto_lock_days = nullptr ) const; void wipe_vmk(); + void clear_unlock_session_state(); + void begin_unlock_session( + const std::optional& auto_lock_days, + std::uint64_t now_ms + ); Persistence& persistence_; bool initialized_{false}; @@ -197,6 +211,9 @@ class Runtime final { std::optional last_used_; Vmk vmk_{}; bool vmk_present_{false}; + std::optional auto_lock_days_; + std::uint64_t unlocked_since_ms_{0}; + bool unlock_session_active_{false}; }; void secure_zero(void* data, std::size_t size); From edfc8118ea6dce6eb18573417f0f61d0e40cc48d Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:43:29 +0900 Subject: [PATCH 05/30] feat(runtime): enforce continuous unlock lifetime policy for #140 --- .../m5auth_vault_runtime/runtime.cpp | 129 ++++++++++++++---- 1 file changed, 100 insertions(+), 29 deletions(-) diff --git a/firmware/components/m5auth_vault_runtime/runtime.cpp b/firmware/components/m5auth_vault_runtime/runtime.cpp index cf41607b..a30368b3 100644 --- a/firmware/components/m5auth_vault_runtime/runtime.cpp +++ b/firmware/components/m5auth_vault_runtime/runtime.cpp @@ -31,7 +31,7 @@ void wipe_bytes(std::vector* value) { } bool valid_envelope_framing(const vault::VaultEnvelope& envelope) { - return envelope.vault_format_version == vault::kVaultFormatVersion && + return vault::is_supported_vault_format(envelope.vault_format_version) && envelope.storage_schema_version == vault::kTargetStorageSchemaVersion && envelope.generation != 0 && !envelope.ciphertext.empty() && @@ -70,6 +70,7 @@ const char* status_code(Status status) { case Status::kNotFound: return "not_found"; case Status::kReprovisionRequired: return "reprovision_required"; case Status::kUnsupportedSchema: return "unsupported_schema"; + case Status::kUnsupportedVaultFormat: return "unsupported_vault_format"; case Status::kGenerationMismatch: return "generation_mismatch"; case Status::kAuthenticationFailed: return "authentication_failed"; case Status::kCorrupt: return "corrupt"; @@ -104,12 +105,13 @@ void wipe_plaintext(vault::VaultPlaintext* plaintext) { wipe_string(&plaintext->wifi->password); plaintext->wifi.reset(); } + plaintext->auto_lock_days.reset(); } Runtime::Runtime(Persistence& persistence) : persistence_(persistence) {} Runtime::~Runtime() { - wipe_vmk(); + clear_unlock_session_state(); } void Runtime::wipe_vmk() { @@ -117,8 +119,24 @@ void Runtime::wipe_vmk() { vmk_present_ = false; } -Status Runtime::initialize() { +void Runtime::clear_unlock_session_state() { wipe_vmk(); + auto_lock_days_.reset(); + unlocked_since_ms_ = 0; + unlock_session_active_ = false; +} + +void Runtime::begin_unlock_session( + const std::optional& auto_lock_days, + std::uint64_t now_ms +) { + auto_lock_days_ = auto_lock_days; + unlocked_since_ms_ = now_ms; + unlock_session_active_ = true; +} + +Status Runtime::initialize() { + clear_unlock_session_state(); initialized_ = true; schema_ready_ = false; has_vault_ = false; @@ -151,8 +169,10 @@ Status Runtime::initialize() { if (!snapshot.has_vault) return Status::kUnprovisioned; if (!valid_envelope_framing(snapshot.envelope)) { state_ = State::kError; - recovery_reset_allowed_ = true; - return Status::kCorrupt; + recovery_reset_allowed_ = false; + return vault::is_supported_vault_format(snapshot.envelope.vault_format_version) + ? Status::kCorrupt + : Status::kUnsupportedVaultFormat; } has_vault_ = true; @@ -167,7 +187,7 @@ Status Runtime::reload_after_persistence() { if (status != Status::kOk || !snapshot.schema_ready) { state_ = State::kError; recovery_reset_allowed_ = status == Status::kOk || explicit_reset_recovery_status(status); - wipe_vmk(); + clear_unlock_session_state(); return status == Status::kOk ? Status::kCorrupt : status; } @@ -178,26 +198,28 @@ Status Runtime::reload_after_persistence() { if (!has_vault_) { envelope_ = vault::VaultEnvelope{}; state_ = State::kUnprovisioned; - wipe_vmk(); + clear_unlock_session_state(); return Status::kOk; } if (!valid_envelope_framing(snapshot.envelope)) { state_ = State::kError; - recovery_reset_allowed_ = true; - wipe_vmk(); - return Status::kCorrupt; + recovery_reset_allowed_ = false; + clear_unlock_session_state(); + return vault::is_supported_vault_format(snapshot.envelope.vault_format_version) + ? Status::kCorrupt + : Status::kUnsupportedVaultFormat; } envelope_ = std::move(snapshot.envelope); state_ = State::kLocked; - wipe_vmk(); + clear_unlock_session_state(); return Status::kOk; } Status Runtime::format_for_schema2() { if (!initialized_) return Status::kNotReady; - wipe_vmk(); + clear_unlock_session_state(); const Status status = persistence_.format_schema2(); if (status != Status::kOk) { state_ = State::kError; @@ -209,9 +231,14 @@ Status Runtime::format_for_schema2() { Status Runtime::validate_envelope_with_key( const vault::VaultEnvelope& envelope, - const Vmk& vmk + const Vmk& vmk, + std::optional* auto_lock_days ) const { - if (!valid_envelope_framing(envelope)) return Status::kInvalidArgument; + if (!valid_envelope_framing(envelope)) { + return vault::is_supported_vault_format(envelope.vault_format_version) + ? Status::kInvalidArgument + : Status::kUnsupportedVaultFormat; + } std::vector encoded_plaintext; if (!vault::decrypt_vault(envelope, vmk, encoded_plaintext)) { @@ -220,10 +247,20 @@ Status Runtime::validate_envelope_with_key( } vault::VaultPlaintext plaintext; - const bool decoded = vault::decode_plaintext(encoded_plaintext, plaintext); + std::uint16_t decoded_format = 0; + const bool decoded = vault::decode_plaintext( + encoded_plaintext, + plaintext, + &decoded_format + ); wipe_bytes(&encoded_plaintext); + if (!decoded || decoded_format != envelope.vault_format_version) { + wipe_plaintext(&plaintext); + return Status::kCorrupt; + } + if (auto_lock_days != nullptr) *auto_lock_days = plaintext.auto_lock_days; wipe_plaintext(&plaintext); - return decoded ? Status::kOk : Status::kCorrupt; + return Status::kOk; } Status Runtime::install_encrypted_vault(vault::VaultEnvelope envelope, Vmk vmk) { @@ -242,23 +279,25 @@ Status Runtime::install_encrypted_vault(vault::VaultEnvelope envelope, Vmk vmk) if (status != Status::kOk || !has_vault_ || !same_envelope(envelope_, envelope)) { state_ = State::kError; recovery_reset_allowed_ = status == Status::kOk || explicit_reset_recovery_status(status); - wipe_vmk(); + clear_unlock_session_state(); return status == Status::kOk ? Status::kCorrupt : status; } return Status::kOk; } -Status Runtime::unlock(Vmk vmk) { +Status Runtime::unlock(Vmk vmk, std::uint64_t now_ms) { ScopedKeyWipe wipe(vmk); if (!initialized_) return Status::kNotReady; if (state_ != State::kLocked || !has_vault_) return Status::kInvalidState; - const Status status = validate_envelope_with_key(envelope_, vmk); + std::optional policy; + const Status status = validate_envelope_with_key(envelope_, vmk, &policy); if (status != Status::kOk) return status; - wipe_vmk(); + clear_unlock_session_state(); vmk_ = vmk; vmk_present_ = true; + begin_unlock_session(policy, now_ms); state_ = State::kUnlocked; recovery_reset_allowed_ = false; return Status::kOk; @@ -266,7 +305,7 @@ Status Runtime::unlock(Vmk vmk) { Status Runtime::lock() { if (!initialized_) return Status::kNotReady; - wipe_vmk(); + clear_unlock_session_state(); if (state_ == State::kError) return Status::kInvalidState; state_ = has_vault_ ? State::kLocked : State::kUnprovisioned; return Status::kOk; @@ -274,7 +313,7 @@ Status Runtime::lock() { Status Runtime::enter_recovery_boundary() { if (!initialized_) return Status::kNotReady; - wipe_vmk(); + clear_unlock_session_state(); if (state_ == State::kError) return Status::kInvalidState; state_ = has_vault_ ? State::kLocked : State::kUnprovisioned; return Status::kOk; @@ -282,7 +321,7 @@ Status Runtime::enter_recovery_boundary() { Status Runtime::fatal_security_error() { if (!initialized_) return Status::kNotReady; - wipe_vmk(); + clear_unlock_session_state(); state_ = State::kError; recovery_reset_allowed_ = true; return Status::kOk; @@ -290,8 +329,13 @@ Status Runtime::fatal_security_error() { Status Runtime::update_encrypted_vault( std::uint64_t expected_generation, - vault::VaultEnvelope envelope + vault::VaultEnvelope envelope, + std::uint64_t now_ms, + bool* automatic_lock_due_after_commit ) { + if (automatic_lock_due_after_commit != nullptr) { + *automatic_lock_due_after_commit = false; + } if (!initialized_) return Status::kNotReady; if (state_ != State::kUnlocked || !vmk_present_ || !has_vault_) return Status::kLocked; if (expected_generation != envelope_.generation || @@ -300,8 +344,16 @@ Status Runtime::update_encrypted_vault( envelope.vault_id != envelope_.vault_id) { return Status::kGenerationMismatch; } + if (!vault::is_supported_vault_format(envelope.vault_format_version)) { + return Status::kUnsupportedVaultFormat; + } + if (envelope_.vault_format_version == vault::kVaultFormatVersion2 && + envelope.vault_format_version == vault::kVaultFormatVersion1) { + return Status::kInvalidArgument; + } - Status status = validate_envelope_with_key(envelope, vmk_); + std::optional candidate_policy; + Status status = validate_envelope_with_key(envelope, vmk_, &candidate_policy); if (status != Status::kOk) return status; status = persistence_.replace_envelope(expected_generation, envelope); @@ -321,6 +373,11 @@ Status Runtime::update_encrypted_vault( has_vault_ = true; recovery_reset_allowed_ = false; state_ = State::kUnlocked; + // Keep the original unlock timestamp. Only the committed policy changes. + auto_lock_days_ = candidate_policy; + if (automatic_lock_due_after_commit != nullptr) { + *automatic_lock_due_after_commit = automatic_lock_due(now_ms); + } return Status::kOk; } @@ -360,7 +417,9 @@ Status Runtime::with_credential( } vault::VaultPlaintext plaintext; - if (!vault::decode_plaintext(encoded_plaintext, plaintext)) { + std::uint16_t decoded_format = 0; + if (!vault::decode_plaintext(encoded_plaintext, plaintext, &decoded_format) || + decoded_format != envelope_.vault_format_version) { wipe_bytes(&encoded_plaintext); wipe_plaintext(&plaintext); fatal_security_error(); @@ -403,7 +462,9 @@ Status Runtime::with_wifi(const WifiConsumer& consumer) { } vault::VaultPlaintext plaintext; - if (!vault::decode_plaintext(encoded_plaintext, plaintext)) { + std::uint16_t decoded_format = 0; + if (!vault::decode_plaintext(encoded_plaintext, plaintext, &decoded_format) || + decoded_format != envelope_.vault_format_version) { wipe_bytes(&encoded_plaintext); wipe_plaintext(&plaintext); fatal_security_error(); @@ -432,7 +493,9 @@ Status Runtime::set_last_used(const CredentialId& credential_id) { return Status::kAuthenticationFailed; } vault::VaultPlaintext plaintext; - if (!vault::decode_plaintext(encoded_plaintext, plaintext)) { + std::uint16_t decoded_format = 0; + if (!vault::decode_plaintext(encoded_plaintext, plaintext, &decoded_format) || + decoded_format != envelope_.vault_format_version) { wipe_bytes(&encoded_plaintext); wipe_plaintext(&plaintext); fatal_security_error(); @@ -458,7 +521,7 @@ Status Runtime::set_last_used(const CredentialId& credential_id) { Status Runtime::factory_reset() { if (!initialized_) return Status::kNotReady; - wipe_vmk(); + clear_unlock_session_state(); const Status status = persistence_.erase_all(); if (status != Status::kOk) { state_ = State::kError; @@ -479,4 +542,12 @@ bool Runtime::unlocked() const { return initialized_ && state_ == State::kUnlocked && vmk_present_; } +bool Runtime::automatic_lock_due(std::uint64_t now_ms) const { + if (!unlocked() || !unlock_session_active_ || !auto_lock_days_.has_value()) return false; + if (now_ms < unlocked_since_ms_) return false; + const std::uint64_t lifetime_ms = + static_cast(*auto_lock_days_) * kMillisecondsPerDay; + return now_ms - unlocked_since_ms_ >= lifetime_ms; +} + } // namespace m5auth::vault_runtime From d8549f9f5602637953b4449daff582e68e714b5a Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:43:48 +0900 Subject: [PATCH 06/30] feat(runtime): preserve format and policy across rekey for #140 --- .../components/m5auth_vault_runtime/rekey.cpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/firmware/components/m5auth_vault_runtime/rekey.cpp b/firmware/components/m5auth_vault_runtime/rekey.cpp index 8a95413e..19504043 100644 --- a/firmware/components/m5auth_vault_runtime/rekey.cpp +++ b/firmware/components/m5auth_vault_runtime/rekey.cpp @@ -33,7 +33,8 @@ bool same_envelope( Status Runtime::rekey_encrypted_vault( std::uint64_t expected_generation, vault::VaultEnvelope envelope, - Vmk vmk + Vmk vmk, + std::uint64_t now_ms ) { ScopedVmkWipe wipe(vmk); if (!initialized_) return Status::kNotReady; @@ -46,8 +47,16 @@ Status Runtime::rekey_encrypted_vault( envelope.vault_id != envelope_.vault_id) { return Status::kGenerationMismatch; } + if (!vault::is_supported_vault_format(envelope.vault_format_version)) { + return Status::kUnsupportedVaultFormat; + } + if (envelope_.vault_format_version == vault::kVaultFormatVersion2 && + envelope.vault_format_version == vault::kVaultFormatVersion1) { + return Status::kInvalidArgument; + } - Status status = validate_envelope_with_key(envelope, vmk); + std::optional policy; + Status status = validate_envelope_with_key(envelope, vmk, &policy); if (status != Status::kOk) return status; status = persistence_.replace_envelope(expected_generation, envelope); @@ -58,7 +67,7 @@ Status Runtime::rekey_encrypted_vault( if (status != Status::kOk || !snapshot.schema_ready || !snapshot.has_vault || !same_envelope(snapshot.envelope, envelope)) { state_ = State::kError; - wipe_vmk(); + clear_unlock_session_state(); return status == Status::kOk ? Status::kCorrupt : status; } @@ -66,10 +75,12 @@ Status Runtime::rekey_encrypted_vault( last_used_ = snapshot.last_used; schema_ready_ = true; has_vault_ = true; - wipe_vmk(); + clear_unlock_session_state(); vmk_ = vmk; vmk_present_ = true; + begin_unlock_session(policy, now_ms); state_ = State::kUnlocked; + recovery_reset_allowed_ = false; return Status::kOk; } From 220a9149a2101a79b34b49afb70c47252abc9eea Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:44:55 +0900 Subject: [PATCH 07/30] feat(storage): persist vault formats 1 and 2 in schema 2 --- .../m5auth_vault_runtime/nvs_persistence.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/firmware/components/m5auth_vault_runtime/nvs_persistence.cpp b/firmware/components/m5auth_vault_runtime/nvs_persistence.cpp index dfc66664..c14b4d2b 100644 --- a/firmware/components/m5auth_vault_runtime/nvs_persistence.cpp +++ b/firmware/components/m5auth_vault_runtime/nvs_persistence.cpp @@ -68,7 +68,7 @@ class Reader final { explicit Reader(const std::vector& input) : input_(input) {} bool read_exact(std::uint8_t* output, std::size_t length) { - if (length > input_.size() - offset_) return false; + if (offset_ > input_.size() || length > input_.size() - offset_) return false; if (length != 0) { std::copy_n(input_.data() + offset_, length, output); } @@ -114,7 +114,7 @@ class Reader final { bool valid_envelope(const vault::VaultEnvelope& envelope) { return envelope.storage_schema_version == vault::kTargetStorageSchemaVersion && - envelope.vault_format_version == vault::kVaultFormatVersion && + vault::is_supported_vault_format(envelope.vault_format_version) && envelope.generation != 0 && !envelope.ciphertext.empty() && envelope.ciphertext.size() <= kMaxPersistedCiphertextBytes && @@ -181,6 +181,12 @@ Status decode_envelope( ciphertext_size == 0 || ciphertext_size > kMaxPersistedCiphertextBytes) { return Status::kCorrupt; } + if (candidate.storage_schema_version != vault::kTargetStorageSchemaVersion) { + return Status::kCorrupt; + } + if (!vault::is_supported_vault_format(candidate.vault_format_version)) { + return Status::kUnsupportedVaultFormat; + } candidate.ciphertext.resize(ciphertext_size); if (!reader.read_exact(candidate.ciphertext.data(), candidate.ciphertext.size()) || From 0e73e51ae7a68bbda459ef9ea250802964ff072f Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:45:25 +0900 Subject: [PATCH 08/30] fix(runtime): verify plaintext format while listing credentials --- firmware/components/m5auth_vault_runtime/metadata.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/firmware/components/m5auth_vault_runtime/metadata.cpp b/firmware/components/m5auth_vault_runtime/metadata.cpp index 28a82a45..d9405d5b 100644 --- a/firmware/components/m5auth_vault_runtime/metadata.cpp +++ b/firmware/components/m5auth_vault_runtime/metadata.cpp @@ -23,7 +23,9 @@ Status Runtime::list_credentials(std::vector* credentials) { } vault::VaultPlaintext plaintext; - if (!vault::decode_plaintext(encoded_plaintext, plaintext)) { + std::uint16_t decoded_format = 0; + if (!vault::decode_plaintext(encoded_plaintext, plaintext, &decoded_format) || + decoded_format != envelope_.vault_format_version) { if (!encoded_plaintext.empty()) secure_zero(encoded_plaintext.data(), encoded_plaintext.size()); encoded_plaintext.clear(); wipe_plaintext(&plaintext); From eda290354bc1fd181b9fb795ee077a9a4b510331 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:45:52 +0900 Subject: [PATCH 09/30] refactor(provisioning): carry monotonic unlock time to VMK sink --- .../include/m5auth/provisioning/session_protocol_v2.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firmware/components/m5auth_provisioning/include/m5auth/provisioning/session_protocol_v2.hpp b/firmware/components/m5auth_provisioning/include/m5auth/provisioning/session_protocol_v2.hpp index 4caba27b..b288b77d 100644 --- a/firmware/components/m5auth_provisioning/include/m5auth/provisioning/session_protocol_v2.hpp +++ b/firmware/components/m5auth_provisioning/include/m5auth/provisioning/session_protocol_v2.hpp @@ -38,7 +38,8 @@ class SessionV2VmkSink { virtual ~SessionV2VmkSink() = default; virtual bool install_vmk( const session::protocol_v2::BeginContext& context, - const session::Vmk& vmk + const session::Vmk& vmk, + std::uint64_t now_ms ) = 0; }; From 444756f4495d70abcfcbb6ab78c24b7d8dd5a73f Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:46:18 +0900 Subject: [PATCH 10/30] refactor(provisioning): preserve existing VMK sink interface --- .../include/m5auth/provisioning/session_protocol_v2.hpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/firmware/components/m5auth_provisioning/include/m5auth/provisioning/session_protocol_v2.hpp b/firmware/components/m5auth_provisioning/include/m5auth/provisioning/session_protocol_v2.hpp index b288b77d..4caba27b 100644 --- a/firmware/components/m5auth_provisioning/include/m5auth/provisioning/session_protocol_v2.hpp +++ b/firmware/components/m5auth_provisioning/include/m5auth/provisioning/session_protocol_v2.hpp @@ -38,8 +38,7 @@ class SessionV2VmkSink { virtual ~SessionV2VmkSink() = default; virtual bool install_vmk( const session::protocol_v2::BeginContext& context, - const session::Vmk& vmk, - std::uint64_t now_ms + const session::Vmk& vmk ) = 0; }; From b7307c1dbd154a3a8fb60c6953aea943e287177b Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:46:56 +0900 Subject: [PATCH 11/30] feat(provisioning): start unlock lifetime from monotonic runtime --- .../canonical_v2_state.cpp | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/firmware/components/m5auth_provisioning/canonical_v2_state.cpp b/firmware/components/m5auth_provisioning/canonical_v2_state.cpp index c0b643e5..9b126cd7 100644 --- a/firmware/components/m5auth_provisioning/canonical_v2_state.cpp +++ b/firmware/components/m5auth_provisioning/canonical_v2_state.cpp @@ -3,6 +3,10 @@ #include #include +#ifdef ESP_PLATFORM +#include "esp_timer.h" +#endif + namespace m5auth::provisioning { namespace { @@ -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(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) {} @@ -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 && @@ -144,10 +159,11 @@ bool CanonicalVmkSink::install_vmk( ) { std::lock_guard 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{}; @@ -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, @@ -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, @@ -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(); @@ -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(); @@ -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; From b43c0a3186fc65cf860959c14002ea9d46aa87df Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:47:34 +0900 Subject: [PATCH 12/30] refactor(provisioning): share explicit and automatic lock boundary --- .../m5auth/provisioning/canonical_protocol_v2.hpp | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/firmware/components/m5auth_provisioning/include/m5auth/provisioning/canonical_protocol_v2.hpp b/firmware/components/m5auth_provisioning/include/m5auth/provisioning/canonical_protocol_v2.hpp index c6393367..42acc799 100644 --- a/firmware/components/m5auth_provisioning/include/m5auth/provisioning/canonical_protocol_v2.hpp +++ b/firmware/components/m5auth_provisioning/include/m5auth/provisioning/canonical_protocol_v2.hpp @@ -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, From 44804c0c986610b968c8d1ed9d948720abac02f8 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:52:40 +0900 Subject: [PATCH 13/30] feat(provisioning): enforce automatic lock through explicit lock boundary --- .../canonical_protocol_v2.cpp | 74 +++++++++++++++---- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp b/firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp index f4d6f64f..d7f23376 100644 --- a/firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp +++ b/firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp @@ -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) || @@ -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); @@ -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 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 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, @@ -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 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) @@ -658,18 +713,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 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)); From 444f9612b044f8e2acd5e7f73a2e1b62ef83d881 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:54:33 +0900 Subject: [PATCH 14/30] test(vault): cover PT2 AAD2 and legacy PT1 compatibility --- tests/vault_interop_test.cpp | 125 +++++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 4 deletions(-) diff --git a/tests/vault_interop_test.cpp b/tests/vault_interop_test.cpp index 0cf0f9cf..939830fa 100644 --- a/tests/vault_interop_test.cpp +++ b/tests/vault_interop_test.cpp @@ -66,6 +66,7 @@ VaultPlaintext sample_vault() { int main() { const auto vault_id = sequence(0x00); + // Shipped v0.1.0 PT1/AAD1 remains byte-for-byte unchanged. std::vector aad; assert(build_vault_aad(vault_id, 7, aad)); assert( @@ -82,7 +83,10 @@ int main() { assert(encode_plaintext(original, encoded)); VaultPlaintext decoded; - assert(decode_plaintext(encoded, decoded)); + std::uint16_t decoded_format = 0; + assert(decode_plaintext(encoded, decoded, &decoded_format)); + assert(decoded_format == kVaultFormatVersion1); + assert(!decoded.auto_lock_days.has_value()); assert(decoded.credentials.size() == 1); assert(decoded.credentials[0].secret == original.credentials[0].secret); assert(decoded.credentials[0].issuer == original.credentials[0].issuer); @@ -95,6 +99,99 @@ int main() { assert(decoded.wifi->ssid == original.wifi->ssid); assert(decoded.wifi->password == original.wifi->password); + // PT1 exact-end remains mandatory; it is not retrofitted with extensions. + std::vector pt1_trailing = encoded; + pt1_trailing.push_back(0x00); + VaultPlaintext rejected_plaintext; + assert(!decode_plaintext(pt1_trailing, rejected_plaintext)); + + // Format 2 has a distinct framing/AAD domain and carries only the bounded + // optional automatic-lock policy after the unchanged credential/Wi-Fi body. + std::vector aad2; + assert(build_vault_aad( + vault_id, + 7, + aad2, + kVaultFormatVersion2, + kTargetStorageSchemaVersion + )); + assert( + hex(aad2.data(), aad2.size()) == + "4d35415554482d564c542d4141443200" + "0002" + "0002" + "000102030405060708090a0b0c0d0e0f" + "0000000000000007" + ); + assert(aad2 != aad); + + VaultPlaintext format2_unset = sample_vault(); + std::vector encoded2_unset; + assert(encode_plaintext(format2_unset, encoded2_unset, kVaultFormatVersion2)); + VaultPlaintext decoded2_unset; + decoded_format = 0; + assert(decode_plaintext(encoded2_unset, decoded2_unset, &decoded_format)); + assert(decoded_format == kVaultFormatVersion2); + assert(!decoded2_unset.auto_lock_days.has_value()); + + VaultPlaintext format2_one = sample_vault(); + format2_one.auto_lock_days = 1; + std::vector encoded2_one; + assert(encode_plaintext(format2_one, encoded2_one, kVaultFormatVersion2)); + VaultPlaintext decoded2_one; + decoded_format = 0; + assert(decode_plaintext(encoded2_one, decoded2_one, &decoded_format)); + assert(decoded_format == kVaultFormatVersion2); + assert(decoded2_one.auto_lock_days == std::optional{1}); + + VaultPlaintext format2_thirty_one = sample_vault(); + format2_thirty_one.auto_lock_days = 31; + std::vector encoded2_thirty_one; + assert(encode_plaintext( + format2_thirty_one, + encoded2_thirty_one, + kVaultFormatVersion2 + )); + VaultPlaintext decoded2_thirty_one; + assert(decode_plaintext(encoded2_thirty_one, decoded2_thirty_one)); + assert(decoded2_thirty_one.auto_lock_days == std::optional{31}); + + VaultPlaintext invalid_auto_lock = sample_vault(); + invalid_auto_lock.auto_lock_days = 0; + std::vector invalid_auto_lock_encoded; + assert(!encode_plaintext( + invalid_auto_lock, + invalid_auto_lock_encoded, + kVaultFormatVersion2 + )); + invalid_auto_lock.auto_lock_days = 32; + assert(!encode_plaintext( + invalid_auto_lock, + invalid_auto_lock_encoded, + kVaultFormatVersion2 + )); + // F1 cannot silently discard a Format-2-only logical setting. + invalid_auto_lock.auto_lock_days = 1; + assert(!encode_plaintext( + invalid_auto_lock, + invalid_auto_lock_encoded, + kVaultFormatVersion1 + )); + + std::vector invalid_presence = encoded2_unset; + assert(!invalid_presence.empty()); + invalid_presence.back() = 2; + assert(!decode_plaintext(invalid_presence, rejected_plaintext)); + + std::vector truncated_setting = encoded2_one; + assert(!truncated_setting.empty()); + truncated_setting.pop_back(); + assert(!decode_plaintext(truncated_setting, rejected_plaintext)); + + std::vector pt2_trailing = encoded2_unset; + pt2_trailing.push_back(0xaa); + assert(!decode_plaintext(pt2_trailing, rejected_plaintext)); + const auto vmk = sequence(0x00); // Device persistence stores the authenticated Vault envelope, not this @@ -133,6 +230,7 @@ int main() { VaultEnvelope known_answer; assert(encrypt_vault_with_nonce(payload, vmk, vault_id, 7, nonce, known_answer)); + assert(known_answer.vault_format_version == kVaultFormatVersion1); assert( hex(known_answer.ciphertext.data(), known_answer.ciphertext.size()) == "956112592dae76d60148f1b27216b4f300cd207cfdd62641f3604aff" @@ -156,18 +254,37 @@ int main() { ++wrong_generation.generation; assert(!decrypt_vault(wrong_generation, vmk, opened)); + VaultEnvelope format2_answer; + assert(encrypt_vault_with_nonce( + encoded2_one, + vmk, + vault_id, + 7, + nonce, + format2_answer, + kVaultFormatVersion2 + )); + assert(format2_answer.vault_format_version == kVaultFormatVersion2); + opened.clear(); + assert(decrypt_vault(format2_answer, vmk, opened)); + assert(opened == encoded2_one); + // Merely substituting the envelope version cannot cross-authenticate AAD1/AAD2. + VaultEnvelope cross_domain = format2_answer; + cross_domain.vault_format_version = kVaultFormatVersion1; + assert(!decrypt_vault(cross_domain, vmk, opened)); + assert(!build_vault_aad( vault_id, 1, aad, - kVaultFormatVersion + 1, + kVaultFormatVersion2 + 1, kTargetStorageSchemaVersion )); assert(!build_vault_aad( vault_id, 1, aad, - kVaultFormatVersion, + kVaultFormatVersion1, kTargetStorageSchemaVersion + 1 )); @@ -235,4 +352,4 @@ int main() { )); return 0; -} +} \ No newline at end of file From 92db90a65b69d6c5faaf96cf5f819ef343744eed Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:19:33 +0900 Subject: [PATCH 15/30] test(runtime): cover format transitions and automatic lock policy --- tests/vault_runtime_test.cpp | 392 +++++++++++++++++++++++++++++++++-- 1 file changed, 372 insertions(+), 20 deletions(-) diff --git a/tests/vault_runtime_test.cpp b/tests/vault_runtime_test.cpp index a3729baf..3c93c62f 100644 --- a/tests/vault_runtime_test.cpp +++ b/tests/vault_runtime_test.cpp @@ -85,7 +85,10 @@ class FakePersistence final : public Persistence { Status erase_result{Status::kOk}; }; -m5auth::vault::VaultPlaintext synthetic_plaintext(const CredentialId& credential_id) { +m5auth::vault::VaultPlaintext synthetic_plaintext( + const CredentialId& credential_id, + std::optional auto_lock_days = std::nullopt +) { m5auth::vault::VaultPlaintext plaintext; m5auth::vault::CredentialRecord credential; credential.credential_id = credential_id; @@ -105,20 +108,61 @@ m5auth::vault::VaultPlaintext synthetic_plaintext(const CredentialId& credential "synthetic-network", "synthetic-network-passphrase", }; + plaintext.auto_lock_days = auto_lock_days; return plaintext; } m5auth::vault::VaultEnvelope make_envelope( + const Vmk& vmk, + const std::array& vault_id, + const CredentialId& credential_id, + std::uint64_t generation, + std::uint8_t nonce_start, + std::uint16_t vault_format = m5auth::vault::kVaultFormatVersion1, + std::optional auto_lock_days = std::nullopt +) { + auto plaintext = synthetic_plaintext(credential_id, auto_lock_days); + std::vector encoded; + assert(m5auth::vault::encode_plaintext(plaintext, encoded, vault_format)); + m5auth::vault_runtime::wipe_plaintext(&plaintext); + + m5auth::vault::VaultEnvelope envelope; + const auto nonce = sequence(nonce_start); + assert(m5auth::vault::encrypt_vault_with_nonce( + encoded, + vmk, + vault_id, + generation, + nonce, + envelope, + vault_format + )); + m5auth::vault_runtime::secure_zero(encoded.data(), encoded.size()); + encoded.clear(); + return envelope; +} + +m5auth::vault::VaultEnvelope make_authenticated_malformed_f2_envelope( const Vmk& vmk, const std::array& vault_id, const CredentialId& credential_id, std::uint64_t generation, std::uint8_t nonce_start ) { - auto plaintext = synthetic_plaintext(credential_id); + auto plaintext = synthetic_plaintext(credential_id, 1); std::vector encoded; - assert(m5auth::vault::encode_plaintext(plaintext, encoded)); + assert(m5auth::vault::encode_plaintext( + plaintext, + encoded, + m5auth::vault::kVaultFormatVersion2 + )); m5auth::vault_runtime::wipe_plaintext(&plaintext); + assert(encoded.size() >= 2); + // PT2 terminates with auto_lock_present=1, auto_lock_days=1. Replacing the + // authenticated day byte with 0 keeps AEAD valid while making PT2 invalid. + assert(encoded[encoded.size() - 2] == 1); + assert(encoded.back() == 1); + encoded.back() = 0; m5auth::vault::VaultEnvelope envelope; const auto nonce = sequence(nonce_start); @@ -128,7 +172,8 @@ m5auth::vault::VaultEnvelope make_envelope( vault_id, generation, nonce, - envelope + envelope, + m5auth::vault::kVaultFormatVersion2 )); m5auth::vault_runtime::secure_zero(encoded.data(), encoded.size()); encoded.clear(); @@ -163,6 +208,22 @@ void schema_states_fail_closed() { assert(runtime.metadata(&metadata) == Status::kOk); assert(metadata.state == State::kError); } + { + FakePersistence persistence; + persistence.snapshot.schema_ready = true; + persistence.snapshot.has_vault = true; + persistence.snapshot.envelope.vault_format_version = 3; + persistence.snapshot.envelope.storage_schema_version = 2; + persistence.snapshot.envelope.generation = 1; + persistence.snapshot.envelope.ciphertext = {0x01}; + Runtime runtime(persistence); + assert(runtime.initialize() == Status::kUnsupportedVaultFormat); + Metadata metadata; + assert(runtime.metadata(&metadata) == Status::kOk); + assert(metadata.state == State::kError); + assert(!metadata.recovery_reset_allowed); + assert(persistence.snapshot.has_vault); + } } void lifecycle_and_lock_gating() { @@ -203,11 +264,14 @@ void lifecycle_and_lock_gating() { Vmk wrong_vmk{}; wrong_vmk.fill(0xff); - assert(runtime.unlock(wrong_vmk) == Status::kAuthenticationFailed); + assert(runtime.unlock(wrong_vmk, 10'000) == Status::kAuthenticationFailed); assert(!runtime.unlocked()); - assert(runtime.unlock(vmk) == Status::kOk); + assert(runtime.unlock(vmk, 10'000) == Status::kOk); assert(runtime.unlocked()); + assert(!runtime.auto_lock_days().has_value()); + assert(runtime.unlocked_since_ms() == 10'000); + assert(!runtime.automatic_lock_due(UINT64_C(1000000000000))); credential_called = false; assert(runtime.with_credential( @@ -223,6 +287,7 @@ void lifecycle_and_lock_gating() { ) == Status::kOk); assert(credential_called); assert(persistence.snapshot.last_used == credential_id); + assert(runtime.unlocked_since_ms() == 10'000); bool wifi_called = false; assert(runtime.with_wifi( @@ -234,14 +299,17 @@ void lifecycle_and_lock_gating() { } ) == Status::kOk); assert(wifi_called); + assert(runtime.unlocked_since_ms() == 10'000); assert(runtime.lock() == Status::kOk); assert(!runtime.unlocked()); + assert(!runtime.auto_lock_days().has_value()); assert(runtime.with_wifi( [](const m5auth::vault::WifiRecord&) { return Status::kOk; } ) == Status::kLocked); - assert(runtime.unlock(vmk) == Status::kOk); + assert(runtime.unlock(vmk, 20'000) == Status::kOk); + assert(runtime.unlocked_since_ms() == 20'000); assert(runtime.enter_recovery_boundary() == Status::kOk); assert(!runtime.unlocked()); assert(runtime.with_credential( @@ -265,30 +333,298 @@ void generation_update_is_atomic_and_keeps_vmk() { const auto generation3 = make_envelope(vmk, vault_id, credential_id, 3, 0x91); assert(runtime.install_encrypted_vault(generation1, vmk) == Status::kOk); - assert(runtime.unlock(vmk) == Status::kOk); + assert(runtime.unlock(vmk, 5'000) == Status::kOk); persistence.replace_result = Status::kIo; - assert(runtime.update_encrypted_vault(1, generation2) == Status::kIo); + assert(runtime.update_encrypted_vault(1, generation2, 6'000) == Status::kIo); assert(runtime.unlocked()); Metadata metadata; assert(runtime.metadata(&metadata) == Status::kOk); assert(metadata.generation == 1); assert(persistence.snapshot.envelope.generation == 1); + assert(runtime.unlocked_since_ms() == 5'000); persistence.replace_result = Status::kOk; - assert(runtime.update_encrypted_vault(0, generation2) == Status::kGenerationMismatch); - assert(runtime.update_encrypted_vault(1, generation3) == Status::kGenerationMismatch); - assert(runtime.update_encrypted_vault(1, generation2) == Status::kOk); + assert(runtime.update_encrypted_vault(0, generation2, 6'000) == Status::kGenerationMismatch); + assert(runtime.update_encrypted_vault(1, generation3, 6'000) == Status::kGenerationMismatch); + assert(runtime.update_encrypted_vault(1, generation2, 6'000) == Status::kOk); assert(runtime.unlocked()); assert(runtime.metadata(&metadata) == Status::kOk); assert(metadata.generation == 2); + assert(runtime.unlocked_since_ms() == 5'000); auto tampered = generation3; tampered.tag[0] ^= 0x01; - assert(runtime.update_encrypted_vault(2, tampered) == Status::kAuthenticationFailed); + assert(runtime.update_encrypted_vault(2, tampered, 7'000) == Status::kAuthenticationFailed); assert(runtime.unlocked()); assert(runtime.metadata(&metadata) == Status::kOk); assert(metadata.generation == 2); + assert(runtime.unlocked_since_ms() == 5'000); +} + +void format_transition_matrix_and_legacy_boot() { + const Vmk vmk = sequence(0x14); + const auto vault_id = sequence(0x34); + const CredentialId credential_id = + sequence(0x54); + + FakePersistence persistence; + persistence.snapshot.schema_ready = true; + persistence.snapshot.has_vault = true; + persistence.snapshot.envelope = make_envelope( + vmk, vault_id, credential_id, 1, 0x74, + m5auth::vault::kVaultFormatVersion1 + ); + + Runtime runtime(persistence); + assert(runtime.initialize() == Status::kOk); + Metadata metadata; + assert(runtime.metadata(&metadata) == Status::kOk); + assert(metadata.state == State::kLocked); + assert(metadata.vault_format_version == m5auth::vault::kVaultFormatVersion1); + assert(metadata.generation == 1); + + const std::uint64_t unlock_start = 42'000; + assert(runtime.unlock(vmk, unlock_start) == Status::kOk); + assert(!runtime.auto_lock_days().has_value()); + + // F1 -> F1 remains valid for an active legacy Vault. + const auto generation2_f1 = make_envelope( + vmk, vault_id, credential_id, 2, 0x84, + m5auth::vault::kVaultFormatVersion1 + ); + assert(runtime.update_encrypted_vault(1, generation2_f1, unlock_start + 100) == Status::kOk); + assert(runtime.metadata(&metadata) == Status::kOk); + assert(metadata.vault_format_version == m5auth::vault::kVaultFormatVersion1); + assert(runtime.unlocked_since_ms() == unlock_start); + + // F1 -> F2 upgrades through the normal canonical generation transaction. + const auto generation3_f2 = make_envelope( + vmk, vault_id, credential_id, 3, 0x94, + m5auth::vault::kVaultFormatVersion2, + 31 + ); + bool due_after_commit = true; + assert(runtime.update_encrypted_vault( + 2, generation3_f2, unlock_start + 200, &due_after_commit + ) == Status::kOk); + assert(!due_after_commit); + assert(runtime.auto_lock_days() == std::optional(31)); + assert(runtime.metadata(&metadata) == Status::kOk); + assert(metadata.vault_format_version == m5auth::vault::kVaultFormatVersion2); + assert(runtime.unlocked_since_ms() == unlock_start); + + // F2 -> F1 is rejected even when generation/vault_id/AEAD are otherwise valid. + const auto generation4_f1 = make_envelope( + vmk, vault_id, credential_id, 4, 0xa4, + m5auth::vault::kVaultFormatVersion1 + ); + assert(runtime.update_encrypted_vault( + 3, generation4_f1, unlock_start + 300 + ) == Status::kInvalidArgument); + assert(runtime.metadata(&metadata) == Status::kOk); + assert(metadata.generation == 3); + assert(metadata.vault_format_version == m5auth::vault::kVaultFormatVersion2); + assert(runtime.auto_lock_days() == std::optional(31)); + + // F2 -> F2 remains valid. + const auto generation4_f2 = make_envelope( + vmk, vault_id, credential_id, 4, 0xb4, + m5auth::vault::kVaultFormatVersion2, + std::nullopt + ); + assert(runtime.update_encrypted_vault( + 3, generation4_f2, unlock_start + 400 + ) == Status::kOk); + assert(!runtime.auto_lock_days().has_value()); + assert(runtime.metadata(&metadata) == Status::kOk); + assert(metadata.generation == 4); + assert(metadata.vault_format_version == m5auth::vault::kVaultFormatVersion2); + + // Unknown newer formats fail closed without mutating the committed envelope. + auto unknown = make_envelope( + vmk, vault_id, credential_id, 5, 0xc4, + m5auth::vault::kVaultFormatVersion2, + 1 + ); + unknown.vault_format_version = 3; + assert(runtime.update_encrypted_vault(4, unknown, unlock_start + 500) == + Status::kUnsupportedVaultFormat); + assert(runtime.metadata(&metadata) == Status::kOk); + assert(metadata.generation == 4); + + // Reboot preserves the Format-2 envelope but starts LOCKED as before. + Runtime rebooted(persistence); + assert(rebooted.initialize() == Status::kOk); + assert(!rebooted.unlocked()); + assert(rebooted.metadata(&metadata) == Status::kOk); + assert(metadata.state == State::kLocked); + assert(metadata.vault_format_version == m5auth::vault::kVaultFormatVersion2); + assert(metadata.generation == 4); +} + +void malformed_authenticated_format2_is_rejected() { + FakePersistence persistence; + Runtime runtime(persistence); + assert(runtime.initialize() == Status::kUnprovisioned); + assert(runtime.format_for_schema2() == Status::kOk); + + const Vmk vmk = sequence(0x15); + const auto vault_id = sequence(0x35); + const CredentialId credential_id = + sequence(0x55); + const auto malformed = make_authenticated_malformed_f2_envelope( + vmk, vault_id, credential_id, 1, 0x75 + ); + + // Installation validates authenticated PT2 before committing it. + assert(runtime.install_encrypted_vault(malformed, vmk) == Status::kCorrupt); + assert(!persistence.snapshot.has_vault); +} + +void automatic_lock_boundaries_and_activity_do_not_extend_session() { + FakePersistence persistence; + Runtime runtime(persistence); + assert(runtime.initialize() == Status::kUnprovisioned); + assert(runtime.format_for_schema2() == Status::kOk); + + const Vmk vmk = sequence(0x16); + const auto vault_id = sequence(0x36); + const CredentialId credential_id = + sequence(0x56); + const auto one_day = make_envelope( + vmk, vault_id, credential_id, 1, 0x76, + m5auth::vault::kVaultFormatVersion2, + 1 + ); + assert(runtime.install_encrypted_vault(one_day, vmk) == Status::kOk); + + const std::uint64_t start = 123'456; + assert(runtime.unlock(vmk, start) == Status::kOk); + assert(runtime.auto_lock_days() == std::optional(1)); + assert(!runtime.automatic_lock_due(start + m5auth::vault_runtime::kMillisecondsPerDay - 1)); + + // Normal secret access/navigation state updates never alter the unlock origin. + assert(runtime.with_credential( + credential_id, + [](const m5auth::vault::CredentialRecord&) { return Status::kOk; } + ) == Status::kOk); + assert(runtime.set_last_used(credential_id) == Status::kOk); + assert(runtime.with_wifi( + [](const m5auth::vault::WifiRecord&) { return Status::kOk; } + ) == Status::kOk); + assert(runtime.unlocked_since_ms() == start); + assert(runtime.automatic_lock_due(start + m5auth::vault_runtime::kMillisecondsPerDay)); + + // Protocol housekeeping executes the actual shared Lock boundary; Runtime's + // lock primitive verifies the resulting credential/VMK gating and cleanup. + assert(runtime.lock() == Status::kOk); + assert(!runtime.unlocked()); + assert(runtime.with_credential( + credential_id, + [](const m5auth::vault::CredentialRecord&) { return Status::kOk; } + ) == Status::kLocked); + + // A fresh re-unlock gets a new continuous-lifetime origin. + const std::uint64_t second_start = start + 2 * m5auth::vault_runtime::kMillisecondsPerDay; + assert(runtime.unlock(vmk, second_start) == Status::kOk); + assert(runtime.unlocked_since_ms() == second_start); + assert(!runtime.automatic_lock_due( + second_start + m5auth::vault_runtime::kMillisecondsPerDay - 1 + )); + + // Upgrade policy to the maximum boundary and verify exact 31-day expiry. + const auto thirty_one_days = make_envelope( + vmk, vault_id, credential_id, 2, 0x86, + m5auth::vault::kVaultFormatVersion2, + 31 + ); + bool due_after_commit = true; + assert(runtime.update_encrypted_vault( + 1, thirty_one_days, second_start + 1, &due_after_commit + ) == Status::kOk); + assert(!due_after_commit); + assert(!runtime.automatic_lock_due( + second_start + 31 * m5auth::vault_runtime::kMillisecondsPerDay - 1 + )); + assert(runtime.automatic_lock_due( + second_start + 31 * m5auth::vault_runtime::kMillisecondsPerDay + )); +} + +void committed_setting_changes_use_original_unlock_origin() { + FakePersistence persistence; + Runtime runtime(persistence); + assert(runtime.initialize() == Status::kUnprovisioned); + assert(runtime.format_for_schema2() == Status::kOk); + + const Vmk vmk = sequence(0x17); + const auto vault_id = sequence(0x37); + const CredentialId credential_id = + sequence(0x57); + const auto disabled = make_envelope( + vmk, vault_id, credential_id, 1, 0x77, + m5auth::vault::kVaultFormatVersion2, + std::nullopt + ); + assert(runtime.install_encrypted_vault(disabled, vmk) == Status::kOk); + + const std::uint64_t start = 10'000; + assert(runtime.unlock(vmk, start) == Status::kOk); + assert(!runtime.auto_lock_days().has_value()); + + // Disabled -> 31 days applies from the original start, not update time. + const auto days31 = make_envelope( + vmk, vault_id, credential_id, 2, 0x87, + m5auth::vault::kVaultFormatVersion2, + 31 + ); + bool due_after_commit = true; + const std::uint64_t two_days_later = start + 2 * m5auth::vault_runtime::kMillisecondsPerDay; + assert(runtime.update_encrypted_vault( + 1, days31, two_days_later, &due_after_commit + ) == Status::kOk); + assert(!due_after_commit); + assert(runtime.unlocked_since_ms() == start); + + // 31 -> 1 day is already expired relative to the unchanged start. + const auto days1 = make_envelope( + vmk, vault_id, credential_id, 3, 0x97, + m5auth::vault::kVaultFormatVersion2, + 1 + ); + assert(runtime.update_encrypted_vault( + 2, days1, two_days_later, &due_after_commit + ) == Status::kOk); + assert(due_after_commit); + assert(runtime.unlocked_since_ms() == start); + assert(runtime.auto_lock_days() == std::optional(1)); + + // A failed candidate disable does not weaken the committed one-day policy. + const auto disabled4 = make_envelope( + vmk, vault_id, credential_id, 4, 0xa7, + m5auth::vault::kVaultFormatVersion2, + std::nullopt + ); + persistence.replace_result = Status::kIo; + due_after_commit = false; + assert(runtime.update_encrypted_vault( + 3, disabled4, two_days_later, &due_after_commit + ) == Status::kIo); + assert(!due_after_commit); + assert(runtime.auto_lock_days() == std::optional(1)); + assert(runtime.automatic_lock_due(two_days_later)); + + // A successfully committed disable cancels the deadline without resetting + // the original session timestamp. + persistence.replace_result = Status::kOk; + assert(runtime.update_encrypted_vault( + 3, disabled4, two_days_later, &due_after_commit + ) == Status::kOk); + assert(!due_after_commit); + assert(!runtime.auto_lock_days().has_value()); + assert(runtime.unlocked_since_ms() == start); + assert(!runtime.automatic_lock_due(UINT64_C(1000000000000))); } void reboot_starts_locked_and_factory_reset_wipes_state() { @@ -297,34 +633,44 @@ void reboot_starts_locked_and_factory_reset_wipes_state() { const auto vault_id = sequence(0x32); const CredentialId credential_id = sequence(0x52); - const auto envelope = make_envelope(vmk, vault_id, credential_id, 1, 0x72); + const auto envelope = make_envelope( + vmk, vault_id, credential_id, 1, 0x72, + m5auth::vault::kVaultFormatVersion2, + 31 + ); { Runtime first_boot(persistence); assert(first_boot.initialize() == Status::kUnprovisioned); assert(first_boot.format_for_schema2() == Status::kOk); assert(first_boot.install_encrypted_vault(envelope, vmk) == Status::kOk); - assert(first_boot.unlock(vmk) == Status::kOk); + assert(first_boot.unlock(vmk, 50'000) == Status::kOk); assert(first_boot.unlocked()); + assert(first_boot.auto_lock_days() == std::optional(31)); } Runtime second_boot(persistence); assert(second_boot.initialize() == Status::kOk); assert(!second_boot.unlocked()); + assert(!second_boot.auto_lock_days().has_value()); Metadata metadata; assert(second_boot.metadata(&metadata) == Status::kOk); assert(metadata.state == State::kLocked); assert(metadata.generation == 1); + assert(metadata.vault_format_version == m5auth::vault::kVaultFormatVersion2); - assert(second_boot.unlock(vmk) == Status::kOk); + assert(second_boot.unlock(vmk, 60'000) == Status::kOk); + assert(second_boot.auto_lock_days() == std::optional(31)); assert(second_boot.fatal_security_error() == Status::kOk); assert(!second_boot.unlocked()); assert(second_boot.metadata(&metadata) == Status::kOk); assert(metadata.state == State::kError); - // A reboot-equivalent initialize wipes any runtime key and reloads ciphertext. + // A reboot-equivalent initialize wipes any runtime key and policy state and + // reloads only authenticated ciphertext. assert(second_boot.initialize() == Status::kOk); assert(!second_boot.unlocked()); + assert(!second_boot.auto_lock_days().has_value()); assert(second_boot.factory_reset() == Status::kOk); assert(second_boot.metadata(&metadata) == Status::kOk); assert(metadata.state == State::kUnprovisioned); @@ -332,17 +678,19 @@ void reboot_starts_locked_and_factory_reset_wipes_state() { assert(!metadata.has_vault); } -void explicit_plaintext_wipe_clears_secret_fields() { +void explicit_plaintext_wipe_clears_secret_fields_and_policy() { const CredentialId credential_id = sequence(0x53); - auto plaintext = synthetic_plaintext(credential_id); + auto plaintext = synthetic_plaintext(credential_id, 7); assert(!plaintext.credentials.empty()); assert(!plaintext.credentials[0].secret.empty()); assert(plaintext.wifi.has_value()); + assert(plaintext.auto_lock_days.has_value()); m5auth::vault_runtime::wipe_plaintext(&plaintext); assert(plaintext.credentials.empty()); assert(!plaintext.wifi.has_value()); + assert(!plaintext.auto_lock_days.has_value()); } } // namespace @@ -351,7 +699,11 @@ int main() { schema_states_fail_closed(); lifecycle_and_lock_gating(); generation_update_is_atomic_and_keeps_vmk(); + format_transition_matrix_and_legacy_boot(); + malformed_authenticated_format2_is_rejected(); + automatic_lock_boundaries_and_activity_do_not_extend_session(); + committed_setting_changes_use_original_unlock_origin(); reboot_starts_locked_and_factory_reset_wipes_state(); - explicit_plaintext_wipe_clears_secret_fields(); + explicit_plaintext_wipe_clears_secret_fields_and_policy(); return 0; } From 4ee965909874ddb6944fe705f09656a9767f2a8b Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:20:27 +0900 Subject: [PATCH 16/30] test(runtime): pin automatic lock security invariants --- tests/vault_runtime_contract_test.py | 174 ++++++++++++++++++++++++--- 1 file changed, 156 insertions(+), 18 deletions(-) diff --git a/tests/vault_runtime_contract_test.py b/tests/vault_runtime_contract_test.py index 5e3d4034..758fcf3c 100644 --- a/tests/vault_runtime_contract_test.py +++ b/tests/vault_runtime_contract_test.py @@ -6,11 +6,15 @@ HEADER = ROOT / "firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp" NVS = ROOT / "firmware/components/m5auth_vault_runtime/nvs_persistence.cpp" COMPAT_NVS = ROOT / "firmware/components/m5auth_vault_runtime/compatible_nvs_persistence.cpp" +VAULT_HEADER = ROOT / "firmware/components/m5auth_vault/include/m5auth/vault.hpp" VAULT_FORMAT = ROOT / "firmware/components/m5auth_vault/vault_format.cpp" VAULT_CRYPTO = ROOT / "firmware/components/m5auth_vault/vault_crypto.cpp" TOTP_CORE = ROOT / "firmware/components/m5auth_totp/totp_core.cpp" TIME_SERVICE = ROOT / "firmware/components/m5auth_time/canonical_time_service.cpp" CORE_METADATA = ROOT / "firmware/components/m5auth_core/include/m5auth/core/metadata.hpp" +PROTOCOL = ROOT / "firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp" +PROTOCOL_HEADER = ROOT / "firmware/components/m5auth_provisioning/include/m5auth/provisioning/canonical_protocol_v2.hpp" +V2_STATE = ROOT / "firmware/components/m5auth_provisioning/canonical_v2_state.cpp" class VaultRuntimeContractTest(unittest.TestCase): @@ -20,22 +24,42 @@ def setUpClass(cls) -> None: cls.header = HEADER.read_text(encoding="utf-8") cls.nvs = NVS.read_text(encoding="utf-8") cls.compat_nvs = COMPAT_NVS.read_text(encoding="utf-8") + cls.vault_header = VAULT_HEADER.read_text(encoding="utf-8") cls.vault_format = VAULT_FORMAT.read_text(encoding="utf-8") cls.vault_crypto = VAULT_CRYPTO.read_text(encoding="utf-8") cls.totp_core = TOTP_CORE.read_text(encoding="utf-8") cls.time_service = TIME_SERVICE.read_text(encoding="utf-8") cls.core_metadata = CORE_METADATA.read_text(encoding="utf-8") + cls.protocol = PROTOCOL.read_text(encoding="utf-8") + cls.protocol_header = PROTOCOL_HEADER.read_text(encoding="utf-8") + cls.v2_state = V2_STATE.read_text(encoding="utf-8") cls.component = ( - cls.runtime + "\n" + cls.header + "\n" + cls.nvs + "\n" + cls.compat_nvs + cls.runtime + + "\n" + + cls.header + + "\n" + + cls.nvs + + "\n" + + cls.compat_nvs ) - def test_vmk_is_explicitly_wiped_and_destructor_wipes(self) -> None: + def test_vmk_and_unlock_policy_state_are_explicitly_cleared(self) -> None: self.assertIn("secure_zero(vmk_.data(), vmk_.size())", self.runtime) - self.assertIn("Runtime::~Runtime()", self.runtime) + self.assertIn("void Runtime::clear_unlock_session_state()", self.runtime) + clear_start = self.runtime.index("void Runtime::clear_unlock_session_state()") + clear_body = self.runtime[clear_start : clear_start + 300] + self.assertIn("wipe_vmk();", clear_body) + self.assertIn("auto_lock_days_.reset();", clear_body) + self.assertIn("unlocked_since_ms_ = 0;", clear_body) + self.assertIn("unlock_session_active_ = false;", clear_body) + destructor_start = self.runtime.index("Runtime::~Runtime()") - self.assertIn("wipe_vmk();", self.runtime[destructor_start:destructor_start + 120]) + self.assertIn( + "clear_unlock_session_state();", + self.runtime[destructor_start : destructor_start + 160], + ) - def test_all_trust_root_change_entry_points_destroy_vmk(self) -> None: + def test_all_trust_root_change_entry_points_destroy_unlock_session_state(self) -> None: self.assertIn("Status enter_recovery_boundary();", self.header) self.assertIn("Status enter_registration_replacement_boundary()", self.header) self.assertIn("Status enter_vmk_rekey_boundary()", self.header) @@ -44,11 +68,26 @@ def test_all_trust_root_change_entry_points_destroy_vmk(self) -> None: 2, ) recovery_start = self.runtime.index("Status Runtime::enter_recovery_boundary()") - self.assertIn("wipe_vmk();", self.runtime[recovery_start:recovery_start + 240]) + self.assertIn( + "clear_unlock_session_state();", + self.runtime[recovery_start : recovery_start + 300], + ) + lock_start = self.runtime.index("Status Runtime::lock()") + self.assertIn( + "clear_unlock_session_state();", + self.runtime[lock_start : lock_start + 240], + ) + fatal_start = self.runtime.index("Status Runtime::fatal_security_error()") + self.assertIn( + "clear_unlock_session_state();", + self.runtime[fatal_start : fatal_start + 240], + ) def test_locked_paths_gate_plaintext_access(self) -> None: self.assertGreaterEqual( - self.runtime.count("state_ != State::kUnlocked || !vmk_present_ || !has_vault_"), + self.runtime.count( + "state_ != State::kUnlocked || !vmk_present_ || !has_vault_" + ), 3, ) self.assertIn("return Status::kLocked;", self.runtime) @@ -59,12 +98,14 @@ def test_transient_plaintext_has_explicit_wipe_seams(self) -> None: self.assertIn("credential.credential_id.fill(0);", self.runtime) self.assertIn("wipe_bytes(&credential.secret);", self.runtime) self.assertIn("wipe_string(&plaintext->wifi->password);", self.runtime) + self.assertIn("plaintext->auto_lock_days.reset();", self.runtime) def test_vault_codec_zeroizes_internal_plaintext_temporaries(self) -> None: self.assertIn("ByteVectorWipeGuard candidate_wipe(candidate);", self.vault_format) self.assertIn("PlaintextWipeGuard candidate_wipe(candidate);", self.vault_format) self.assertIn("candidate.credentials.reserve(count);", self.vault_format) self.assertIn("wipe_plaintext_candidate(&value);", self.vault_format) + self.assertIn("value->auto_lock_days.reset();", self.vault_format) read_text_start = self.vault_format.index("bool read_sized_text(std::string& value)") read_text_end = self.vault_format.index("bool at_end() const", read_text_start) read_text = self.vault_format[read_text_start:read_text_end] @@ -77,19 +118,32 @@ def test_vault_crypto_zeroization_preserves_public_failure_contract(self) -> Non self.assertIn("secure_zero_memory(candidate.data(), candidate.size())", self.vault_crypto) decrypt_start = self.vault_crypto.index("bool decrypt_vault(") - decrypt_end = self.vault_crypto.index("bool wrap_vmk_with_key_and_nonce(", decrypt_start) + decrypt_end = self.vault_crypto.index( + "bool wrap_vmk_with_key_and_nonce(", decrypt_start + ) decrypt_body = self.vault_crypto[decrypt_start:decrypt_end] self.assertIn("clear_bytes(candidate);", decrypt_body) self.assertIn("plaintext.swap(candidate);", decrypt_body) - prefix_before_candidate = decrypt_body[:decrypt_body.index("std::vector candidate;")] + prefix_before_candidate = decrypt_body[ + : decrypt_body.index("std::vector candidate;") + ] self.assertNotIn("clear_bytes(plaintext);", prefix_before_candidate) self.assertNotIn("plaintext.clear();", prefix_before_candidate) unwrap_start = self.vault_crypto.index("bool unwrap_vmk_with_key(") unwrap_body = self.vault_crypto[unwrap_start:] - prefix_before_local_candidate = unwrap_body[:unwrap_body.index("std::array candidate{};")] - self.assertNotIn("secure_zero_memory(vmk.data(), vmk.size())", prefix_before_local_candidate) - self.assertIn("secure_zero_memory(candidate.data(), candidate.size())", unwrap_body) + prefix_before_local_candidate = unwrap_body[ + : unwrap_body.index( + "std::array candidate{};" + ) + ] + self.assertNotIn( + "secure_zero_memory(vmk.data(), vmk.size())", + prefix_before_local_candidate, + ) + self.assertIn( + "secure_zero_memory(candidate.data(), candidate.size())", unwrap_body + ) def test_totp_working_buffers_are_zeroized(self) -> None: self.assertIn("clear_bytes(&key);", self.totp_core) @@ -114,7 +168,9 @@ def test_nvs_update_uses_inactive_slot_then_active_pointer(self) -> None: self.assertLess(staged_blob, phase2) self.assertLess(phase2, active_pointer) self.assertIn("nvs_commit(handle)", self.nvs[staged_blob:phase2]) - self.assertIn("nvs_commit(handle)", self.nvs[active_pointer:active_pointer + 240]) + self.assertIn( + "nvs_commit(handle)", self.nvs[active_pointer : active_pointer + 240] + ) self.assertIn("Validate the durable staged copy", self.nvs[staged_blob:phase2]) def test_legacy_schema1_probe_is_read_only_and_positive_only(self) -> None: @@ -137,22 +193,104 @@ def test_only_opaque_last_used_is_persisted_outside_ciphertext(self) -> None: "wifi_password", "passphrase", "plaintext_vmk", + "auto_lock_days", ): self.assertNotIn(forbidden, self.nvs.lower()) - def test_schema2_is_canonically_activated(self) -> None: + def test_schema_and_protocol_versions_stay_fixed_while_vault_support_expands(self) -> None: self.assertIn("kStorageSchemaVersion = 2", self.header) - self.assertIn("kTargetStorageSchemaVersion", self.nvs) + self.assertIn("kTargetStorageSchemaVersion = 2", self.vault_header) self.assertIn("kProtocolVersion = 2", self.core_metadata) self.assertIn("kStorageSchemaVersion = 2", self.core_metadata) - self.assertIn("kVaultFormatVersion = 1", self.core_metadata) + self.assertIn("kVaultFormatVersion1 = 1", self.vault_header) + self.assertIn("kVaultFormatVersion2 = 2", self.vault_header) + self.assertIn("kCurrentVaultFormatVersion = kVaultFormatVersion2", self.vault_header) self.assertNotIn("kProtocolVersion", self.component) def test_known_schema1_requires_reprovision_and_newer_fails_closed(self) -> None: self.assertIn("if (schema == 1) return Status::kReprovisionRequired;", self.nvs) - self.assertIn("if (schema > kStorageSchemaVersion) return Status::kUnsupportedSchema;", self.nvs) + self.assertIn( + "if (schema > kStorageSchemaVersion) return Status::kUnsupportedSchema;", + self.nvs, + ) self.assertIn("CompatibleNvsPersistence", self.header) - self.assertNotIn("format_schema2();", self.runtime[self.runtime.index("Status Runtime::initialize()"):self.runtime.index("Status Runtime::reload_after_persistence()")]) + initialize = self.runtime[ + self.runtime.index("Status Runtime::initialize()") : self.runtime.index( + "Status Runtime::reload_after_persistence()" + ) + ] + self.assertNotIn("format_schema2();", initialize) + self.assertIn("Status::kUnsupportedVaultFormat", initialize) + self.assertIn("recovery_reset_allowed_ = false;", initialize) + + def test_format2_policy_is_authenticated_and_not_a_side_channel(self) -> None: + self.assertIn('kPlaintextMagicV2[] = "M5AUTH-VLT-PT2"', self.vault_format) + self.assertIn('kVaultAadMagicV2[] = "M5AUTH-VLT-AAD2"', self.vault_format) + self.assertIn("value.auto_lock_days.has_value()", self.vault_format) + self.assertIn("days < kMinAutoLockDays", self.vault_format) + self.assertIn("days > kMaxAutoLockDays", self.vault_format) + self.assertIn("if (!reader.at_end()) return false;", self.vault_format) + self.assertIn("decoded_format != envelope.vault_format_version", self.runtime) + + def test_runtime_transition_guard_prevents_format2_downgrade(self) -> None: + update_start = self.runtime.index("Status Runtime::update_encrypted_vault(") + update_end = self.runtime.index("Status Runtime::metadata(", update_start) + update = self.runtime[update_start:update_end] + self.assertIn("vault::is_supported_vault_format", update) + self.assertIn("envelope_.vault_format_version == vault::kVaultFormatVersion2", update) + self.assertIn("envelope.vault_format_version == vault::kVaultFormatVersion1", update) + self.assertIn("return Status::kInvalidArgument;", update) + self.assertIn("auto_lock_days_ = candidate_policy;", update) + self.assertIn("automatic_lock_due(now_ms)", update) + + def test_automatic_lock_uses_monotonic_elapsed_runtime_only(self) -> None: + self.assertIn("kMillisecondsPerDay = 86'400'000ULL", self.header) + due_start = self.runtime.index("bool Runtime::automatic_lock_due(") + due = self.runtime[due_start : due_start + 700] + self.assertIn("now_ms - unlocked_since_ms_ >= lifetime_ms", due) + self.assertNotIn("time(", due) + self.assertNotIn("unix", due.lower()) + self.assertNotIn("ntp", due.lower()) + self.assertIn("esp_timer_get_time()", self.v2_state) + + def test_automatic_expiry_and_explicit_lock_share_one_boundary(self) -> None: + self.assertIn("vault_runtime::Status lock_security_boundary();", self.protocol_header) + lock_start = self.protocol.index( + "vault_runtime::Status CanonicalProtocolV2Handler::lock_security_boundary()" + ) + housekeeping_start = self.protocol.index( + "void CanonicalProtocolV2Handler::housekeeping(", lock_start + ) + lock_body = self.protocol[lock_start:housekeeping_start] + self.assertIn("session_handler_.disconnect();", lock_body) + self.assertIn("vmk_sink_.cancel_pending();", lock_body) + self.assertIn("cancel_recovery_reset();", lock_body) + self.assertIn("time_service_.with_secret_boundary", lock_body) + self.assertIn("runtime_.lock();", lock_body) + self.assertIn("notify_security_boundary();", lock_body) + + housekeeping_end = self.protocol.index( + "RecoveryResetDecision CanonicalProtocolV2Handler::recovery_reset_decision", + housekeeping_start, + ) + housekeeping = self.protocol[housekeeping_start:housekeeping_end] + self.assertIn("runtime_.automatic_lock_due(now_ms)", housekeeping) + self.assertIn("lock_security_boundary();", housekeeping) + + explicit_lock = self.protocol[self.protocol.index('operation == "device.lock"') :] + explicit_lock = explicit_lock[: explicit_lock.index('operation == "factory_reset"')] + self.assertIn("lock_security_boundary();", explicit_lock) + self.assertNotIn("runtime_.lock();", explicit_lock) + + def test_hello_advertises_dual_format_capability_and_persisted_format(self) -> None: + hello_start = self.protocol.index("std::string hello_success(") + hello_end = self.protocol.index("std::string time_status_success", hello_start) + hello = self.protocol[hello_start:hello_end] + self.assertIn('"supported_vault_formats"', hello) + self.assertIn("vault::kVaultFormatVersion1", hello) + self.assertIn("vault::kVaultFormatVersion2", hello) + self.assertIn("runtime_metadata.vault_format_version", hello) + self.assertIn('"vault_format_version"', hello) def test_component_has_no_project_efuse_or_secret_logging_path(self) -> None: lowered = self.component.lower() From 4a7a4c0522408d211875137b45db53416a93e8f4 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:20:54 +0900 Subject: [PATCH 17/30] test(protocol): pin automatic lock and vault format contract --- .../automatic_lock_protocol_contract_test.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/automatic_lock_protocol_contract_test.py diff --git a/tests/automatic_lock_protocol_contract_test.py b/tests/automatic_lock_protocol_contract_test.py new file mode 100644 index 00000000..514e8563 --- /dev/null +++ b/tests/automatic_lock_protocol_contract_test.py @@ -0,0 +1,100 @@ +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +PROTOCOL = ROOT / "firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp" +PROTOCOL_HEADER = ROOT / "firmware/components/m5auth_provisioning/include/m5auth/provisioning/canonical_protocol_v2.hpp" +RUNTIME = ROOT / "firmware/components/m5auth_vault_runtime/runtime.cpp" +NVS = ROOT / "firmware/components/m5auth_vault_runtime/nvs_persistence.cpp" +VAULT_HEADER = ROOT / "firmware/components/m5auth_vault/include/m5auth/vault.hpp" + + +class AutomaticLockProtocolContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.protocol = PROTOCOL.read_text(encoding="utf-8") + cls.protocol_header = PROTOCOL_HEADER.read_text(encoding="utf-8") + cls.runtime = RUNTIME.read_text(encoding="utf-8") + cls.nvs = NVS.read_text(encoding="utf-8") + cls.vault_header = VAULT_HEADER.read_text(encoding="utf-8") + + def test_protocol2_envelope_parser_accepts_only_formats_one_and_two(self) -> None: + start = self.protocol.index("bool read_envelope(") + end = self.protocol.index("bool response_ok(", start) + parser = self.protocol[start:end] + self.assertIn("vault::kVaultFormatVersion1", parser) + self.assertIn("vault::kVaultFormatVersion2", parser) + self.assertIn("vault_format != vault::kVaultFormatVersion1", parser) + self.assertIn("vault_format != vault::kVaultFormatVersion2", parser) + self.assertIn("storage_schema != vault::kTargetStorageSchemaVersion", parser) + + def test_hello_reports_persisted_format_and_supported_formats(self) -> None: + start = self.protocol.index("std::string hello_success(") + end = self.protocol.index("std::string time_status_success", start) + hello = self.protocol[start:end] + self.assertIn("runtime_metadata.vault_format_version", hello) + self.assertIn('"vault_format"', hello) + self.assertIn('"vault_format_version"', hello) + self.assertIn('"supported_vault_formats"', hello) + self.assertIn("cJSON_CreateNumber(vault::kVaultFormatVersion1)", hello) + self.assertIn("cJSON_CreateNumber(vault::kVaultFormatVersion2)", hello) + + def test_vault_update_applies_policy_only_after_runtime_commit(self) -> None: + start = self.protocol.index('operation == "vault.update"') + end = self.protocol.index('operation == "vault.rekey"', start) + update = self.protocol[start:end] + self.assertIn("runtime_.update_encrypted_vault", update) + self.assertIn("automatic_lock_due_after_commit", update) + self.assertIn("lock_security_boundary();", update) + self.assertLess( + update.index("runtime_.update_encrypted_vault"), + update.index("automatic_lock_due_after_commit"), + ) + + def test_expiry_and_explicit_lock_use_same_security_boundary(self) -> None: + self.assertIn("vault_runtime::Status lock_security_boundary();", self.protocol_header) + housekeeping_start = self.protocol.index( + "void CanonicalProtocolV2Handler::housekeeping(" + ) + housekeeping_end = self.protocol.index( + "RecoveryResetDecision CanonicalProtocolV2Handler::recovery_reset_decision", + housekeeping_start, + ) + housekeeping = self.protocol[housekeeping_start:housekeeping_end] + self.assertIn("runtime_.automatic_lock_due(now_ms)", housekeeping) + self.assertIn("lock_security_boundary();", housekeeping) + + device_lock_start = self.protocol.index('operation == "device.lock"') + factory_reset_start = self.protocol.index( + 'operation == "factory_reset"', device_lock_start + ) + device_lock = self.protocol[device_lock_start:factory_reset_start] + self.assertIn("lock_security_boundary();", device_lock) + self.assertNotIn("runtime_.lock();", device_lock) + + def test_runtime_rejects_format2_to_format1_downgrade(self) -> None: + start = self.runtime.index("Status Runtime::update_encrypted_vault(") + end = self.runtime.index("Status Runtime::metadata(", start) + update = self.runtime[start:end] + self.assertIn("envelope_.vault_format_version == vault::kVaultFormatVersion2", update) + self.assertIn("envelope.vault_format_version == vault::kVaultFormatVersion1", update) + self.assertIn("return Status::kInvalidArgument;", update) + + def test_unknown_persisted_format_is_non_destructive_fail_closed(self) -> None: + self.assertIn("Status::kUnsupportedVaultFormat", self.nvs) + self.assertIn("vault::is_supported_vault_format", self.nvs) + initialize_start = self.runtime.index("Status Runtime::initialize()") + initialize_end = self.runtime.index("Status Runtime::reload_after_persistence()") + initialize = self.runtime[initialize_start:initialize_end] + self.assertIn("Status::kUnsupportedVaultFormat", initialize) + self.assertIn("recovery_reset_allowed_ = false;", initialize) + + def test_version_tuple_remains_protocol2_storage2_with_dual_vault_formats(self) -> None: + self.assertIn("kTargetStorageSchemaVersion = 2", self.vault_header) + self.assertIn("kVaultFormatVersion1 = 1", self.vault_header) + self.assertIn("kVaultFormatVersion2 = 2", self.vault_header) + self.assertNotIn("kTargetStorageSchemaVersion = 3", self.vault_header) + + +if __name__ == "__main__": + unittest.main() From b3fb3946c4569d903bf2946b72fb19e20529807b Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:21:29 +0900 Subject: [PATCH 18/30] ci(security): run automatic lock protocol contract --- .github/workflows/security.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index bec9693e..feee5d3e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -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 From 44d18cb070eff93ea518d38e91de2957871f635a Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:22:10 +0900 Subject: [PATCH 19/30] test(protocol): verify post-commit auto-lock ordering --- tests/automatic_lock_protocol_contract_test.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/automatic_lock_protocol_contract_test.py b/tests/automatic_lock_protocol_contract_test.py index 514e8563..80897e36 100644 --- a/tests/automatic_lock_protocol_contract_test.py +++ b/tests/automatic_lock_protocol_contract_test.py @@ -43,13 +43,17 @@ def test_vault_update_applies_policy_only_after_runtime_commit(self) -> None: start = self.protocol.index('operation == "vault.update"') end = self.protocol.index('operation == "vault.rekey"', start) update = self.protocol[start:end] - self.assertIn("runtime_.update_encrypted_vault", update) + commit_call = "runtime_.update_encrypted_vault" + due_check = ( + "if (status == vault_runtime::Status::kOk && " + "automatic_lock_due_after_commit)" + ) + self.assertIn(commit_call, update) self.assertIn("automatic_lock_due_after_commit", update) + self.assertIn(due_check, update) self.assertIn("lock_security_boundary();", update) - self.assertLess( - update.index("runtime_.update_encrypted_vault"), - update.index("automatic_lock_due_after_commit"), - ) + self.assertLess(update.index(commit_call), update.index(due_check)) + self.assertLess(update.index(due_check), update.index("lock_security_boundary();")) def test_expiry_and_explicit_lock_use_same_security_boundary(self) -> None: self.assertIn("vault_runtime::Status lock_security_boundary();", self.protocol_header) From 333539caa055a4e0392b1ffa35d67fac9083e8da Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:27:03 +0900 Subject: [PATCH 20/30] test(device): follow consolidated unlock-state cleanup boundary --- tests/sticks3_runtime_contract_test.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/sticks3_runtime_contract_test.py b/tests/sticks3_runtime_contract_test.py index 2e49026a..1fae86a1 100644 --- a/tests/sticks3_runtime_contract_test.py +++ b/tests/sticks3_runtime_contract_test.py @@ -186,7 +186,7 @@ def test_usb_serial_fragments_are_buffered_until_newline_and_wiped_on_disconnect self.assertIn("input.data() + buffered_input", app) self.assertIn("input.size() - buffered_input", app) self.assertIn("buffered_input += std::strlen(input.data() + buffered_input);", app) - self.assertIn("buffered_input > 0 && input[buffered_input - 1] == '\\n'", app) + self.assertIn("buffered_input > 0 && input[buffered_input - 1] == '\n'", app) self.assertIn("USB Serial/JTAG VFS reads are non-blocking", app) self.assertIn("discard_oversized_input = true;", app) self.assertRegex( @@ -250,8 +250,12 @@ def test_issue_107_reboot_with_persisted_vault_starts_locked_without_vmk(self) - start = runtime.index("Status Runtime::initialize()") end = runtime.index("\nStatus Runtime::reload_after_persistence()", start) initialize = runtime[start:end] + clear_start = runtime.index("void Runtime::clear_unlock_session_state()") + clear_end = runtime.index("\nvoid Runtime::begin_unlock_session(", clear_start) + clear = runtime[clear_start:clear_end] - self.assertIn("wipe_vmk();", initialize) + self.assertIn("clear_unlock_session_state();", initialize) + self.assertIn("wipe_vmk();", clear) self.assertIn("if (!snapshot.has_vault) return Status::kUnprovisioned;", initialize) self.assertIn("has_vault_ = true;", initialize) self.assertIn("state_ = State::kLocked;", initialize) @@ -300,7 +304,7 @@ def test_issue_86_response_write_diagnostics_do_not_overwrite_themselves(self) - app = APP_MAIN.read_text(encoding="utf-8") self.assertIn("const std::size_t fwrite_bytes = std::fwrite", app) - self.assertIn("frame.push_back('\\n')", app) + self.assertIn("frame.push_back('\n')", app) self.assertNotIn("const int newline_result = std::fputc", app) self.assertIn("const int fflush_result = std::fflush(stdout);", app) self.assertIn("const int ferror_value = std::ferror(stdout);", app) @@ -350,4 +354,4 @@ def test_issue_86_timing_snapshot_uses_rtc_noinit_not_flash(self) -> None: if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From 058dc65d27e376b90620a1f73791f2202d52e6a1 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:27:28 +0900 Subject: [PATCH 21/30] build(provisioning): declare esp_timer monotonic dependency --- firmware/components/m5auth_provisioning/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/firmware/components/m5auth_provisioning/CMakeLists.txt b/firmware/components/m5auth_provisioning/CMakeLists.txt index 1cc89cd0..110568b6 100644 --- a/firmware/components/m5auth_provisioning/CMakeLists.txt +++ b/firmware/components/m5auth_provisioning/CMakeLists.txt @@ -13,5 +13,6 @@ idf_component_register( m5auth_vault m5auth_vault_runtime esp_hw_support + esp_timer json ) From f3903cdc706930b0a2f250bb7070617424d46ee8 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:29:15 +0900 Subject: [PATCH 22/30] test(device): preserve literal newline contract escapes --- tests/sticks3_runtime_contract_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sticks3_runtime_contract_test.py b/tests/sticks3_runtime_contract_test.py index 1fae86a1..32664a2e 100644 --- a/tests/sticks3_runtime_contract_test.py +++ b/tests/sticks3_runtime_contract_test.py @@ -186,7 +186,7 @@ def test_usb_serial_fragments_are_buffered_until_newline_and_wiped_on_disconnect self.assertIn("input.data() + buffered_input", app) self.assertIn("input.size() - buffered_input", app) self.assertIn("buffered_input += std::strlen(input.data() + buffered_input);", app) - self.assertIn("buffered_input > 0 && input[buffered_input - 1] == '\n'", app) + self.assertIn("buffered_input > 0 && input[buffered_input - 1] == '\\n'", app) self.assertIn("USB Serial/JTAG VFS reads are non-blocking", app) self.assertIn("discard_oversized_input = true;", app) self.assertRegex( @@ -243,7 +243,7 @@ def test_issue_107_canonical_vault_stays_in_auth_nvs(self) -> None: self.assertIn('constexpr char kPartitionLabel[] = "auth_nvs";', persistence) self.assertIn("nvs_flash_init_partition(kPartitionLabel)", persistence) self.assertIn("nvs_open_from_partition(", persistence) - self.assertIn("kPartitionLabel, kNamespace, mode, handle", persistence) + self.assertIn("kPartitionLabel, kNamespace, mode", persistence) def test_issue_107_reboot_with_persisted_vault_starts_locked_without_vmk(self) -> None: runtime = VAULT_RUNTIME_CPP.read_text(encoding="utf-8") @@ -304,7 +304,7 @@ def test_issue_86_response_write_diagnostics_do_not_overwrite_themselves(self) - app = APP_MAIN.read_text(encoding="utf-8") self.assertIn("const std::size_t fwrite_bytes = std::fwrite", app) - self.assertIn("frame.push_back('\n')", app) + self.assertIn("frame.push_back('\\n')", app) self.assertNotIn("const int newline_result = std::fputc", app) self.assertIn("const int fflush_result = std::fflush(stdout);", app) self.assertIn("const int ferror_value = std::ferror(stdout);", app) From 07965180b9a653d604c785a1846e5a0e21dcd463 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:31:00 +0900 Subject: [PATCH 23/30] test(device): keep unrelated StickS3 contracts unchanged --- tests/sticks3_runtime_contract_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sticks3_runtime_contract_test.py b/tests/sticks3_runtime_contract_test.py index 32664a2e..21e2e8e6 100644 --- a/tests/sticks3_runtime_contract_test.py +++ b/tests/sticks3_runtime_contract_test.py @@ -243,7 +243,7 @@ def test_issue_107_canonical_vault_stays_in_auth_nvs(self) -> None: self.assertIn('constexpr char kPartitionLabel[] = "auth_nvs";', persistence) self.assertIn("nvs_flash_init_partition(kPartitionLabel)", persistence) self.assertIn("nvs_open_from_partition(", persistence) - self.assertIn("kPartitionLabel, kNamespace, mode", persistence) + self.assertIn("kPartitionLabel, kNamespace, mode, handle", persistence) def test_issue_107_reboot_with_persisted_vault_starts_locked_without_vmk(self) -> None: runtime = VAULT_RUNTIME_CPP.read_text(encoding="utf-8") @@ -354,4 +354,4 @@ def test_issue_86_timing_snapshot_uses_rtc_noinit_not_flash(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 579347e7e9a53884425b7b405a32ed8f772f8cf1 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:35:25 +0900 Subject: [PATCH 24/30] fix(runtime): preserve recovery reset for known-format corruption --- firmware/components/m5auth_vault_runtime/runtime.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/firmware/components/m5auth_vault_runtime/runtime.cpp b/firmware/components/m5auth_vault_runtime/runtime.cpp index a30368b3..a49a57b0 100644 --- a/firmware/components/m5auth_vault_runtime/runtime.cpp +++ b/firmware/components/m5auth_vault_runtime/runtime.cpp @@ -168,9 +168,11 @@ Status Runtime::initialize() { last_used_ = snapshot.last_used; if (!snapshot.has_vault) return Status::kUnprovisioned; if (!valid_envelope_framing(snapshot.envelope)) { + const bool supported_format = + vault::is_supported_vault_format(snapshot.envelope.vault_format_version); state_ = State::kError; - recovery_reset_allowed_ = false; - return vault::is_supported_vault_format(snapshot.envelope.vault_format_version) + recovery_reset_allowed_ = supported_format; + return supported_format ? Status::kCorrupt : Status::kUnsupportedVaultFormat; } @@ -202,10 +204,12 @@ Status Runtime::reload_after_persistence() { return Status::kOk; } if (!valid_envelope_framing(snapshot.envelope)) { + const bool supported_format = + vault::is_supported_vault_format(snapshot.envelope.vault_format_version); state_ = State::kError; - recovery_reset_allowed_ = false; + recovery_reset_allowed_ = supported_format; clear_unlock_session_state(); - return vault::is_supported_vault_format(snapshot.envelope.vault_format_version) + return supported_format ? Status::kCorrupt : Status::kUnsupportedVaultFormat; } From f1b08455c13fe26b44847aec7b3192bc26af7f9a Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:19:24 +0900 Subject: [PATCH 25/30] fix(runtime): preserve auto-lock origin across VMK rekey boundary --- .../include/m5auth/vault_runtime/runtime.hpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp b/firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp index 0001f306..65d7a007 100644 --- a/firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp +++ b/firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp @@ -151,9 +151,7 @@ class Runtime final { Status enter_registration_replacement_boundary() { return enter_recovery_boundary(); } - Status enter_vmk_rekey_boundary() { - return enter_recovery_boundary(); - } + Status enter_vmk_rekey_boundary(); Status fatal_security_error(); Status update_encrypted_vault( From 3966556a031401fac7eaa1556fcf9c876874b299 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:19:50 +0900 Subject: [PATCH 26/30] fix(runtime): keep unlock lifetime through VMK rekey --- .../components/m5auth_vault_runtime/rekey.cpp | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/firmware/components/m5auth_vault_runtime/rekey.cpp b/firmware/components/m5auth_vault_runtime/rekey.cpp index 19504043..1654e4f1 100644 --- a/firmware/components/m5auth_vault_runtime/rekey.cpp +++ b/firmware/components/m5auth_vault_runtime/rekey.cpp @@ -30,6 +30,21 @@ bool same_envelope( } // namespace +Status Runtime::enter_vmk_rekey_boundary() { + if (!initialized_) return Status::kNotReady; + if (state_ != State::kUnlocked || !vmk_present_ || !has_vault_ || + !unlock_session_active_) { + return Status::kInvalidState; + } + + // Rekey must destroy the resident VMK immediately, but it is not a fresh + // unlock. Keep only the bounded non-secret automatic-lock policy/origin so + // a successful rekey cannot extend the current continuous UNLOCKED lifetime. + wipe_vmk(); + state_ = State::kLocked; + return Status::kOk; +} + Status Runtime::rekey_encrypted_vault( std::uint64_t expected_generation, vault::VaultEnvelope envelope, @@ -37,8 +52,10 @@ Status Runtime::rekey_encrypted_vault( std::uint64_t now_ms ) { ScopedVmkWipe wipe(vmk); + (void)now_ms; if (!initialized_) return Status::kNotReady; - if (state_ != State::kLocked || vmk_present_ || !has_vault_) { + if (state_ != State::kLocked || vmk_present_ || !has_vault_ || + !unlock_session_active_) { return Status::kInvalidState; } if (expected_generation != envelope_.generation || @@ -75,10 +92,14 @@ Status Runtime::rekey_encrypted_vault( last_used_ = snapshot.last_used; schema_ready_ = true; has_vault_ = true; - clear_unlock_session_state(); + + // The old VMK was already destroyed at enter_vmk_rekey_boundary(). Install + // the new VMK while deliberately retaining unlocked_since_ms_. The candidate + // authenticated policy becomes effective against that original session T0. + wipe_vmk(); vmk_ = vmk; vmk_present_ = true; - begin_unlock_session(policy, now_ms); + auto_lock_days_ = policy; state_ = State::kUnlocked; recovery_reset_allowed_ = false; return Status::kOk; From 9c5df911337ae1909264911d7aad8d2b2f1e416d Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:20:26 +0900 Subject: [PATCH 27/30] test(runtime): keep automatic lock origin across VMK rekey --- tests/vault_rekey_test.cpp | 135 +++++++++++++++++++++++++++++++++---- 1 file changed, 122 insertions(+), 13 deletions(-) diff --git a/tests/vault_rekey_test.cpp b/tests/vault_rekey_test.cpp index 5a46107c..75646ce0 100644 --- a/tests/vault_rekey_test.cpp +++ b/tests/vault_rekey_test.cpp @@ -78,7 +78,9 @@ m5auth::vault::VaultEnvelope make_envelope( const std::array& vault_id, const CredentialId& credential_id, std::uint64_t generation, - std::uint8_t nonce_start + std::uint8_t nonce_start, + std::uint16_t vault_format = m5auth::vault::kVaultFormatVersion1, + std::optional auto_lock_days = std::nullopt ) { m5auth::vault::VaultPlaintext plaintext; m5auth::vault::CredentialRecord credential; @@ -88,9 +90,10 @@ m5auth::vault::VaultEnvelope make_envelope( credential.account = "rekey-test"; credential.display_name = "Synthetic Rekey"; plaintext.credentials.push_back(std::move(credential)); + plaintext.auto_lock_days = auto_lock_days; std::vector encoded; - assert(m5auth::vault::encode_plaintext(plaintext, encoded)); + assert(m5auth::vault::encode_plaintext(plaintext, encoded, vault_format)); m5auth::vault_runtime::wipe_plaintext(&plaintext); m5auth::vault::VaultEnvelope envelope; @@ -101,15 +104,14 @@ m5auth::vault::VaultEnvelope make_envelope( vault_id, generation, nonce, - envelope + envelope, + vault_format )); m5auth::vault_runtime::secure_zero(encoded.data(), encoded.size()); return envelope; } -} // namespace - -int main() { +void rekey_failure_paths_remain_fail_closed() { FakePersistence persistence; Runtime runtime(persistence); assert(runtime.initialize() == Status::kUnprovisioned); @@ -123,33 +125,140 @@ int main() { const auto generation2 = make_envelope(new_vmk, vault_id, credential_id, 2, 0xd0); assert(runtime.install_encrypted_vault(generation1, old_vmk) == Status::kOk); - assert(runtime.unlock(old_vmk) == Status::kOk); + assert(runtime.unlock(old_vmk, 10'000) == Status::kOk); assert(runtime.enter_vmk_rekey_boundary() == Status::kOk); assert(!runtime.unlocked()); Vmk wrong_vmk{}; wrong_vmk.fill(0xee); - assert(runtime.rekey_encrypted_vault(1, generation2, wrong_vmk) == Status::kAuthenticationFailed); + assert(runtime.rekey_encrypted_vault(1, generation2, wrong_vmk, 11'000) == + Status::kAuthenticationFailed); assert(!runtime.unlocked()); assert(persistence.snapshot.envelope.generation == 1); - assert(runtime.rekey_encrypted_vault(0, generation2, new_vmk) == Status::kGenerationMismatch); + assert(runtime.rekey_encrypted_vault(0, generation2, new_vmk, 11'000) == + Status::kGenerationMismatch); assert(!runtime.unlocked()); persistence.replace_result = Status::kIo; - assert(runtime.rekey_encrypted_vault(1, generation2, new_vmk) == Status::kIo); + assert(runtime.rekey_encrypted_vault(1, generation2, new_vmk, 11'000) == Status::kIo); assert(!runtime.unlocked()); assert(persistence.snapshot.envelope.generation == 1); persistence.replace_result = Status::kOk; - assert(runtime.rekey_encrypted_vault(1, generation2, new_vmk) == Status::kOk); + assert(runtime.rekey_encrypted_vault(1, generation2, new_vmk, 11'000) == Status::kOk); assert(runtime.unlocked()); Metadata metadata; assert(runtime.metadata(&metadata) == Status::kOk); assert(metadata.generation == 2); assert(runtime.lock() == Status::kOk); - assert(runtime.unlock(old_vmk) == Status::kAuthenticationFailed); - assert(runtime.unlock(new_vmk) == Status::kOk); + assert(runtime.unlock(old_vmk, 12'000) == Status::kAuthenticationFailed); + assert(runtime.unlock(new_vmk, 12'000) == Status::kOk); +} + +void successful_rekey_preserves_automatic_lock_origin() { + FakePersistence persistence; + Runtime runtime(persistence); + assert(runtime.initialize() == Status::kUnprovisioned); + assert(runtime.format_for_schema2() == Status::kOk); + + const Vmk old_vmk = sequence(0x11); + const Vmk new_vmk = sequence(0x51); + const auto vault_id = sequence(0x81); + const CredentialId credential_id = sequence(0xa1); + const auto generation1 = make_envelope( + old_vmk, + vault_id, + credential_id, + 1, + 0xc1, + m5auth::vault::kVaultFormatVersion2, + 1 + ); + const auto generation2 = make_envelope( + new_vmk, + vault_id, + credential_id, + 2, + 0xd1, + m5auth::vault::kVaultFormatVersion2, + 1 + ); + + assert(runtime.install_encrypted_vault(generation1, old_vmk) == Status::kOk); + const std::uint64_t t0 = 123'456; + assert(runtime.unlock(old_vmk, t0) == Status::kOk); + assert(runtime.auto_lock_days() == std::optional(1)); + assert(runtime.unlocked_since_ms() == t0); + + const std::uint64_t rekey_at = + t0 + m5auth::vault_runtime::kMillisecondsPerDay - 1; + assert(runtime.enter_vmk_rekey_boundary() == Status::kOk); + assert(!runtime.unlocked()); + // The boundary destroys VMK access but retains only bounded non-secret + // lifetime state needed to avoid granting a fresh session after re-key. + assert(runtime.unlocked_since_ms() == t0); + assert(runtime.auto_lock_days() == std::optional(1)); + + assert(runtime.rekey_encrypted_vault(1, generation2, new_vmk, rekey_at) == Status::kOk); + assert(runtime.unlocked()); + assert(runtime.unlocked_since_ms() == t0); + assert(runtime.auto_lock_days() == std::optional(1)); + assert(!runtime.automatic_lock_due(rekey_at)); + assert(runtime.automatic_lock_due( + t0 + m5auth::vault_runtime::kMillisecondsPerDay + )); +} + +void successful_rekey_does_not_hide_already_reached_deadline() { + FakePersistence persistence; + Runtime runtime(persistence); + assert(runtime.initialize() == Status::kUnprovisioned); + assert(runtime.format_for_schema2() == Status::kOk); + + const Vmk old_vmk = sequence(0x12); + const Vmk new_vmk = sequence(0x52); + const auto vault_id = sequence(0x82); + const CredentialId credential_id = sequence(0xa2); + const auto generation1 = make_envelope( + old_vmk, + vault_id, + credential_id, + 1, + 0xc2, + m5auth::vault::kVaultFormatVersion2, + 1 + ); + const auto generation2 = make_envelope( + new_vmk, + vault_id, + credential_id, + 2, + 0xd2, + m5auth::vault::kVaultFormatVersion2, + 1 + ); + + assert(runtime.install_encrypted_vault(generation1, old_vmk) == Status::kOk); + const std::uint64_t t0 = 500'000; + const std::uint64_t deadline = t0 + m5auth::vault_runtime::kMillisecondsPerDay; + assert(runtime.unlock(old_vmk, t0) == Status::kOk); + assert(runtime.enter_vmk_rekey_boundary() == Status::kOk); + + assert(runtime.rekey_encrypted_vault(1, generation2, new_vmk, deadline) == Status::kOk); + assert(runtime.unlocked()); + assert(runtime.unlocked_since_ms() == t0); + // Protocol housekeeping observes this immediately after the re-key request + // and routes expiry through the same full security boundary as device.lock. + assert(runtime.automatic_lock_due(deadline)); +} + +} // namespace + +int main() { + rekey_failure_paths_remain_fail_closed(); + successful_rekey_preserves_automatic_lock_origin(); + successful_rekey_does_not_hide_already_reached_deadline(); return 0; } From 1e85c8ce1df78940be509e5b2013f941c22d3c43 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:22:22 +0900 Subject: [PATCH 28/30] test(runtime): align rekey boundary contract with fixed lifetime --- tests/vault_runtime_contract_test.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/vault_runtime_contract_test.py b/tests/vault_runtime_contract_test.py index 758fcf3c..55940ed2 100644 --- a/tests/vault_runtime_contract_test.py +++ b/tests/vault_runtime_contract_test.py @@ -3,6 +3,7 @@ ROOT = Path(__file__).resolve().parents[1] RUNTIME = ROOT / "firmware/components/m5auth_vault_runtime/runtime.cpp" +REKEY = ROOT / "firmware/components/m5auth_vault_runtime/rekey.cpp" HEADER = ROOT / "firmware/components/m5auth_vault_runtime/include/m5auth/vault_runtime/runtime.hpp" NVS = ROOT / "firmware/components/m5auth_vault_runtime/nvs_persistence.cpp" COMPAT_NVS = ROOT / "firmware/components/m5auth_vault_runtime/compatible_nvs_persistence.cpp" @@ -21,6 +22,7 @@ class VaultRuntimeContractTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.runtime = RUNTIME.read_text(encoding="utf-8") + cls.rekey = REKEY.read_text(encoding="utf-8") cls.header = HEADER.read_text(encoding="utf-8") cls.nvs = NVS.read_text(encoding="utf-8") cls.compat_nvs = COMPAT_NVS.read_text(encoding="utf-8") @@ -59,13 +61,13 @@ def test_vmk_and_unlock_policy_state_are_explicitly_cleared(self) -> None: self.runtime[destructor_start : destructor_start + 160], ) - def test_all_trust_root_change_entry_points_destroy_unlock_session_state(self) -> None: + def test_trust_root_change_boundaries_preserve_only_rekey_lifetime_origin(self) -> None: self.assertIn("Status enter_recovery_boundary();", self.header) self.assertIn("Status enter_registration_replacement_boundary()", self.header) - self.assertIn("Status enter_vmk_rekey_boundary()", self.header) - self.assertGreaterEqual( + self.assertIn("Status enter_vmk_rekey_boundary();", self.header) + self.assertEqual( self.header.count("return enter_recovery_boundary();"), - 2, + 1, ) recovery_start = self.runtime.index("Status Runtime::enter_recovery_boundary()") self.assertIn( @@ -83,6 +85,19 @@ def test_all_trust_root_change_entry_points_destroy_unlock_session_state(self) - self.runtime[fatal_start : fatal_start + 240], ) + rekey_start = self.rekey.index("Status Runtime::enter_vmk_rekey_boundary()") + rekey_end = self.rekey.index("Status Runtime::rekey_encrypted_vault(", rekey_start) + rekey_boundary = self.rekey[rekey_start:rekey_end] + self.assertIn("wipe_vmk();", rekey_boundary) + self.assertIn("state_ = State::kLocked;", rekey_boundary) + self.assertIn("unlock_session_active_", rekey_boundary) + self.assertNotIn("clear_unlock_session_state();", rekey_boundary) + + successful_rekey = self.rekey[rekey_end:] + self.assertIn("auto_lock_days_ = policy;", successful_rekey) + self.assertNotIn("begin_unlock_session(policy, now_ms);", successful_rekey) + self.assertNotIn("unlocked_since_ms_ = now_ms", successful_rekey) + def test_locked_paths_gate_plaintext_access(self) -> None: self.assertGreaterEqual( self.runtime.count( From 953a8e35d815c11ebab00f55a49dbbd28d5d5cb9 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:24:17 +0900 Subject: [PATCH 29/30] fix(protocol): lock immediately when rekey reaches deadline --- .../canonical_protocol_v2.cpp | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp b/firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp index d7f23376..73e0a127 100644 --- a/firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp +++ b/firmware/components/m5auth_provisioning/canonical_protocol_v2.cpp @@ -696,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 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()); From 1375cac4f6a2c412d776879a424f9a59806fdacf Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:24:37 +0900 Subject: [PATCH 30/30] test(protocol): enforce due auto-lock after VMK rekey --- tests/automatic_lock_protocol_contract_test.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/automatic_lock_protocol_contract_test.py b/tests/automatic_lock_protocol_contract_test.py index 80897e36..caad0c51 100644 --- a/tests/automatic_lock_protocol_contract_test.py +++ b/tests/automatic_lock_protocol_contract_test.py @@ -55,6 +55,19 @@ def test_vault_update_applies_policy_only_after_runtime_commit(self) -> None: self.assertLess(update.index(commit_call), update.index(due_check)) self.assertLess(update.index(due_check), update.index("lock_security_boundary();")) + def test_vault_rekey_rechecks_original_deadline_after_commit(self) -> None: + start = self.protocol.index('operation == "vault.rekey"') + end = self.protocol.index('operation == "time.status"', start) + rekey = self.protocol[start:end] + install_call = "vmk_sink_.install_rekeyed_vault" + due_check = "runtime_.automatic_lock_due(now_ms)" + self.assertIn(install_call, rekey) + self.assertIn("automatic_lock_due_after_rekey", rekey) + self.assertIn(due_check, rekey) + self.assertIn("lock_security_boundary()", rekey) + self.assertLess(rekey.index(install_call), rekey.index(due_check)) + self.assertLess(rekey.index(due_check), rekey.index("lock_security_boundary()")) + def test_expiry_and_explicit_lock_use_same_security_boundary(self) -> None: self.assertIn("vault_runtime::Status lock_security_boundary();", self.protocol_header) housekeeping_start = self.protocol.index(