From a763940a71c72b6e76fdbdf1c5d46b0ce57f2ed0 Mon Sep 17 00:00:00 2001 From: Erik-Castro Date: Sun, 7 Jun 2026 17:48:15 -0300 Subject: [PATCH] =?UTF-8?q?feat(lazy-reencryption):=20Phase=202-4=20?= =?UTF-8?q?=E2=80=94=20Core=20logic,=20password=20change,=20integration=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite kek_rotate() to O(1) archive→generate→update, no secrets loop - Add lazy-migrate in ssm_secret_get() — decrypt with archived KEK, re-encrypt with current - Add safe-purge — auto-delete archive entry when all secrets migrated - Add ssm_kek_purge_archive() public API - Modify ssm_user_change_password() to re-wrap all archive entries - 15 tests: O(1) rotation, lazy-migrate, safe-purge, purge API, password change, concurrency --- include/ssm/ssm.h | 1 + .../lazy-reencryption/apply-progress.md | 91 ++- openspec/changes/lazy-reencryption/tasks.md | 30 +- src/kek/kek.cc | 127 +--- src/ssm.cc | 186 +++++- tests/CMakeLists.txt | 1 + tests/kek/kek_test.cc | 291 +++++---- tests/lazy_reencryption_test.cc | 592 ++++++++++++++++++ 8 files changed, 1018 insertions(+), 301 deletions(-) create mode 100644 tests/lazy_reencryption_test.cc diff --git a/include/ssm/ssm.h b/include/ssm/ssm.h index 71a1656..15078f7 100644 --- a/include/ssm/ssm.h +++ b/include/ssm/ssm.h @@ -65,6 +65,7 @@ SSM_EXPORT ssm_status ssm_secret_list(ssm_handle* h, const char* username, ssm_secret_list_cb callback, void* user_data); SSM_EXPORT ssm_status ssm_kek_rotate(ssm_handle* h, const char* username); +SSM_EXPORT ssm_status ssm_kek_purge_archive(ssm_handle* h, const char* username); typedef struct { size_t total_entries; diff --git a/openspec/changes/lazy-reencryption/apply-progress.md b/openspec/changes/lazy-reencryption/apply-progress.md index ef3ff69..8686573 100644 --- a/openspec/changes/lazy-reencryption/apply-progress.md +++ b/openspec/changes/lazy-reencryption/apply-progress.md @@ -1,8 +1,8 @@ -# Apply Progress — Lazy KEK Re-encryption (Phase 1 Foundation) +# Apply Progress — Lazy KEK Re-encryption (Phases 1–5) -**Mode**: Strict TDD -**PR Boundary**: PR #1 targeting `feat/lazy-reencryption` (tracker branch) -**Target branch**: `feat/lazy-reencryption` (feature-branch-chain, PR #1) +**Mode**: Strict TDD (RED→GREEN per task) +**PR Boundary**: PR #1 (Phase 1) + PR #2 (Phases 2–4) targeting `feat/lazy-reencryption` (feature-branch-chain) +**Target branch**: `feat/lazy-reencryption` ## TDD Cycle Evidence @@ -18,55 +18,78 @@ | 1.8 | — | N/A (const) | N/A | N/A | ✅ Modified | ➖ Single | ➖ None needed | | 1.9 | — | N/A (migration) | N/A | N/A | ✅ Modified | ➖ Single | ➖ None needed | | 1.10 | — | Review | N/A | N/A | ✅ Logically verified | ➖ N/A | ➖ None needed | - -> **GREEN execution note**: This environment (Termux/Android) has NDK header incompatibilities that prevent compilation. All GREEN entries are marked ❌ Not executed. Tests follow existing GTest patterns exactly — a working CI should pass them. +| 2.1 | `tests/kek/kek_test.cc` | Unit | Existing fixture | ✅ Written | ❌ Not executed | ✅ 3 cases updated | ➖ None needed | +| 2.2 | `tests/lazy_reencryption_test.cc` | Integration | N/A (new file) | ✅ Written | ❌ Not executed | ✅ Full cycle tested | ➖ None needed | +| 2.3 | `tests/lazy_reencryption_test.cc` | Integration | N/A | ✅ Written | ❌ Not executed | ✅ 2 scenarios | ➖ None needed | +| 2.4 | `tests/lazy_reencryption_test.cc` | Integration | N/A | ✅ Written | ❌ Not executed | ✅ 3 scenarios | ➖ None needed | +| 2.5 | — | N/A (impl) | N/A | N/A | ✅ Rewritten | ➖ O(1) per design | ➖ None needed | +| 2.6 | — | N/A (impl) | N/A | N/A | ✅ Written | ➖ Fast + lazy path | ➖ None needed | +| 2.7 | — | N/A (impl) | N/A | N/A | ✅ Inline in 2.6 | ➖ COUNT + conditional | ➖ None needed | +| 2.8 | — | N/A (impl+header) | N/A | N/A | ✅ Added | ➖ Iterate + count + delete | ➖ None needed | +| 3.1 | `tests/lazy_reencryption_test.cc` | Integration | N/A | ✅ Written | ❌ Not executed | ✅ 3 archived KEKs | ➖ None needed | +| 3.2 | `tests/lazy_reencryption_test.cc` | Integration | N/A | ✅ Written | ❌ Not executed | ✅ 0 archive entries | ➖ None needed | +| 3.3 | — | N/A (impl) | N/A | N/A | ✅ Modified | ➖ list + re-wrap loop | ➖ None needed | +| 4.1 | `tests/lazy_reencryption_test.cc` | Integration | N/A | ✅ Written | ❌ Not executed | ✅ Full cycle test | ➖ None needed | +| 4.2 | `tests/lazy_reencryption_test.cc` | Integration | N/A | ✅ Written | ❌ Not executed | ✅ Password change + re-wrap | ➖ None needed | +| 4.3 | `tests/lazy_reencryption_test.cc` | Concurrency | N/A | ✅ Written | ❌ Not executed | ✅ 10 threads | ➖ None needed | +| 4.4 | — | Migration | Phase 1 covers | N/A | ✅ Already covered | ➖ N/A | ➖ None needed | +| 5.1 | — | Build | — | — | ❌ Blocked | — | — | +| 5.2 | — | Review | — | — | ✅ Logically verified | — | — | +| 5.3 | — | Review | — | — | 🔲 Not verified | — | — | + +> **GREEN execution note**: This environment (Termux/Android) has NDK header incompatibilities that prevent compilation. All GREEN entries marked ❌ Not executed. GREEN implementation follows existing patterns — a working CI should pass them. Task 5.2 was verified logically (all `secure_buffer`/`secure_vector` RAII types auto-wipe via `sodium_memzero` in destructors). ## Test Summary -- **Total tests written**: 11 (8 kek_archive unit tests + 3 migration tests) +- **Total tests written**: 24 (11 from Phase 1 + 13 from Phases 2–4) +- **Test files**: `tests/db/kek_archive_test.cc`, `tests/db/migrations_test.cc`, `tests/kek/kek_test.cc`, `tests/lazy_reencryption_test.cc` - **Total tests passing**: 0 (build broken in this environment) -- **Layers used**: Unit (11) -- **Approval tests**: None — all new code -- **Pure functions created**: 0 — all CRUD with SQLite side effects +- **Layers used**: Unit (11), Integration (12), Concurrency (1) +- **All RED tests exist before GREEN implementation** per Strict TDD -## Files Changed +## Files Changed (Cumulative — Phase 1 + Phases 2–4) | File | Action | Description | |------|--------|-------------| -| `tests/db/kek_archive_test.cc` | **Created** | 8 tests: store/find, not-found, delete, delete-nonexistent, list-user-count, list-empty, duplicate-rejected, cross-user-allowed | -| `src/db/kek_archive.h` | **Created** | `kek_archive_row` struct + 4 CRUD function declarations | -| `src/db/kek_archive.cc` | **Created** | 4 CRUD implementations following `kek_metadata.cc` SQLite pattern | -| `src/db/secrets.h` | **Modified** | Added `int64_t kek_version = 1` to `secret_row`; declared `secrets_count_by_kek_version` + `secrets_update_ciphertext` | -| `src/db/secrets.cc` | **Modified** | Added `kek_version` (col 9) to `read_secret_row`; updated SELECT SQL in `secrets_find` + `secrets_list_for_user`; implemented count + update_ciphertext | -| `src/db/database.cc` | **Modified** | Added `kek_archive` CREATE TABLE to `db_create_schema()` multi-statement exec | -| `src/db/migrations.h` | **Modified** | Bumped `SSM_SCHEMA_VERSION` from 3→4; changed array size 2→3 | -| `src/db/migrations.cc` | **Modified** | Appended `{3, 4}` migration entry (CREATE kek_archive + ALTER secrets ADD COLUMN, rollback DROP) | -| `tests/db/migrations_test.cc` | **Modified** | Added 3 new migration tests; renamed `RollbackFromVersion3To1` → `RollbackFromVersion4To1`; updated `RollbackMigrateRoundtrip` to use `SSM_SCHEMA_VERSION` | -| `tests/CMakeLists.txt` | **Modified** | Added `db/kek_archive_test.cc` to test sources | -| `src/CMakeLists.txt` | **Modified** | Added `db/kek_archive.cc` to library sources | -| `openspec/changes/lazy-reencryption/tasks.md` | **Modified** | Marked 10 Phase 1 tasks as `[x]` | +| `tests/db/kek_archive_test.cc` | **Created** (P1) | 8 tests: store/find/delete/list for archive CRUD | +| `src/db/kek_archive.h` | **Created** (P1) | `kek_archive_row` struct + 4 CRUD declarations | +| `src/db/kek_archive.cc` | **Created** (P1) | 4 CRUD implementations | +| `src/db/secrets.h` | **Modified** (P1) | Added `kek_version` to `secret_row`; declared count + update_ciphertext | +| `src/db/secrets.cc` | **Modified** (P1) | Updated SELECT/INSERT for `kek_version`; implemented count + update | +| `src/db/database.cc` | **Modified** (P1) | Added `kek_archive` CREATE TABLE | +| `src/db/migrations.h` | **Modified** (P1) | Bumped `SSM_SCHEMA_VERSION` 3→4 | +| `src/db/migrations.cc` | **Modified** (P1) | v3→v4 migration: CREATE kek_archive + ALTER secrets | +| `tests/db/migrations_test.cc` | **Modified** (P1) | Updated migration tests for v4 | +| `src/CMakeLists.txt` | **Modified** (P1) | Added `db/kek_archive.cc` | +| `tests/CMakeLists.txt` | **Modified** (P1+P2) | Added `db/kek_archive_test.cc` + `lazy_reencryption_test.cc` | +| `tests/kek/kek_test.cc` | **Modified** (P2) | Updated rotation tests for O(1) — archive + switch, no secrets loop | +| `tests/lazy_reencryption_test.cc` | **Created** (P2) | 592 lines — all integration/concurrency tests for lazxy-migrate, safe-purge, purge API, password change re-wrap, concurrency | +| `src/kek/kek.cc` | **Modified** (P2) | Rewrote `kek_rotate()` — O(1) archive→generate→update, no secrets loop | +| `src/ssm.cc` | **Modified** (P2+P3) | Added lazy-migrate + safe-purge in `ssm_secret_get`, `ssm_kek_purge_archive` impl, archive re-wrap in `ssm_user_change_password` | +| `include/ssm/ssm.h` | **Modified** (P2) | Added `SSM_EXPORT ssm_kek_purge_archive` declaration | +| `openspec/changes/lazy-reencryption/tasks.md` | **Modified** (P1+P2) | Marked Phase 1–4 all `[x]` | ## Deviations from Design -1. **Schema version**: Design specifies v2→v3 migration. The actual codebase already had `SSM_SCHEMA_VERSION = 3` with 2 applied migrations (1→idx_secrets_user_id, 2→idx_secrets_unique_name). Bumped to **4** and created v3→v4 migration instead. -2. **No separate `db_create_kek_archive()`**: Design suggests a separate function. The existing `db_create_schema()` uses a single multi-statement `sqlite3_exec`. Added the CREATE TABLE inline instead — follows the existing pattern. -3. **Test updates**: `RollbackFromVersion3To1` hardcoded version 3 which would break. Renamed to `RollbackFromVersion4To1` using `SSM_SCHEMA_VERSION`. `RollbackMigrateRoundtrip` also updated. +1. **Schema version**: Design specifies v2→v3. Codebase already at v3 with 2 migrations. Bumped to **4** for v3→v4. +2. **No separate `db_create_kek_archive()`**: Added CREATE TABLE inline in `db_create_schema()` per existing pattern. +3. **Inline safe-purge in lazy-migrate transaction**: The design says safe-purge happens after migrate. My impl does it in the same `BEGIN IMMEDIATE` block — more atomic. +4. **`ssm_kek_purge_archive` iterates all entries**: Design didn't specify exact iteration strategy. I list all, check `secrets_count_by_kek_version`, delete only zero-count. This catches orphans that inline purge couldn't handle (e.g., if a version was skipped). +5. **Password change re-wrap reuses `kek_raw` buffer**: The existing password change code already unwraps the current KEK into `kek_raw`. For archive entries, I reuse the same `kek_raw` buffer to unwrap the archived KEK (same key length), then re-wrap with the same `new_wrapped` buffer — efficient and consistent. ## Issues Found - Build environment (Termux/Android) has NDK header incompatibilities — **cannot compile or run tests**. GREEN execution deferred to CI. -- The `secrets_store` INSERT does not include `kek_version` — it relies on `DEFAULT 1` from the migration. This is correct per design. +- `ssm_secret_get` uses `unique_lock` (exclusive write lock) — this means no concurrent read path. The lazy-migrate write path is always serialized, which is correct but slightly pessimistic for the fast (current kek_version) path. Acceptable per design. ## Remaining Tasks -- Phase 2: Core Logic (tasks 2.1–2.8) -- Phase 3: Password Change Archive Re-wrap (tasks 3.1–3.3) -- Phase 4: Integration + System Tests (tasks 4.1–4.4) -- Phase 5: Cleanup (tasks 5.1–5.3) +- [ ] 5.1 Full build + `ctest` — all pass, valgrind-clean in Debug (requires CI) +- [ ] 5.3 Consistency: new KEK archive entries get wiped on handle destroy (CASCADE) ## Workload / PR Boundary - **Mode**: chained PR slice (feature-branch-chain) -- **Current work unit**: Unit 1 — Schema, migration, kek_archive CRUD, secrets changes -- **Boundary**: Phase 1 only (10 tasks) — PR #1 targets `feat/lazy-reencryption` -- **Estimated review budget impact**: ~1120 changed lines (new files + modifications) — exceeds 400-line budget, justified by chained PR approach +- **Current work unit**: Unit 2 — O(1) rotation, lazy-migrate, safe-purge, pw re-wrap, integration tests +- **Boundary**: PR #2 — all Phase 2–4 implementation + tests (Phases 2–4: 26 tasks) +- **Estimated review budget impact**: ~460 new/changed lines beyond PR #1 diff --git a/openspec/changes/lazy-reencryption/tasks.md b/openspec/changes/lazy-reencryption/tasks.md index 828b39b..ec772e0 100644 --- a/openspec/changes/lazy-reencryption/tasks.md +++ b/openspec/changes/lazy-reencryption/tasks.md @@ -38,27 +38,27 @@ Chain strategy: feature-branch-chain ## Phase 2: Core Logic -- [ ] 2.1 (RED) Test O(1) rotation: archive current KEK, generate new, no secrets scan -- [ ] 2.2 (RED) Test lazy-migrate path in `ssm_secret_get`: stale kek_version → decrypt→re-encrypt→update -- [ ] 2.3 (RED) Test safe-purge: COUNT after UPDATE, DELETE archive when zero, preserve when >0 -- [ ] 2.4 (RED) Test `ssm_kek_purge_archive` — purge only versions with zero secrets -- [ ] 2.5 (GREEN) Rewrite `kek_rotate()` in `src/kek/kek.cc`: archive→generate→update, no secrets loop -- [ ] 2.6 (GREEN) Add lazy-migrate block in `ssm_secret_get()` in `src/ssm.cc` — compare kek_version, lookup archive, decrypt, re-encrypt, update row -- [ ] 2.7 (GREEN) Add safe-purge after migrate: COUNT + conditional DELETE archive in same transaction -- [ ] 2.8 (GREEN) Add `ssm_kek_purge_archive` to `include/ssm/ssm.h` + implement in `src/ssm.cc` +- [x] 2.1 (RED) Test O(1) rotation: archive current KEK, generate new, no secrets scan +- [x] 2.2 (RED) Test lazy-migrate path in `ssm_secret_get`: stale kek_version → decrypt→re-encrypt→update +- [x] 2.3 (RED) Test safe-purge: COUNT after UPDATE, DELETE archive when zero, preserve when >0 +- [x] 2.4 (RED) Test `ssm_kek_purge_archive` — purge only versions with zero secrets +- [x] 2.5 (GREEN) Rewrite `kek_rotate()` in `src/kek/kek.cc`: archive→generate→update, no secrets loop +- [x] 2.6 (GREEN) Add lazy-migrate block in `ssm_secret_get()` in `src/ssm.cc` — compare kek_version, lookup archive, decrypt, re-encrypt, update row +- [x] 2.7 (GREEN) Add safe-purge after migrate: COUNT + conditional DELETE archive in same transaction +- [x] 2.8 (GREEN) Add `ssm_kek_purge_archive` to `include/ssm/ssm.h` + implement in `src/ssm.cc` ## Phase 3: Password Change Archive Re-wrap -- [ ] 3.1 (RED) Test password change with 3 archived KEKs — all re-wrapped atomically -- [ ] 3.2 (RED) Test password change with 0 archive entries — no-op, no error -- [ ] 3.3 (GREEN) Modify `ssm_user_change_password()` in `src/ssm.cc`: loop `kek_archive_list_for_user()`, re-wrap each, all in one transaction +- [x] 3.1 (RED) Test password change with 3 archived KEKs — all re-wrapped atomically +- [x] 3.2 (RED) Test password change with 0 archive entries — no-op, no error +- [x] 3.3 (GREEN) Modify `ssm_user_change_password()` in `src/ssm.cc`: loop `kek_archive_list_for_user()`, re-wrap each, all in one transaction ## Phase 4: Integration + System Tests -- [ ] 4.1 Integration: full cycle register → rotate (O(1)) → get stale secret (lazy-migrate) → purge archive -- [ ] 4.2 Integration: password change with archived KEKs, verify all re-wrap on login -- [ ] 4.3 Concurrency: 10 threads on stale secrets, verify atomic migration under write lock -- [ ] 4.4 Migration test: DB at v2 → migrate to v3 → verify schema → rollback to v2 +- [x] 4.1 Integration: full cycle register → rotate (O(1)) → get stale secret (lazy-migrate) → purge archive +- [x] 4.2 Integration: password change with archived KEKs, verify all re-wrap on login +- [x] 4.3 Concurrency: 10 threads on stale secrets, verify atomic migration under write lock +- [x] 4.4 Migration test: DB at v2 → migrate to v3 → verify schema → rollback to v2 ## Phase 5: Cleanup diff --git a/src/kek/kek.cc b/src/kek/kek.cc index 1d5dd1a..26da7dd 100644 --- a/src/kek/kek.cc +++ b/src/kek/kek.cc @@ -8,8 +8,8 @@ #include "crypto/aes_gcm.h" #include "crypto/aes_kw.h" #include "crypto/random.h" +#include "db/kek_archive.h" #include "db/kek_metadata.h" -#include "db/secrets.h" #include "utils/secure_memory.h" namespace ssm::v1 { @@ -128,127 +128,44 @@ bool kek_expires_at(int days, char* out, size_t out_size) { return true; } +// O(1) KEK rotation: archive current KEK → generate new → update kek_metadata +// No secrets loop — secrets are lazy-migrated on read. bool kek_rotate(sqlite3* db, int64_t user_id, const unsigned char* auth_hash, size_t auth_hash_len) { if (!db || !auth_hash) return false; - secure_buffer wrapping_key(KEK_KEY_LEN); - secure_buffer new_kek(KEK_KEY_LEN); - secure_buffer new_wrapping_key(KEK_KEY_LEN); - secure_buffer new_salt(KEK_SALT_LEN); - secure_buffer new_wrapped(64); - secure_buffer old_kek_raw(KEK_KEY_LEN); - if (!wrapping_key || !new_kek || !new_wrapping_key || !new_salt || !new_wrapped || !old_kek_raw) - return false; - - size_t new_wrapped_len = 0; - char new_expires[24]; - bool ok = false; do { - sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr); + if (sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr) != SQLITE_OK) + break; + // --- load current KEK --- kek_row old_kek; if (!kek_find_by_user(db, user_id, &old_kek)) break; - size_t old_kek_len = old_kek_raw.size(); - if (!kek_unwrap(old_kek.wrapped_kek.data(), old_kek.wrapped_kek.size(), auth_hash, - auth_hash_len, old_kek.salt.data(), old_kek.salt.size(), old_kek_raw.data(), - &old_kek_len)) - break; - - // --- load all secrets --- - std::vector secrets; - if (!secrets_list_for_user(db, user_id, &secrets)) + // --- archive current KEK before generating new one (crash safety) --- + if (!kek_archive_store(db, user_id, old_kek.kek_version, + old_kek.wrapped_kek.data(), old_kek.wrapped_kek.size(), + old_kek.salt.data(), old_kek.salt.size(), + old_kek.expires_at.c_str())) break; // --- generate new KEK + salt --- - random_bytes(new_kek.data(), new_kek.size()); - random_bytes(new_salt.data(), new_salt.size()); - - if (!kek_derive_wrapping_key(auth_hash, auth_hash_len, new_salt.data(), new_salt.size(), - new_wrapping_key.data(), new_wrapping_key.size())) - break; - - if (!aes_kw_wrap(new_kek.data(), new_kek.size(), new_wrapping_key.data(), - new_wrapping_key.size(), new_wrapped.data(), &new_wrapped_len)) - break; - - if (!kek_expires_at(KEK_DEFAULT_DAYS, new_expires, sizeof(new_expires))) - break; - - // --- pre-allocate buffers (max size across all secrets) --- - size_t max_priv_len = 0; - for (auto& secret : secrets) { - if (secret.private_key.size() > max_priv_len) - max_priv_len = secret.private_key.size(); - } - secure_vector plain_priv(max_priv_len); - secure_vector new_priv(max_priv_len); - - const char* sql = - "UPDATE secrets SET private_key = ?, public_key = ?, " - "nonce = ?, tag = ?, " - "updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') " - "WHERE id = ?"; - sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) - break; - - // --- re-encrypt each secret --- - bool rotation_ok = true; - for (auto& secret : secrets) { - if (secret.private_key.size() > max_priv_len) { - rotation_ok = false; - break; - } - - if (!aes_gcm_decrypt(secret.private_key.data(), secret.private_key.size(), - old_kek_raw.data(), old_kek_len, secret.nonce.data(), - secret.nonce.size(), nullptr, 0, secret.tag.data(), - secret.tag.size(), plain_priv.data())) { - rotation_ok = false; - break; - } - - unsigned char new_nonce[AES_GCM_NONCE_LEN]; - unsigned char new_priv_tag[AES_GCM_TAG_LEN]; - random_bytes(new_nonce, sizeof(new_nonce)); - - if (!aes_gcm_encrypt(plain_priv.data(), secret.private_key.size(), new_kek.data(), - new_kek.size(), new_nonce, sizeof(new_nonce), nullptr, 0, - new_priv.data(), new_priv_tag, sizeof(new_priv_tag))) { - rotation_ok = false; - break; - } - - sqlite3_reset(stmt); - sqlite3_bind_blob(stmt, 1, new_priv.data(), - static_cast(secret.private_key.size()), SQLITE_TRANSIENT); - if (!secret.public_key.empty()) - sqlite3_bind_blob(stmt, 2, secret.public_key.data(), - static_cast(secret.public_key.size()), SQLITE_TRANSIENT); - else - sqlite3_bind_null(stmt, 2); - sqlite3_bind_blob(stmt, 3, new_nonce, sizeof(new_nonce), SQLITE_TRANSIENT); - sqlite3_bind_blob(stmt, 4, new_priv_tag, sizeof(new_priv_tag), SQLITE_TRANSIENT); - sqlite3_bind_int64(stmt, 5, secret.id); - if (sqlite3_step(stmt) != SQLITE_DONE) { - rotation_ok = false; - break; - } - } - - sqlite3_finalize(stmt); - - if (!rotation_ok) + unsigned char new_wrapped[64]; + size_t new_wrapped_len = sizeof(new_wrapped); + unsigned char new_salt[KEK_SALT_LEN]; + size_t new_salt_len = sizeof(new_salt); + char new_expires[24]; + + if (!kek_generate(auth_hash, auth_hash_len, new_wrapped, &new_wrapped_len, + new_salt, &new_salt_len, new_expires, sizeof(new_expires))) break; - // --- update kek_metadata (increment kek_version) --- - if (!kek_update(db, user_id, new_wrapped.data(), new_wrapped_len, new_salt.data(), - new_salt.size(), new_expires, old_kek.kek_version)) + // --- update kek_metadata (increment kek_version atomically) --- + if (!kek_update(db, user_id, new_wrapped, new_wrapped_len, + new_salt, new_salt_len, new_expires, old_kek.kek_version)) break; ok = true; diff --git a/src/ssm.cc b/src/ssm.cc index 0931e70..618ceb8 100644 --- a/src/ssm.cc +++ b/src/ssm.cc @@ -19,6 +19,7 @@ #include "db/audit_log.h" #include "db/database.h" #include "db/migrations.h" +#include "db/kek_archive.h" #include "db/kek_metadata.h" #include "db/secrets.h" #include "db/users.h" @@ -415,15 +416,100 @@ ssm_status ssm_secret_get(ssm_handle* h, const char* username, const char* name, } secure_vector plaintext(secret.private_key.size()); - bool dec_ok = - aes_gcm_decrypt(secret.private_key.data(), secret.private_key.size(), kek_raw.data(), - kek_len, secret.nonce.data(), secret.nonce.size(), nullptr, 0, - secret.tag.data(), secret.tag.size(), plaintext.data()); - if (!dec_ok) { - audit_write(h->db, username, user.id, "secret_get", SSM_ERR_INTEGRITY, name, - "{\"error\":\"GCM integrity check failed\"}"); - return SSM_ERR_INTEGRITY; + if (secret.kek_version == kek_meta.kek_version) { + // FAST PATH: secret is current — decrypt directly with current KEK + bool dec_ok = + aes_gcm_decrypt(secret.private_key.data(), secret.private_key.size(), + kek_raw.data(), kek_len, + secret.nonce.data(), secret.nonce.size(), nullptr, 0, + secret.tag.data(), secret.tag.size(), plaintext.data()); + if (!dec_ok) { + audit_write(h->db, username, user.id, "secret_get", SSM_ERR_INTEGRITY, name, + "{\"error\":\"GCM integrity check failed\"}"); + return SSM_ERR_INTEGRITY; + } + } else { + // LAZY-MIGRATE PATH: secret uses an archived KEK + // 1. Look up archived KEK entry for this secret's version + kek_archive_row archived; + if (!kek_archive_find_by_version(h->db, user.id, secret.kek_version, &archived)) { + audit_write(h->db, username, user.id, "secret_get", SSM_ERR_INTEGRITY, name, + "{\"error\":\"archive entry missing for lazy-migrate\"}"); + return SSM_ERR_INTEGRITY; + } + + // 2. Derive old wrapping key from current password_hash + archived salt + secure_buffer archived_wrapping_key(KEK_KEY_LEN); + secure_buffer archived_kek(KEK_KEY_LEN); + if (!archived_wrapping_key || !archived_kek) + return SSM_ERR_INTERNAL; + + if (!kek_derive_wrapping_key(user.password_hash.data(), user.password_hash.size(), + archived.salt.data(), archived.salt.size(), + archived_wrapping_key.data(), + archived_wrapping_key.size())) + return SSM_ERR_INTERNAL; + + // 3. AES-KW unwrap the archived KEK + size_t archived_kek_len = archived_kek.size(); + if (!aes_kw_unwrap(archived.wrapped_kek.data(), archived.wrapped_kek.size(), + archived_wrapping_key.data(), archived_wrapping_key.size(), + archived_kek.data(), &archived_kek_len)) + return SSM_ERR_INTERNAL; + + // 4. Decrypt secret using the archived KEK + bool dec_ok = + aes_gcm_decrypt(secret.private_key.data(), secret.private_key.size(), + archived_kek.data(), archived_kek_len, + secret.nonce.data(), secret.nonce.size(), nullptr, 0, + secret.tag.data(), secret.tag.size(), plaintext.data()); + if (!dec_ok) { + audit_write(h->db, username, user.id, "secret_get", SSM_ERR_INTEGRITY, name, + "{\"error\":\"GCM integrity check with archived KEK failed\"}"); + return SSM_ERR_INTEGRITY; + } + + // 5. Re-encrypt with current KEK in a transaction + if (sqlite3_exec(h->db, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr) != SQLITE_OK) + return SSM_ERR_INTERNAL; + + bool migrate_ok = false; + do { + unsigned char new_nonce[AES_GCM_NONCE_LEN]; + unsigned char new_tag[AES_GCM_TAG_LEN]; + random_bytes(new_nonce, sizeof(new_nonce)); + + secure_vector new_ciphertext(secret.private_key.size()); + if (!aes_gcm_encrypt(plaintext.data(), plaintext.size(), + kek_raw.data(), kek_len, + new_nonce, sizeof(new_nonce), nullptr, 0, + new_ciphertext.data(), new_tag, sizeof(new_tag))) + break; + + if (!secrets_update_ciphertext(h->db, secret.id, new_ciphertext.data(), + new_ciphertext.size(), new_nonce, sizeof(new_nonce), + new_tag, sizeof(new_tag), kek_meta.kek_version)) + break; + + // SAFE-PURGE: if no more secrets reference the old version, delete archive entry + int64_t count = 0; + if (secrets_count_by_kek_version(h->db, user.id, secret.kek_version, &count) && + count == 0) { + kek_archive_delete_version(h->db, user.id, secret.kek_version); + } + + migrate_ok = true; + } while (false); + + if (migrate_ok) { + sqlite3_exec(h->db, "COMMIT", nullptr, nullptr, nullptr); + } else { + sqlite3_exec(h->db, "ROLLBACK", nullptr, nullptr, nullptr); + audit_write(h->db, username, user.id, "secret_get", SSM_ERR_INTERNAL, name, + "{\"error\":\"lazy-migrate re-encrypt failed\"}"); + return SSM_ERR_INTERNAL; + } } std::memcpy(private_key_out, plaintext.data(), secret.private_key.size()); @@ -668,6 +754,53 @@ ssm_status ssm_user_change_password(ssm_handle* h, const char* username, const c if (!kek_ok) break; + // Re-wrap all archive entries with the new wrapping key + std::vector archive_entries; + if (!kek_archive_list_for_user(h->db, user.id, &archive_entries)) + break; + + bool archive_ok = true; + for (auto& entry : archive_entries) { + // Unwrap archived KEK with old wrapping key + size_t archive_kek_len = kek_raw.size(); + if (!kek_unwrap(entry.wrapped_kek.data(), entry.wrapped_kek.size(), + user.password_hash.data(), user.password_hash.size(), + entry.salt.data(), entry.salt.size(), + kek_raw.data(), &archive_kek_len)) { + archive_ok = false; + break; + } + + // Re-wrap with new wrapping key + size_t rewrap_len = new_wrapped.size(); + if (!aes_kw_wrap(kek_raw.data(), archive_kek_len, + new_wrapping_key.data(), new_wrapping_key.size(), + new_wrapped.data(), &rewrap_len)) { + archive_ok = false; + break; + } + + // Update archive entry + const char* upd = "UPDATE kek_archive SET wrapped_kek = ? WHERE id = ?"; + sqlite3_stmt* stmt_a = nullptr; + if (sqlite3_prepare_v2(h->db, upd, -1, &stmt_a, nullptr) != SQLITE_OK) { + archive_ok = false; + break; + } + sqlite3_bind_blob(stmt_a, 1, new_wrapped.data(), + static_cast(rewrap_len), SQLITE_TRANSIENT); + sqlite3_bind_int64(stmt_a, 2, entry.id); + if (sqlite3_step(stmt_a) != SQLITE_DONE) { + sqlite3_finalize(stmt_a); + archive_ok = false; + break; + } + sqlite3_finalize(stmt_a); + } + + if (!archive_ok) + break; + ok = true; } while (false); @@ -710,6 +843,43 @@ ssm_status ssm_kek_rotate(ssm_handle* h, const char* username) { return SSM_OK; } +ssm_status ssm_kek_purge_archive(ssm_handle* h, const char* username) { + if (!h || !username) + return SSM_ERR_INTERNAL; + + std::unique_lock lock(h->mutex); + + user_row user; + if (!users_find_by_username(h->db, username, &user)) { + audit_write(h->db, username, 0, "kek_purge_archive", SSM_ERR_AUTH, nullptr, + "{\"error\":\"user not found\"}"); + return SSM_ERR_AUTH; + } + + // List all archive entries for this user + std::vector entries; + if (!kek_archive_list_for_user(h->db, user.id, &entries)) { + audit_write(h->db, username, user.id, "kek_purge_archive", SSM_ERR_INTERNAL, nullptr, + "{\"error\":\"failed to list archive\"}"); + return SSM_ERR_INTERNAL; + } + + int deleted = 0; + for (auto& entry : entries) { + int64_t count = 0; + if (secrets_count_by_kek_version(h->db, user.id, entry.kek_version, &count) && + count == 0) { + if (kek_archive_delete_version(h->db, user.id, entry.kek_version)) + ++deleted; + } + } + + char det[64] = {}; + std::snprintf(det, sizeof(det), "{\"deleted\":%d}", deleted); + audit_write(h->db, username, user.id, "kek_purge_archive", SSM_OK, nullptr, det); + return SSM_OK; +} + ssm_status ssm_backup_create(ssm_handle* h, const char* backup_path, const unsigned char* backup_key, size_t backup_key_len) { if (!h || !backup_path || !backup_key) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f018499..103a392 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable(ssm_test db/secrets_test.cc db/kek_archive_test.cc kek/kek_test.cc + lazy_reencryption_test.cc ssm_test.cc integration/lifecycle_test.cc backup_test.cc diff --git a/tests/kek/kek_test.cc b/tests/kek/kek_test.cc index c01ad64..6fc6469 100644 --- a/tests/kek/kek_test.cc +++ b/tests/kek/kek_test.cc @@ -6,8 +6,10 @@ #include #include "crypto/aes_gcm.h" +#include "crypto/aes_kw.h" #include "crypto/random.h" #include "db/database.h" +#include "db/kek_archive.h" #include "db/kek_metadata.h" #include "db/secrets.h" #include "db/users.h" @@ -140,7 +142,7 @@ TEST(KekGenerateTest, WrongAuthHashFailsUnwrap) { unsigned char unwrapped[KEK_KEY_LEN]; size_t unwrapped_len = sizeof(unwrapped); EXPECT_FALSE(kek_unwrap(wrapped, wrapped_len, wrong_hash, sizeof(wrong_hash), salt, salt_len, - unwrapped, &unwrapped_len)); + unwrapped, &unwrapped_len)); } // --- expiry --- @@ -174,7 +176,7 @@ TEST(KekExpiryTest, ExpiresAtNegativeDays) { EXPECT_TRUE(kek_is_expired(buf)); } -// --- rotation --- +// --- rotation (O(1) archive-and-switch) --- class KekRotationTest : public ::testing::Test { protected: @@ -188,14 +190,14 @@ class KekRotationTest : public ::testing::Test { void TearDown() override { db_close(db_); } }; -TEST_F(KekRotationTest, RotateGeneratesNewKek) { +TEST_F(KekRotationTest, RotateGeneratesNewKekAndArchivesOld) { unsigned char auth_hash[64] = {}; random_bytes(auth_hash, sizeof(auth_hash)); int64_t user_id; ASSERT_TRUE(users_create(db_, "alice", auth_hash, sizeof(auth_hash), &user_id)); - // initial KEK + // initial KEK (version 1) unsigned char wrapped[64]; size_t wrapped_len = sizeof(wrapped); unsigned char salt[KEK_SALT_LEN]; @@ -205,7 +207,7 @@ TEST_F(KekRotationTest, RotateGeneratesNewKek) { expires_at, sizeof(expires_at))); ASSERT_TRUE(kek_store(db_, user_id, wrapped, wrapped_len, salt, salt_len, expires_at)); - // store a secret encrypted with this KEK + // store a secret encrypted with version 1 KEK unsigned char kek_raw[KEK_KEY_LEN]; size_t kek_len = sizeof(kek_raw); ASSERT_TRUE(kek_unwrap(wrapped, wrapped_len, auth_hash, sizeof(auth_hash), salt, salt_len, @@ -218,45 +220,54 @@ TEST_F(KekRotationTest, RotateGeneratesNewKek) { random_bytes(nonce, sizeof(nonce)); ASSERT_TRUE(aes_gcm_encrypt(secret_plain, sizeof(secret_plain), kek_raw, sizeof(kek_raw), nonce, sizeof(nonce), nullptr, 0, ciphertext, tag, sizeof(tag))); - ASSERT_TRUE(secrets_store(db_, user_id, "key1", ciphertext, sizeof(ciphertext), nullptr, 0, nonce, sizeof(nonce), tag, sizeof(tag), "test key")); - // rotate + // capture pre-rotation state + kek_row before; + ASSERT_TRUE(kek_find_by_user(db_, user_id, &before)); + EXPECT_EQ(before.kek_version, 1); + + secret_row secret_before; + ASSERT_TRUE(secrets_find(db_, user_id, "key1", &secret_before)); + auto saved_cipher = secret_before.private_key; + + // rotate (O(1) — archive current, generate new, no secrets loop) ASSERT_TRUE(kek_rotate(db_, user_id, auth_hash, sizeof(auth_hash))); - // verify kek_metadata was updated - kek_row new_row; - ASSERT_TRUE(kek_find_by_user(db_, user_id, &new_row)); - EXPECT_NE(memcmp(new_row.wrapped_kek.data(), wrapped, wrapped_len), 0); - EXPECT_NE(memcmp(new_row.salt.data(), salt, salt_len), 0); - EXPECT_GT(new_row.expires_at.size(), 0u); - - // verify secret can be decrypted with new KEK - unsigned char new_kek[KEK_KEY_LEN]; - size_t new_kek_len = sizeof(new_kek); - ASSERT_TRUE(kek_unwrap(new_row.wrapped_kek.data(), new_row.wrapped_kek.size(), auth_hash, - sizeof(auth_hash), new_row.salt.data(), new_row.salt.size(), new_kek, - &new_kek_len)); - - secret_row updated; - ASSERT_TRUE(secrets_find(db_, user_id, "key1", &updated)); - - secure_vector decrypted(updated.private_key.size()); - ASSERT_TRUE(aes_gcm_decrypt(updated.private_key.data(), updated.private_key.size(), new_kek, - sizeof(new_kek), updated.nonce.data(), updated.nonce.size(), - nullptr, 0, updated.tag.data(), updated.tag.size(), - decrypted.data())); - EXPECT_EQ(memcmp(decrypted.data(), secret_plain, sizeof(secret_plain)), 0); + // verify kek_metadata was updated (version incremented) + kek_row after; + ASSERT_TRUE(kek_find_by_user(db_, user_id, &after)); + EXPECT_EQ(after.kek_version, 2); + EXPECT_NE(memcmp(after.wrapped_kek.data(), wrapped, wrapped_len), 0); + + // verify archive entry for version 1 exists + kek_archive_row archived; + ASSERT_TRUE(kek_archive_find_by_version(db_, user_id, 1, &archived)); + EXPECT_EQ(archived.user_id, user_id); + EXPECT_EQ(archived.kek_version, 1); + EXPECT_EQ(archived.wrapped_kek.size(), before.wrapped_kek.size()); + EXPECT_EQ(memcmp(archived.wrapped_kek.data(), before.wrapped_kek.data(), + before.wrapped_kek.size()), 0); + EXPECT_EQ(archived.salt.size(), before.salt.size()); + EXPECT_FALSE(archived.expires_at.empty()); + EXPECT_FALSE(archived.created_at.empty()); + + // verify secret was NOT modified (O(1) guarantee) + secret_row secret_after; + ASSERT_TRUE(secrets_find(db_, user_id, "key1", &secret_after)); + EXPECT_EQ(secret_after.kek_version, 1); // still at version 1 + EXPECT_EQ(memcmp(secret_after.private_key.data(), saved_cipher.data(), saved_cipher.size()), 0); } -TEST_F(KekRotationTest, RotateMultipleSecrets) { +TEST_F(KekRotationTest, O1RotationWith1000SecretsNoSecretScan) { unsigned char auth_hash[64] = {}; random_bytes(auth_hash, sizeof(auth_hash)); int64_t user_id; - ASSERT_TRUE(users_create(db_, "bob", auth_hash, sizeof(auth_hash), &user_id)); + ASSERT_TRUE(users_create(db_, "o1bulk", auth_hash, sizeof(auth_hash), &user_id)); + // initial KEK unsigned char wrapped[64]; size_t wrapped_len = sizeof(wrapped); unsigned char salt[KEK_SALT_LEN]; @@ -266,70 +277,63 @@ TEST_F(KekRotationTest, RotateMultipleSecrets) { expires_at, sizeof(expires_at))); ASSERT_TRUE(kek_store(db_, user_id, wrapped, wrapped_len, salt, salt_len, expires_at)); - unsigned char kek_raw[KEK_KEY_LEN]; - size_t kek_len = sizeof(kek_raw); - ASSERT_TRUE(kek_unwrap(wrapped, wrapped_len, auth_hash, sizeof(auth_hash), salt, salt_len, - kek_raw, &kek_len)); - - for (int i = 0; i < 3; ++i) { - unsigned char pt[16]; - unsigned char n[AES_GCM_NONCE_LEN]; - unsigned char t[AES_GCM_TAG_LEN]; - unsigned char ct[sizeof(pt)]; - random_bytes(pt, sizeof(pt)); - random_bytes(n, sizeof(n)); - ASSERT_TRUE(aes_gcm_encrypt(pt, sizeof(pt), kek_raw, sizeof(kek_raw), n, sizeof(n), nullptr, - 0, ct, t, sizeof(t))); - char name[8]; - snprintf(name, sizeof(name), "k%d", i); - ASSERT_TRUE(secrets_store(db_, user_id, name, ct, sizeof(ct), nullptr, 0, n, sizeof(n), t, - sizeof(t), nullptr)); + // Insert 1005 fake secrets directly (no crypto needed) + ASSERT_EQ(sqlite3_exec(db_, "BEGIN IMMEDIATE", nullptr, nullptr, nullptr), SQLITE_OK); + const char* insert_sql = + "INSERT INTO secrets (user_id, name, private_key, nonce, tag, kek_version) " + "VALUES (?, ?, X'00', X'00', X'00', 1)"; + sqlite3_stmt* stmt = nullptr; + ASSERT_EQ(sqlite3_prepare_v2(db_, insert_sql, -1, &stmt, nullptr), SQLITE_OK); + for (int i = 0; i < 1005; ++i) { + char name[16]; + std::snprintf(name, sizeof(name), "bulk_%d", i); + sqlite3_reset(stmt); + sqlite3_bind_int64(stmt, 1, user_id); + sqlite3_bind_text(stmt, 2, name, -1, SQLITE_STATIC); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_DONE); } + sqlite3_finalize(stmt); + ASSERT_EQ(sqlite3_exec(db_, "COMMIT", nullptr, nullptr, nullptr), SQLITE_OK); + // Verify 1005 secrets exist + int64_t count_before = 0; + ASSERT_TRUE(secrets_count_by_kek_version(db_, user_id, 1, &count_before)); + EXPECT_EQ(count_before, 1005); + + // Rotate — O(1) must NOT scan secrets ASSERT_TRUE(kek_rotate(db_, user_id, auth_hash, sizeof(auth_hash))); - // verify all 3 secrets still accessible with new KEK - kek_row new_row; - ASSERT_TRUE(kek_find_by_user(db_, user_id, &new_row)); - unsigned char new_kek[KEK_KEY_LEN]; - size_t new_kek_len = sizeof(new_kek); - ASSERT_TRUE(kek_unwrap(new_row.wrapped_kek.data(), new_row.wrapped_kek.size(), auth_hash, - sizeof(auth_hash), new_row.salt.data(), new_row.salt.size(), new_kek, - &new_kek_len)); + // Verify NO secrets were updated — all still at kek_version=1 + int64_t count_after = 0; + ASSERT_TRUE(secrets_count_by_kek_version(db_, user_id, 1, &count_after)); + EXPECT_EQ(count_after, 1005); - for (int i = 0; i < 3; ++i) { - char name[8]; - snprintf(name, sizeof(name), "k%d", i); - secret_row sr; - ASSERT_TRUE(secrets_find(db_, user_id, name, &sr)); - secure_vector dec(sr.private_key.size()); - ASSERT_TRUE(aes_gcm_decrypt(sr.private_key.data(), sr.private_key.size(), new_kek, - sizeof(new_kek), sr.nonce.data(), sr.nonce.size(), nullptr, 0, - sr.tag.data(), sr.tag.size(), dec.data())); - } + // Verify kek_version incremented + kek_row meta; + ASSERT_TRUE(kek_find_by_user(db_, user_id, &meta)); + EXPECT_EQ(meta.kek_version, 2); + + // Verify archive entry was created + kek_archive_row archived; + ASSERT_TRUE(kek_archive_find_by_version(db_, user_id, 1, &archived)); + EXPECT_EQ(archived.kek_version, 1); } -TEST_F(KekRotationTest, RotateWithoutSecretsSucceeds) { +TEST_F(KekRotationTest, O1RotationNullParams) { unsigned char auth_hash[64] = {}; random_bytes(auth_hash, sizeof(auth_hash)); + EXPECT_FALSE(kek_rotate(nullptr, 1, auth_hash, sizeof(auth_hash))); + EXPECT_FALSE(kek_rotate(db_, 1, nullptr, 0)); +} - int64_t user_id; - ASSERT_TRUE(users_create(db_, "carol", auth_hash, sizeof(auth_hash), &user_id)); - - unsigned char wrapped[64]; - size_t wrapped_len = sizeof(wrapped); - unsigned char salt[KEK_SALT_LEN]; - size_t salt_len = sizeof(salt); - char expires_at[24]; - ASSERT_TRUE(kek_generate(auth_hash, sizeof(auth_hash), wrapped, &wrapped_len, salt, &salt_len, - expires_at, sizeof(expires_at))); - ASSERT_TRUE(kek_store(db_, user_id, wrapped, wrapped_len, salt, salt_len, expires_at)); +TEST_F(KekRotationTest, NoKekMetadataFailsRotation) { + unsigned char auth_hash[64] = {}; + random_bytes(auth_hash, sizeof(auth_hash)); - EXPECT_TRUE(kek_rotate(db_, user_id, auth_hash, sizeof(auth_hash))); + int64_t user_id; + ASSERT_TRUE(users_create(db_, "nokek", auth_hash, sizeof(auth_hash), &user_id)); - kek_row new_row; - ASSERT_TRUE(kek_find_by_user(db_, user_id, &new_row)); - EXPECT_NE(memcmp(new_row.wrapped_kek.data(), wrapped, wrapped_len), 0); + EXPECT_FALSE(kek_rotate(db_, user_id, auth_hash, sizeof(auth_hash))); } TEST_F(KekRotationTest, WrongAuthHashFailsRotation) { @@ -339,7 +343,7 @@ TEST_F(KekRotationTest, WrongAuthHashFailsRotation) { random_bytes(wrong_hash, sizeof(wrong_hash)); int64_t user_id; - ASSERT_TRUE(users_create(db_, "dave", auth_hash, sizeof(auth_hash), &user_id)); + ASSERT_TRUE(users_create(db_, "wrongauth", auth_hash, sizeof(auth_hash), &user_id)); unsigned char wrapped[64]; size_t wrapped_len = sizeof(wrapped); @@ -353,12 +357,12 @@ TEST_F(KekRotationTest, WrongAuthHashFailsRotation) { EXPECT_FALSE(kek_rotate(db_, user_id, wrong_hash, sizeof(wrong_hash))); } -TEST_F(KekRotationTest, RotateWithPublicKey) { +TEST_F(KekRotationTest, RotateWithoutSecretsSucceeds) { unsigned char auth_hash[64] = {}; random_bytes(auth_hash, sizeof(auth_hash)); int64_t user_id; - ASSERT_TRUE(users_create(db_, "eve", auth_hash, sizeof(auth_hash), &user_id)); + ASSERT_TRUE(users_create(db_, "noscrts", auth_hash, sizeof(auth_hash), &user_id)); unsigned char wrapped[64]; size_t wrapped_len = sizeof(wrapped); @@ -369,59 +373,18 @@ TEST_F(KekRotationTest, RotateWithPublicKey) { expires_at, sizeof(expires_at))); ASSERT_TRUE(kek_store(db_, user_id, wrapped, wrapped_len, salt, salt_len, expires_at)); - unsigned char kek_raw[KEK_KEY_LEN]; - size_t kek_len = sizeof(kek_raw); - ASSERT_TRUE(kek_unwrap(wrapped, wrapped_len, auth_hash, sizeof(auth_hash), salt, salt_len, - kek_raw, &kek_len)); - - unsigned char priv[] = "private-key-material-here-32bytes!"; - unsigned char pub[] = "public-key-material-here!"; - unsigned char nonce[AES_GCM_NONCE_LEN]; - unsigned char priv_tag[AES_GCM_TAG_LEN]; - unsigned char priv_ct[sizeof(priv)]; - random_bytes(nonce, sizeof(nonce)); - - ASSERT_TRUE(aes_gcm_encrypt(priv, sizeof(priv), kek_raw, sizeof(kek_raw), nonce, sizeof(nonce), - nullptr, 0, priv_ct, priv_tag, sizeof(priv_tag))); - - // public_key is stored plaintext (public keys are public) - ASSERT_TRUE(secrets_store(db_, user_id, "keypair", priv_ct, sizeof(priv_ct), pub, sizeof(pub), - nonce, sizeof(nonce), priv_tag, sizeof(priv_tag), - "keypair with pub")); - - ASSERT_TRUE(kek_rotate(db_, user_id, auth_hash, sizeof(auth_hash))); + EXPECT_TRUE(kek_rotate(db_, user_id, auth_hash, sizeof(auth_hash))); kek_row new_row; ASSERT_TRUE(kek_find_by_user(db_, user_id, &new_row)); - unsigned char new_kek[KEK_KEY_LEN]; - size_t new_kek_len = sizeof(new_kek); - ASSERT_TRUE(kek_unwrap(new_row.wrapped_kek.data(), new_row.wrapped_kek.size(), auth_hash, - sizeof(auth_hash), new_row.salt.data(), new_row.salt.size(), new_kek, - &new_kek_len)); - - secret_row sr; - ASSERT_TRUE(secrets_find(db_, user_id, "keypair", &sr)); - ASSERT_FALSE(sr.public_key.empty()); - - secure_vector dec_priv(sr.private_key.size()); - ASSERT_TRUE(aes_gcm_decrypt(sr.private_key.data(), sr.private_key.size(), new_kek, - sizeof(new_kek), sr.nonce.data(), sr.nonce.size(), nullptr, 0, - sr.tag.data(), sr.tag.size(), dec_priv.data())); - EXPECT_EQ(memcmp(dec_priv.data(), priv, sizeof(priv)), 0); - - // public_key is preserved plaintext - ASSERT_EQ(sr.public_key.size(), sizeof(pub)); - EXPECT_EQ(memcmp(sr.public_key.data(), pub, sizeof(pub)), 0); -} - -TEST_F(KekRotationTest, NoKekMetadataFailsRotation) { - unsigned char auth_hash[64] = {}; - random_bytes(auth_hash, sizeof(auth_hash)); - - int64_t user_id; - ASSERT_TRUE(users_create(db_, "frank", auth_hash, sizeof(auth_hash), &user_id)); + EXPECT_EQ(new_row.kek_version, 2); + EXPECT_NE(memcmp(new_row.wrapped_kek.data(), wrapped, wrapped_len), 0); - EXPECT_FALSE(kek_rotate(db_, user_id, auth_hash, sizeof(auth_hash))); + // Verify archive entry for version 1 + kek_archive_row archived; + ASSERT_TRUE(kek_archive_find_by_version(db_, user_id, 1, &archived)); + EXPECT_EQ(archived.user_id, user_id); + EXPECT_EQ(archived.kek_version, 1); } TEST_F(KekRotationTest, RotationPreservesAllSecretsCount) { @@ -429,7 +392,7 @@ TEST_F(KekRotationTest, RotationPreservesAllSecretsCount) { random_bytes(auth_hash, sizeof(auth_hash)); int64_t user_id; - ASSERT_TRUE(users_create(db_, "grace", auth_hash, sizeof(auth_hash), &user_id)); + ASSERT_TRUE(users_create(db_, "cnttest", auth_hash, sizeof(auth_hash), &user_id)); unsigned char wrapped[64]; size_t wrapped_len = sizeof(wrapped); @@ -453,9 +416,9 @@ TEST_F(KekRotationTest, RotationPreservesAllSecretsCount) { random_bytes(pt, sizeof(pt)); random_bytes(n, sizeof(n)); ASSERT_TRUE(aes_gcm_encrypt(pt, sizeof(pt), kek_raw, sizeof(kek_raw), n, sizeof(n), nullptr, - 0, ct, t, sizeof(t))); + 0, ct, t, sizeof(t))); char name[8]; - snprintf(name, sizeof(name), "s%d", i); + std::snprintf(name, sizeof(name), "s%d", i); ASSERT_TRUE(secrets_store(db_, user_id, name, ct, sizeof(ct), nullptr, 0, n, sizeof(n), t, sizeof(t), nullptr)); } @@ -471,5 +434,55 @@ TEST_F(KekRotationTest, RotationPreservesAllSecretsCount) { EXPECT_EQ(after.size(), 5u); } +TEST_F(KekRotationTest, RotateWithPublicKeyPreserved) { + unsigned char auth_hash[64] = {}; + random_bytes(auth_hash, sizeof(auth_hash)); + + int64_t user_id; + ASSERT_TRUE(users_create(db_, "pubtest", auth_hash, sizeof(auth_hash), &user_id)); + + unsigned char wrapped[64]; + size_t wrapped_len = sizeof(wrapped); + unsigned char salt[KEK_SALT_LEN]; + size_t salt_len = sizeof(salt); + char expires_at[24]; + ASSERT_TRUE(kek_generate(auth_hash, sizeof(auth_hash), wrapped, &wrapped_len, salt, &salt_len, + expires_at, sizeof(expires_at))); + ASSERT_TRUE(kek_store(db_, user_id, wrapped, wrapped_len, salt, salt_len, expires_at)); + + unsigned char kek_raw[KEK_KEY_LEN]; + size_t kek_len = sizeof(kek_raw); + ASSERT_TRUE(kek_unwrap(wrapped, wrapped_len, auth_hash, sizeof(auth_hash), salt, salt_len, + kek_raw, &kek_len)); + + unsigned char priv[] = "private-key-material-here-32bytes!"; + unsigned char pub[] = "public-key-material-here!"; + unsigned char nonce[AES_GCM_NONCE_LEN]; + unsigned char priv_tag[AES_GCM_TAG_LEN]; + unsigned char priv_ct[sizeof(priv)]; + random_bytes(nonce, sizeof(nonce)); + ASSERT_TRUE(aes_gcm_encrypt(priv, sizeof(priv), kek_raw, sizeof(kek_raw), nonce, sizeof(nonce), + nullptr, 0, priv_ct, priv_tag, sizeof(priv_tag))); + ASSERT_TRUE(secrets_store(db_, user_id, "keypair", priv_ct, sizeof(priv_ct), pub, sizeof(pub), + nonce, sizeof(nonce), priv_tag, sizeof(priv_tag), "keypair with pub")); + + ASSERT_TRUE(kek_rotate(db_, user_id, auth_hash, sizeof(auth_hash))); + + // Verify archive entry + kek_archive_row archived; + ASSERT_TRUE(kek_archive_find_by_version(db_, user_id, 1, &archived)); + EXPECT_EQ(archived.kek_version, 1); + + // Verify secret public_key is preserved + secret_row sr; + ASSERT_TRUE(secrets_find(db_, user_id, "keypair", &sr)); + ASSERT_FALSE(sr.public_key.empty()); + ASSERT_EQ(sr.public_key.size(), sizeof(pub)); + EXPECT_EQ(memcmp(sr.public_key.data(), pub, sizeof(pub)), 0); + + // Verify secret still at kek_version=1 (O(1) guarantee) + EXPECT_EQ(sr.kek_version, 1); +} + } // namespace } // namespace ssm::v1 diff --git a/tests/lazy_reencryption_test.cc b/tests/lazy_reencryption_test.cc new file mode 100644 index 0000000..a6c4f3d --- /dev/null +++ b/tests/lazy_reencryption_test.cc @@ -0,0 +1,592 @@ +#include "ssm/ssm.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "db/database.h" +#include "db/kek_archive.h" +#include "db/kek_metadata.h" +#include "db/secrets.h" +#include "db/users.h" +#include "utils/secure_memory.h" + +namespace ssm::v1 { +namespace { + +// ============================================================================ +// Task 2.2 — Lazy-migrate in ssm_secret_get +// ============================================================================ + +class LazyReencryptionTest : public ::testing::Test { +protected: + ssm_handle* handle_ = nullptr; + const char* path_ = "/data/data/com.termux/files/usr/tmp/opencode/lazy_reencrypt.db"; + + void SetUp() override { + ::remove(path_); + ASSERT_EQ(ssm_init(&handle_, path_, nullptr, 0), SSM_OK); + ASSERT_NE(handle_, nullptr); + } + + void TearDown() override { + if (handle_) { + ssm_destroy(handle_); + handle_ = nullptr; + } + ::remove(path_); + } + + // Helper: open a raw DB connection for verification + sqlite3* open_raw() { + sqlite3* db = nullptr; + EXPECT_TRUE(db_open(path_, nullptr, 0, &db)); + return db; + } + + // Helper: read kek_version directly from secrets table + int64_t secret_kek_version(const char* username, const char* secret_name) { + sqlite3* db = open_raw(); + if (!db) return -1; + const char* sql = + "SELECT s.kek_version FROM secrets s " + "JOIN users u ON u.id = s.user_id " + "WHERE u.username = ? AND s.name = ?"; + sqlite3_stmt* stmt = nullptr; + int64_t ver = -1; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, username, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, secret_name, -1, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) + ver = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + } + db_close(db); + return ver; + } + + // Helper: check archive entry exists + bool archive_exists(const char* username, int64_t kek_version) { + sqlite3* db = open_raw(); + if (!db) return false; + const char* sql = + "SELECT COUNT(*) FROM kek_archive a " + "JOIN users u ON u.id = a.user_id " + "WHERE u.username = ? AND a.kek_version = ?"; + sqlite3_stmt* stmt = nullptr; + bool exists = false; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, username, -1, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 2, kek_version); + if (sqlite3_step(stmt) == SQLITE_ROW) + exists = sqlite3_column_int64(stmt, 0) > 0; + sqlite3_finalize(stmt); + } + db_close(db); + return exists; + } + + // Helper: count archive entries for user + int64_t archive_count(const char* username) { + sqlite3* db = open_raw(); + if (!db) return -1; + const char* sql = + "SELECT COUNT(*) FROM kek_archive a " + "JOIN users u ON u.id = a.user_id " + "WHERE u.username = ?"; + sqlite3_stmt* stmt = nullptr; + int64_t cnt = -1; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, username, -1, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) + cnt = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + } + db_close(db); + return cnt; + } +}; + +// 2.2 — Test: store with kek_version=1, rotate, get, verify kek_version updated +TEST_F(LazyReencryptionTest, LazyMigrateUpdatesKekVersionOnGet) { + ASSERT_EQ(ssm_user_register(handle_, "lazy1", "password123"), SSM_OK); + + // Store secret at kek_version=1 (default) + const unsigned char priv[] = "test-secret-32bytes-xxxxxxxxxxxxxx!!"; + ASSERT_EQ(ssm_secret_store(handle_, "lazy1", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Verify initial kek_version is 1 + EXPECT_EQ(secret_kek_version("lazy1", "mykey"), 1); + + // Rotate to kek_version=2 (archives version 1) + ASSERT_EQ(ssm_kek_rotate(handle_, "lazy1"), SSM_OK); + + // Get secret — triggers lazy-migrate from version 1 to 2 + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "lazy1", "mykey", out, &len, nullptr, nullptr), SSM_OK); + EXPECT_EQ(len, sizeof(priv)); + EXPECT_EQ(std::memcmp(out, priv, sizeof(priv)), 0); + + // Verify kek_version was updated to 2 + EXPECT_EQ(secret_kek_version("lazy1", "mykey"), 2); +} + +// 2.2 — Test: fast path — secret already has current kek_version +TEST_F(LazyReencryptionTest, LazyMigrateFastPathWhenCurrentVersion) { + ASSERT_EQ(ssm_user_register(handle_, "fast1", "password123"), SSM_OK); + + const unsigned char priv[] = "fast-path-secret-data-32bytes!!!"; + ASSERT_EQ(ssm_secret_store(handle_, "fast1", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Get secret — both are at version 1, no archive needed + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "fast1", "mykey", out, &len, nullptr, nullptr), SSM_OK); + EXPECT_EQ(len, sizeof(priv)); + EXPECT_EQ(std::memcmp(out, priv, sizeof(priv)), 0); + + // kek_version should remain 1 + EXPECT_EQ(secret_kek_version("fast1", "mykey"), 1); +} + +// 2.2 — Test: secret with stale kek_version but missing archive → SSM_ERR_INTEGRITY +TEST_F(LazyReencryptionTest, LazyMigrateWithMissingArchiveReturnsIntegrityError) { + ASSERT_EQ(ssm_user_register(handle_, "missing1", "password123"), SSM_OK); + + const unsigned char priv[] = "missing-archive-secret-key!!!"; + ASSERT_EQ(ssm_secret_store(handle_, "missing1", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Rotate (archives version 1) + ASSERT_EQ(ssm_kek_rotate(handle_, "missing1"), SSM_OK); + + // Manually DELETE the archive entry to simulate corruption + sqlite3* raw = open_raw(); + ASSERT_NE(raw, nullptr); + sqlite3_exec(raw, + "DELETE FROM kek_archive " + "WHERE user_id = (SELECT id FROM users WHERE username = 'missing1')", + nullptr, nullptr, nullptr); + db_close(raw); + + // Get secret — archive for version 1 is missing, should fail with INTEGRITY + unsigned char out[64] = {}; + size_t len = sizeof(out); + EXPECT_EQ(ssm_secret_get(handle_, "missing1", "mykey", out, &len, nullptr, nullptr), + SSM_ERR_INTEGRITY); +} + +// ============================================================================ +// Task 2.3 — Safe-purge after migrate +// ============================================================================ + +// 2.3 — Test: rotate, get secret (triggers migrate), verify old archive entry is deleted +TEST_F(LazyReencryptionTest, SafePurgeDeletesArchiveWhenLastSecretMigrates) { + ASSERT_EQ(ssm_user_register(handle_, "purge1", "password123"), SSM_OK); + + // Store ONE secret + const unsigned char priv[] = "only-one-secret-data-32bytes!!!"; + ASSERT_EQ(ssm_secret_store(handle_, "purge1", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Rotate to version 2 + ASSERT_EQ(ssm_kek_rotate(handle_, "purge1"), SSM_OK); + + // Verify archive entry exists + EXPECT_TRUE(archive_exists("purge1", 1)); + + // Get secret — migrates to version 2, should purge archive for version 1 + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "purge1", "mykey", out, &len, nullptr, nullptr), SSM_OK); + EXPECT_EQ(std::memcmp(out, priv, sizeof(priv)), 0); + + // Archive entry for version 1 should be DELETED (count=0 after migrate) + EXPECT_FALSE(archive_exists("purge1", 1)); +} + +// 2.3 — Test: if TWO secrets reference old version, archive persists after first migrate +TEST_F(LazyReencryptionTest, SafePurgePreservesArchiveWhenSecretsRemain) { + ASSERT_EQ(ssm_user_register(handle_, "purge2", "password123"), SSM_OK); + + const unsigned char priv[] = "shared-version-key-data-32bytes!!"; + ASSERT_EQ(ssm_secret_store(handle_, "purge2", priv, sizeof(priv), nullptr, 0, "key1", nullptr), + SSM_OK); + ASSERT_EQ(ssm_secret_store(handle_, "purge2", priv, sizeof(priv), nullptr, 0, "key2", nullptr), + SSM_OK); + + // Rotate to version 2 + ASSERT_EQ(ssm_kek_rotate(handle_, "purge2"), SSM_OK); + + // Get key1 — migrates to version 2, but key2 still at version 1 + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "purge2", "key1", out, &len, nullptr, nullptr), SSM_OK); + + // Archive should still exist (one secret still at version 1) + EXPECT_TRUE(archive_exists("purge2", 1)); + + // Get key2 — migrates the last secret + ASSERT_EQ(ssm_secret_get(handle_, "purge2", "key2", out, &len, nullptr, nullptr), SSM_OK); + + // Now archive should be deleted (no secrets left at version 1) + EXPECT_FALSE(archive_exists("purge2", 1)); +} + +// 2.3 — Test: archive persists with >0 count even after some migrate +TEST_F(LazyReencryptionTest, SafePurgeCountRespected) { + ASSERT_EQ(ssm_user_register(handle_, "purge3", "password123"), SSM_OK); + + const unsigned char priv[] = "count-respected-data-32bytes!!!!"; + ASSERT_EQ(ssm_secret_store(handle_, "purge3", priv, sizeof(priv), nullptr, 0, "ka", nullptr), + SSM_OK); + ASSERT_EQ(ssm_secret_store(handle_, "purge3", priv, sizeof(priv), nullptr, 0, "kb", nullptr), + SSM_OK); + ASSERT_EQ(ssm_secret_store(handle_, "purge3", priv, sizeof(priv), nullptr, 0, "kc", nullptr), + SSM_OK); + + // Rotate to version 2 + ASSERT_EQ(ssm_kek_rotate(handle_, "purge3"), SSM_OK); + + // Migrate only ONE of three secrets + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "purge3", "ka", out, &len, nullptr, nullptr), SSM_OK); + + // Archive must persist — 2 secrets still at version 1 + EXPECT_TRUE(archive_exists("purge3", 1)); + EXPECT_EQ(archive_count("purge3"), 1); +} + +// ============================================================================ +// Task 2.4 — ssm_kek_purge_archive +// ============================================================================ + +// 2.4 — Test: purge with no archive entries → no error +TEST_F(LazyReencryptionTest, PurgeArchiveWithNoEntries) { + ASSERT_EQ(ssm_user_register(handle_, "nopurge", "password123"), SSM_OK); + + // No archive entries exist (no rotations done) + EXPECT_EQ(ssm_kek_purge_archive(handle_, "nopurge"), SSM_OK); +} + +// 2.4 — Test: purge when all secrets migrated → deletes archive entries +TEST_F(LazyReencryptionTest, PurgeArchiveWhenAllMigrated) { + ASSERT_EQ(ssm_user_register(handle_, "purgeall", "password123"), SSM_OK); + + const unsigned char priv[] = "purge-all-secrets-data-32bytes!!"; + ASSERT_EQ(ssm_secret_store(handle_, "purgeall", priv, sizeof(priv), nullptr, 0, "k1", nullptr), + SSM_OK); + + // Rotate twice — creates 2 archive entries + ASSERT_EQ(ssm_kek_rotate(handle_, "purgeall"), SSM_OK); + ASSERT_EQ(ssm_kek_rotate(handle_, "purgeall"), SSM_OK); + + // Verify 2 archive entries + EXPECT_EQ(archive_count("purgeall"), 2); + + // Migrate the secret through both versions (via 2 gets) — assumes migration handles + // version gaps: version 1→3 via sequential gets + // Actually: secret is at version 1 after first get it goes to 2, archive v1 stays + // Second get: secret at 2, current at 3, migrates 2→3, archive v2 stays + // Then purge should delete both archives since no secrets reference them + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "purgeall", "k1", out, &len, nullptr, nullptr), SSM_OK); + ASSERT_EQ(ssm_secret_get(handle_, "purgeall", "k1", out, &len, nullptr, nullptr), SSM_OK); + + // Now purge + EXPECT_EQ(ssm_kek_purge_archive(handle_, "purgeall"), SSM_OK); + + // All archive entries should be deleted + EXPECT_EQ(archive_count("purgeall"), 0); +} + +// 2.4 — Test: purge when secrets still reference old version → archives preserved +TEST_F(LazyReencryptionTest, PurgeArchivePreservesWhenSecretsRemain) { + ASSERT_EQ(ssm_user_register(handle_, "preserve", "password123"), SSM_OK); + + const unsigned char priv[] = "preserved-data-32bytes-xxxxxxxxx!!"; + ASSERT_EQ(ssm_secret_store(handle_, "preserve", priv, sizeof(priv), nullptr, 0, "k1", nullptr), + SSM_OK); + ASSERT_EQ(ssm_secret_store(handle_, "preserve", priv, sizeof(priv), nullptr, 0, "k2", nullptr), + SSM_OK); + + // Rotate once — creates archive entry for version 1 + ASSERT_EQ(ssm_kek_rotate(handle_, "preserve"), SSM_OK); + + // Migrate only one secret + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "preserve", "k1", out, &len, nullptr, nullptr), SSM_OK); + + // One secret still at version 1 — purge should preserve archive + EXPECT_EQ(ssm_kek_purge_archive(handle_, "preserve"), SSM_OK); + + // Archive should still exist (one secret at version 1) + EXPECT_TRUE(archive_exists("preserve", 1)); +} + +// ============================================================================ +// Task 3.1 — Password change with 3 archived KEKs +// ============================================================================ + +TEST_F(LazyReencryptionTest, PasswordChangeWithThreeArchiveEntries) { + ASSERT_EQ(ssm_user_register(handle_, "pwarch", "oldpassword123"), SSM_OK); + + // Store a secret + const unsigned char priv[] = "password-change-archive-data-32b!"; + ASSERT_EQ(ssm_secret_store(handle_, "pwarch", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Rotate 3 times (creates 3 archive entries: versions 1, 2, 3; current = 4) + ASSERT_EQ(ssm_kek_rotate(handle_, "pwarch"), SSM_OK); + ASSERT_EQ(ssm_kek_rotate(handle_, "pwarch"), SSM_OK); + ASSERT_EQ(ssm_kek_rotate(handle_, "pwarch"), SSM_OK); + + // Verify 3 archive entries + EXPECT_EQ(archive_count("pwarch"), 3); + + // Change password — must re-wrap all 3 archives + current KEK + ASSERT_EQ(ssm_user_change_password(handle_, "pwarch", "oldpassword123", "newpassword456"), + SSM_OK); + + // Verify new password works + int valid = 0; + EXPECT_EQ(ssm_user_authenticate(handle_, "pwarch", "newpassword456", &valid), SSM_OK); + EXPECT_EQ(valid, 1); + + // Old password should fail + EXPECT_EQ(ssm_user_authenticate(handle_, "pwarch", "oldpassword123", &valid), SSM_OK); + EXPECT_EQ(valid, 0); + + // Get secret — should still decrypt correctly (archive re-wrap worked) + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "pwarch", "mykey", out, &len, nullptr, nullptr), SSM_OK); + EXPECT_EQ(std::memcmp(out, priv, sizeof(priv)), 0); + + // Archive entries should still exist (3 entries, all re-wrapped) + EXPECT_EQ(archive_count("pwarch"), 3); +} + +// ============================================================================ +// Task 3.2 — Password change with 0 archive entries +// ============================================================================ + +TEST_F(LazyReencryptionTest, PasswordChangeWithNoArchiveEntries) { + ASSERT_EQ(ssm_user_register(handle_, "noarch", "oldpassword123"), SSM_OK); + + // Store a secret (no rotations yet) + const unsigned char priv[] = "no-archive-change-data-32bytes!!"; + ASSERT_EQ(ssm_secret_store(handle_, "noarch", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Verify no archive entries + EXPECT_EQ(archive_count("noarch"), 0); + + // Change password — no archive to re-wrap, should succeed + ASSERT_EQ(ssm_user_change_password(handle_, "noarch", "oldpassword123", "newpassword456"), + SSM_OK); + + // Verify new password works + int valid = 0; + EXPECT_EQ(ssm_user_authenticate(handle_, "noarch", "newpassword456", &valid), SSM_OK); + EXPECT_EQ(valid, 1); + + // Verify secret still accessible + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "noarch", "mykey", out, &len, nullptr, nullptr), SSM_OK); + EXPECT_EQ(std::memcmp(out, priv, sizeof(priv)), 0); +} + +// ============================================================================ +// Task 4.1 — Integration: full cycle +// ============================================================================ + +TEST_F(LazyReencryptionTest, FullCycleRegisterRotateStoreMigratePurge) { + ASSERT_EQ(ssm_user_register(handle_, "fullcyc", "password123"), SSM_OK); + + // Rotate (O(1), no secrets) — archive version 1, new version 2 + ASSERT_EQ(ssm_kek_rotate(handle_, "fullcyc"), SSM_OK); + + // Verify archive entry exists + EXPECT_TRUE(archive_exists("fullcyc", 1)); + + // Now store a secret (uses current KEK version 2) + const unsigned char priv[] = "full-cycle-integration-test-data!"; + ASSERT_EQ(ssm_secret_store(handle_, "fullcyc", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Verify secret starts at kek_version 2 + EXPECT_EQ(secret_kek_version("fullcyc", "mykey"), 2); + + // Rotate again — archive version 2, new version 3 + ASSERT_EQ(ssm_kek_rotate(handle_, "fullcyc"), SSM_OK); + + // Verify archive entries for both 1 and 2 + EXPECT_TRUE(archive_exists("fullcyc", 1)); + EXPECT_TRUE(archive_exists("fullcyc", 2)); + + // Get secret — should lazy-migrate from version 2 to 3 + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "fullcyc", "mykey", out, &len, nullptr, nullptr), SSM_OK); + EXPECT_EQ(std::memcmp(out, priv, sizeof(priv)), 0); + + // Verify kek_version updated to 3 + EXPECT_EQ(secret_kek_version("fullcyc", "mykey"), 3); + + // Archive version 2 should be purged (no secrets at v2) + // Archive version 1 should still exist (no secrets ever referenced it after store at v2) + EXPECT_FALSE(archive_exists("fullcyc", 2)); + EXPECT_TRUE(archive_exists("fullcyc", 1)); + + // Explicit purge should delete archive version 1 + EXPECT_EQ(ssm_kek_purge_archive(handle_, "fullcyc"), SSM_OK); + EXPECT_FALSE(archive_exists("fullcyc", 1)); +} + +// ============================================================================ +// Task 4.2 — Integration: password change + archive +// ============================================================================ + +TEST_F(LazyReencryptionTest, PasswordChangeThenLazyMigrate) { + ASSERT_EQ(ssm_user_register(handle_, "pwlm", "originalpw"), SSM_OK); + + // Store secret + const unsigned char priv[] = "pw-change-lazy-migrate-data-32!"; + ASSERT_EQ(ssm_secret_store(handle_, "pwlm", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Rotate twice (archive v1, v2; current v3) + ASSERT_EQ(ssm_kek_rotate(handle_, "pwlm"), SSM_OK); + ASSERT_EQ(ssm_kek_rotate(handle_, "pwlm"), SSM_OK); + + // Change password — re-wraps all 3 KEKs (current + 2 archives) + ASSERT_EQ(ssm_user_change_password(handle_, "pwlm", "originalpw", "newpw456"), SSM_OK); + + // Login with new password + int valid = 0; + EXPECT_EQ(ssm_user_authenticate(handle_, "pwlm", "newpw456", &valid), SSM_OK); + EXPECT_EQ(valid, 1); + + // Get secret encrypted with oldest KEK (version 1) — lazy-migrate must work + // Secret starts at v1, current is v3, needs 2-step migrate (v1→v3) + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "pwlm", "mykey", out, &len, nullptr, nullptr), SSM_OK); + EXPECT_EQ(std::memcmp(out, priv, sizeof(priv)), 0); + + // Verify secret now at version 3 + EXPECT_EQ(secret_kek_version("pwlm", "mykey"), 3); +} + +// ============================================================================ +// Task 4.3 — Concurrency: 10 threads on stale secrets +// ============================================================================ + +TEST_F(LazyReencryptionTest, ConcurrentStaleSecretMigrate) { + ASSERT_EQ(ssm_user_register(handle_, "concurr", "password123"), SSM_OK); + + // Store 10 secrets at version 1 + const unsigned char priv[] = "concurrent-migrate-data-32bytes!"; + for (int i = 0; i < 10; ++i) { + char name[16]; + std::snprintf(name, sizeof(name), "k%d", i); + ASSERT_EQ(ssm_secret_store(handle_, "concurr", priv, sizeof(priv), nullptr, 0, name, + nullptr), SSM_OK); + } + + // Rotate to version 2 (all secrets now stale) + ASSERT_EQ(ssm_kek_rotate(handle_, "concurr"), SSM_OK); + + // 10 threads each get a different stale secret simultaneously + std::atomic ok_count{0}; + std::vector threads; + for (int i = 0; i < 10; ++i) { + threads.emplace_back([this, i, &ok_count]() { + char name[16]; + std::snprintf(name, sizeof(name), "k%d", i); + unsigned char out[64] = {}; + size_t len = sizeof(out); + if (ssm_secret_get(handle_, "concurr", name, out, &len, nullptr, nullptr) == SSM_OK) + ++ok_count; + }); + } + for (auto& t : threads) + t.join(); + + EXPECT_EQ(ok_count.load(), 10); + + // All secrets should now be at kek_version 2 + for (int i = 0; i < 10; ++i) { + char name[16]; + std::snprintf(name, sizeof(name), "k%d", i); + EXPECT_EQ(secret_kek_version("concurr", name), 2); + } + + // Archive for version 1 should be deleted (all 10 secrets migrated) + EXPECT_FALSE(archive_exists("concurr", 1)); +} + +// ============================================================================ +// Additional edge cases +// ============================================================================ + +TEST_F(LazyReencryptionTest, RotateTwiceThenMigrateTwice) { + ASSERT_EQ(ssm_user_register(handle_, "rot2mig2", "password123"), SSM_OK); + + const unsigned char priv[] = "double-rotate-double-migrate-data"; + ASSERT_EQ(ssm_secret_store(handle_, "rot2mig2", priv, sizeof(priv), nullptr, 0, "mykey", nullptr), + SSM_OK); + + // Rotate twice: versions go 1→2→3, archives for 1 and 2 + ASSERT_EQ(ssm_kek_rotate(handle_, "rot2mig2"), SSM_OK); + ASSERT_EQ(ssm_kek_rotate(handle_, "rot2mig2"), SSM_OK); + + EXPECT_TRUE(archive_exists("rot2mig2", 1)); + EXPECT_TRUE(archive_exists("rot2mig2", 2)); + + // First get: migrate 1→2 (archive v1 should NOT purge since secret now at 2, not current) + // Actually with our one-step migration: 1→3 directly via archive lookup + // The design says: compare secret's kek_version with current, if stale, look up archive for secret's version + // So from v1→v3 directly using archive entry for v1 + unsigned char out[64] = {}; + size_t len = sizeof(out); + ASSERT_EQ(ssm_secret_get(handle_, "rot2mig2", "mykey", out, &len, nullptr, nullptr), SSM_OK); + EXPECT_EQ(std::memcmp(out, priv, sizeof(priv)), 0); + + // After first get: secret at v3, archive v1 purged (no secrets at v1) + EXPECT_EQ(secret_kek_version("rot2mig2", "mykey"), 3); + EXPECT_FALSE(archive_exists("rot2mig2", 1)); + // Archive v2 also purged? No — secret was never at v2. It went from 1→3 directly. + // But v2 was never referenced by any secret so count was always 0 + // So ssm_kek_purge_archive would clean it, but inline purge only triggers for the old_version + // Wait, the inline purge only happens for the OLD version (the one we migrated FROM) + // So v1 gets purged. v2 is still there. + // Hmm, actually v2's count was already 0 before the migrate (no secret was ever at v2) + // But the inline purge only checks AND cleans the exact old_version + // So v2 remains orphaned until explicit ssm_kek_purge_archive + + // Verify v2 archive still exists (not purged inline since no secret referenced it) + // This is expected — explicit purge is needed for orphaned archives + EXPECT_TRUE(archive_exists("rot2mig2", 2)); + + // Explicit purge + EXPECT_EQ(ssm_kek_purge_archive(handle_, "rot2mig2"), SSM_OK); + EXPECT_FALSE(archive_exists("rot2mig2", 2)); +} + +} // namespace +} // namespace ssm::v1