330 lines
12 KiB
C++
330 lines
12 KiB
C++
// Seal unit tests: test_seal.py ported 1:1 (class SealTests) plus known-answer vectors for
|
|
// the primitives. Cross-implementation checks live in tests/seal_diff.py.
|
|
#include <unistd.h>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include "config/config.h"
|
|
#include "repl/boot.h"
|
|
#include "seal/seal_base64.h"
|
|
#include "seal/seal_factory.h"
|
|
#include "seal/seal_hmac.h"
|
|
#include "seal/seal_pbkdf2.h"
|
|
#include "seal/seal_rotation.h"
|
|
#include "seal/seal_sha256.h"
|
|
#include "store/json.h"
|
|
#include "store/store.h"
|
|
using namespace detz;
|
|
namespace fs = std::filesystem;
|
|
|
|
static int g_checks = 0, g_fail = 0;
|
|
#define CHECK(cond) do { g_checks++; if (!(cond)) { g_fail++; std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); } } while (0)
|
|
#define CHECK_THROWS_SEAL(expr) do { g_checks++; bool hit = false; try { expr; } catch (const store::SealError&) { hit = true; } \
|
|
if (!hit) { g_fail++; std::printf("FAIL %s:%d no SealError: %s\n", __FILE__, __LINE__, #expr); } } while (0)
|
|
|
|
// One throwaway TAI_HOME (the python setUp/tearDown pair).
|
|
class TmpHome {
|
|
public:
|
|
TmpHome() {
|
|
char tmpl[] = "/tmp/seal-test-XXXXXX";
|
|
root = mkdtemp(tmpl);
|
|
fs::create_directories(fs::path(root) / "profiles");
|
|
}
|
|
~TmpHome() { std::error_code ec; fs::remove_all(root, ec); }
|
|
Config config() const {
|
|
Config c = Config::FromEnv();
|
|
c.home = root;
|
|
c.profilesDir = (fs::path(root) / "profiles").string();
|
|
c.dbPath = (fs::path(root) / "memory.db").string();
|
|
c.profile = "default";
|
|
return c;
|
|
}
|
|
std::string root;
|
|
};
|
|
|
|
static bool starts(const std::string& s, const char* p) { return s.rfind(p, 0) == 0; }
|
|
static bool contains(const std::string& s, const std::string& p) { return s.find(p) != std::string::npos; }
|
|
static std::string slurp(const std::string& path) { std::ifstream f(path, std::ios::binary); std::stringstream ss; ss << f.rdbuf(); return ss.str(); }
|
|
|
|
static std::unique_ptr<store::Store> make_store(const TmpHome& h, store::SealPort& seal) { return std::make_unique<store::Store>(h.config(), seal); }
|
|
|
|
static void test_vectors() {
|
|
using namespace seal;
|
|
CHECK(SealHmac::to_hex(SealSha256::digest("abc")) == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
|
CHECK(SealHmac::to_hex(SealSha256::digest("")) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
|
CHECK(SealHmac::to_hex(SealSha256::digest(std::string(1000, 'a'))) == "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3");
|
|
// RFC 4231 test case 2
|
|
CHECK(SealHmac::hex("Jefe", "what do ya want for nothing?") == "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843");
|
|
// RFC 7914 section 11: PBKDF2-HMAC-SHA-256, P="passwd", S="salt", c=1, dkLen=64
|
|
CHECK(SealHmac::to_hex(SealPbkdf2::derive("passwd", "salt", 1, 64)) ==
|
|
"55ac046e56e3089fec1691c22544b605f94185216dde0465e68b9d57c20dacbc49ca9cccf179b645991664b39d77ef317c71b845b1e30bd509112041d3a19783");
|
|
std::string out;
|
|
CHECK(SealBase64::encode("") == "" && SealBase64::encode("f") == "Zg==" && SealBase64::encode("fo") == "Zm8=" && SealBase64::encode("foo") == "Zm9v");
|
|
CHECK(SealBase64::decode("Zm9vYg==", out) && out == "foob");
|
|
CHECK(!SealBase64::decode("Zm9vY", out));
|
|
CHECK(!SealBase64::decode("Zg", out));
|
|
}
|
|
|
|
static void test_round_trip() {
|
|
TmpHome h;
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
std::string locked = s.lock("the wifi password is orchid-4421");
|
|
CHECK(starts(locked, "tai1$"));
|
|
CHECK(!contains(locked, "orchid"));
|
|
CHECK(s.unlock(locked) == "the wifi password is orchid-4421");
|
|
}
|
|
|
|
static void test_unique_nonce() {
|
|
TmpHome h;
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
CHECK(s.lock("same") != s.lock("same"));
|
|
}
|
|
|
|
static void test_wrong_passphrase() {
|
|
TmpHome h;
|
|
seal::Seal first(h.root, "correct-horse");
|
|
CHECK_THROWS_SEAL(seal::Seal(h.root, "wrong-guess"));
|
|
try { seal::Seal(h.root, "wrong-guess"); } catch (const store::SealError& e) { CHECK(std::string(e.what()) == "wrong TAI_PASSPHRASE"); }
|
|
}
|
|
|
|
static void test_tamper_detected() {
|
|
TmpHome h;
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
std::string locked = s.lock("precious");
|
|
std::string tampered = locked;
|
|
size_t i = tampered.size() - 4;
|
|
tampered[i] = locked[i] != 'A' ? 'A' : 'B';
|
|
CHECK_THROWS_SEAL(s.unlock(tampered));
|
|
CHECK_THROWS_SEAL(s.unlock("plain"));
|
|
CHECK_THROWS_SEAL(s.unlock("tai1$onlytwo$parts"));
|
|
CHECK(s.safe_unlock(tampered) == "[sealed: decrypt failed]");
|
|
}
|
|
|
|
static void test_sql_functions() {
|
|
TmpHome h;
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
auto st = make_store(h, s);
|
|
std::string row = st->db().execute("SELECT tai_enc('hello-sql')").rows[0][0].text();
|
|
CHECK(starts(row, "tai1$"));
|
|
CHECK(st->db().execute("SELECT tai_dec(?)", {row}).rows[0][0].text() == "hello-sql");
|
|
st->close();
|
|
}
|
|
|
|
static void test_sealed_events_recall() {
|
|
TmpHome h;
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
auto st = make_store(h, s);
|
|
st->log_event("default", "user", "message", "my vault code is zebra-9");
|
|
std::string raw = st->db().execute("SELECT text FROM events").rows[0][0].text();
|
|
CHECK(starts(raw, "tai1$"));
|
|
CHECK(!contains(raw, "zebra"));
|
|
auto rows = st->search_events("default", "zebra");
|
|
CHECK(rows.size() == 1);
|
|
CHECK(!rows.empty() && contains(rows[0].text, "zebra-9"));
|
|
st->close();
|
|
}
|
|
|
|
static void test_sealed_profile_files() {
|
|
TmpHome h;
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
auto st = make_store(h, s);
|
|
st->save_system("work", "system knows orchid-4421");
|
|
CHECK(starts(slurp(st->profile_path("work")), "tai1$"));
|
|
CHECK(st->load_system("work") == std::optional<std::string>("system knows orchid-4421"));
|
|
st->save_session("work", {store::Json::loads("{\"role\": \"user\", \"content\": \"secret hello\"}")});
|
|
CHECK(starts(slurp(st->session_path("work")), "tai1$"));
|
|
auto msgs = st->load_session("work");
|
|
CHECK(msgs.size() == 1 && msgs[0].get_string("content") == "secret hello");
|
|
st->close();
|
|
}
|
|
|
|
static void test_migration_from_plaintext() {
|
|
TmpHome h;
|
|
{
|
|
seal::Seal plain(h.root, "");
|
|
auto st = make_store(h, plain);
|
|
st->log_event("default", "user", "message", "plain memory");
|
|
st->save_system("default", "plain system");
|
|
st->close();
|
|
}
|
|
seal::Seal sealed(h.root, "new-secret-2");
|
|
auto st = make_store(h, sealed);
|
|
CHECK(starts(st->db().execute("SELECT text FROM events").rows[0][0].text(), "tai1$"));
|
|
CHECK(starts(slurp(st->profile_path("default")), "tai1$"));
|
|
CHECK(st->load_system("default") == std::optional<std::string>("plain system"));
|
|
st->close();
|
|
}
|
|
|
|
static void test_sealed_home_requires_passphrase() {
|
|
TmpHome h;
|
|
{
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
auto st = make_store(h, s);
|
|
st->log_event("default", "user", "message", "locked memory");
|
|
st->close();
|
|
}
|
|
seal::Seal none(h.root, "");
|
|
CHECK_THROWS_SEAL(store::Store(h.config(), none));
|
|
}
|
|
|
|
static void test_passphrase_default() {
|
|
unsetenv("TAI_PASSPHRASE");
|
|
auto [value, def] = repl::Boot::resolve_passphrase();
|
|
CHECK(value == seal::Seal::kDefaultPassphrase);
|
|
CHECK(def);
|
|
setenv("TAI_PASSPHRASE", "personal-1", 1);
|
|
auto [v2, d2] = repl::Boot::resolve_passphrase();
|
|
CHECK(v2 == "personal-1" && !d2);
|
|
setenv("TAI_PASSPHRASE", "", 1);
|
|
auto [v3, d3] = repl::Boot::resolve_passphrase();
|
|
CHECK(v3.empty() && !d3);
|
|
unsetenv("TAI_PASSPHRASE");
|
|
}
|
|
|
|
static void test_rotate_default_to_personal() {
|
|
TmpHome h;
|
|
seal::Seal old(h.root, seal::Seal::kDefaultPassphrase);
|
|
{
|
|
auto st = make_store(h, old);
|
|
st->log_event("default", "user", "message", "rotate me");
|
|
st->save_system("default", "rotate system");
|
|
st->close();
|
|
}
|
|
auto fresh = seal::SealRotation::rotate(seal::SealRotation::Paths::ForHome(h.root), old, "personal-2");
|
|
CHECK(fresh->enabled());
|
|
CHECK_THROWS_SEAL(seal::Seal(h.root, seal::Seal::kDefaultPassphrase));
|
|
seal::Seal again(h.root, "personal-2");
|
|
auto re = make_store(h, again);
|
|
CHECK(re->load_system("default") == std::optional<std::string>("rotate system"));
|
|
CHECK(contains(re->search_events("default", "rotate")[0].text, "rotate me"));
|
|
CHECK(starts(re->db().execute("SELECT text FROM events").rows[0][0].text(), "tai1$"));
|
|
re->close();
|
|
}
|
|
|
|
static void test_plaintext_home_unaffected() {
|
|
TmpHome h;
|
|
seal::Seal plain(h.root, "");
|
|
auto st = make_store(h, plain);
|
|
st->log_event("default", "user", "message", "open memory");
|
|
CHECK(st->search_events("default", "open")[0].text == "open memory");
|
|
st->save_system("default", "open system");
|
|
CHECK(slurp(st->profile_path("default")) == "open system");
|
|
CHECK(!fs::exists(fs::path(h.root) / ".seal"));
|
|
st->close();
|
|
}
|
|
|
|
static void test_rotate_moves_secrets() {
|
|
TmpHome h;
|
|
seal::Seal old(h.root, seal::Seal::kDefaultPassphrase);
|
|
{
|
|
auto st = make_store(h, old);
|
|
st->save_secret("api", "rotate-me-1");
|
|
st->close();
|
|
}
|
|
seal::SealRotation::rotate(seal::SealRotation::Paths::ForHome(h.root), old, "personal-3");
|
|
seal::Seal again(h.root, "personal-3");
|
|
auto re = make_store(h, again);
|
|
CHECK(re->load_secret("api") == std::optional<std::string>("rotate-me-1"));
|
|
CHECK(starts(re->db().execute("SELECT value FROM secrets WHERE name = 'api'").rows[0][0].text(), "tai1$"));
|
|
re->close();
|
|
}
|
|
|
|
static void test_sealed_secrets_require_passphrase() {
|
|
TmpHome h;
|
|
{
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
auto st = make_store(h, s);
|
|
st->save_secret("k", "locked-value");
|
|
st->close();
|
|
}
|
|
seal::Seal none(h.root, "");
|
|
CHECK_THROWS_SEAL(store::Store(h.config(), none));
|
|
}
|
|
|
|
static void test_schedule_prompt_sealed() {
|
|
TmpHome h;
|
|
seal::Seal s(h.root, "test-secret-1");
|
|
auto st = make_store(h, s);
|
|
st->add_schedule("job", "prompt-secret-3", "t", 0, "2026-01-01T00:00:00+00:00", 60);
|
|
std::string raw = st->db().execute("SELECT prompt FROM schedules").rows[0][0].text();
|
|
CHECK(starts(raw, "tai1$"));
|
|
CHECK(!contains(raw, "prompt-secret"));
|
|
CHECK(st->list_schedules("t")[0].get_string("prompt") == "prompt-secret-3");
|
|
st->close();
|
|
}
|
|
|
|
static void test_rotate_moves_schedules() {
|
|
TmpHome h;
|
|
seal::Seal old(h.root, seal::Seal::kDefaultPassphrase);
|
|
{
|
|
auto st = make_store(h, old);
|
|
st->add_schedule("job", "rotate-prompt-4", "t", 3600, "2026-01-01T00:00:00+00:00", 60);
|
|
st->close();
|
|
}
|
|
seal::SealRotation::rotate(seal::SealRotation::Paths::ForHome(h.root), old, "personal-4");
|
|
seal::Seal again(h.root, "personal-4");
|
|
auto re = make_store(h, again);
|
|
CHECK(re->list_schedules("t")[0].get_string("prompt") == "rotate-prompt-4");
|
|
CHECK(starts(re->db().execute("SELECT prompt FROM schedules").rows[0][0].text(), "tai1$"));
|
|
re->close();
|
|
}
|
|
|
|
// boot(): default-key home upgraded when TAI_PASSPHRASE is set; default flag follows the key.
|
|
static void test_factory_upgrade() {
|
|
TmpHome h;
|
|
{
|
|
auto s = seal::SealFactory::make(h.root, seal::Seal::kDefaultPassphrase, true);
|
|
CHECK(s->default_key() && s->enabled());
|
|
auto st = make_store(h, *s);
|
|
st->save_secret("api", "factory-1");
|
|
st->close();
|
|
}
|
|
auto s2 = seal::SealFactory::make(h.root, "personal-5", false);
|
|
CHECK(s2->enabled() && !s2->default_key());
|
|
auto st = make_store(h, *s2);
|
|
CHECK(st->load_secret("api") == std::optional<std::string>("factory-1"));
|
|
st->close();
|
|
// default key against a personal home: refused, not rotated
|
|
CHECK_THROWS_SEAL(seal::SealFactory::make(h.root, seal::Seal::kDefaultPassphrase, true));
|
|
// wrong personal passphrase and no default key behind it: original error
|
|
try { seal::SealFactory::make(h.root, "nope", false); CHECK(false); } catch (const store::SealError& e) { CHECK(std::string(e.what()) == "wrong TAI_PASSPHRASE"); }
|
|
// empty passphrase: disabled seal, never default
|
|
TmpHome plain;
|
|
auto s3 = seal::SealFactory::make(plain.root, "", false);
|
|
CHECK(!s3->enabled() && !s3->default_key());
|
|
}
|
|
|
|
static void test_corrupt_seal_file() {
|
|
TmpHome h;
|
|
{ std::ofstream f(fs::path(h.root) / ".seal"); f << "{not json"; }
|
|
CHECK_THROWS_SEAL(seal::Seal(h.root, "x"));
|
|
{ std::ofstream f(fs::path(h.root) / ".seal"); f << "{\"check\": \"a\"}"; }
|
|
CHECK_THROWS_SEAL(seal::Seal(h.root, "x"));
|
|
}
|
|
|
|
int main() {
|
|
test_vectors();
|
|
test_round_trip();
|
|
test_unique_nonce();
|
|
test_wrong_passphrase();
|
|
test_tamper_detected();
|
|
test_sql_functions();
|
|
test_sealed_events_recall();
|
|
test_sealed_profile_files();
|
|
test_migration_from_plaintext();
|
|
test_sealed_home_requires_passphrase();
|
|
test_passphrase_default();
|
|
test_rotate_default_to_personal();
|
|
test_plaintext_home_unaffected();
|
|
test_rotate_moves_secrets();
|
|
test_sealed_secrets_require_passphrase();
|
|
test_schedule_prompt_sealed();
|
|
test_rotate_moves_schedules();
|
|
test_factory_upgrade();
|
|
test_corrupt_seal_file();
|
|
std::printf("seal_test: %d checks, %d failed\n", g_checks, g_fail);
|
|
return g_fail ? 1 : 0;
|
|
}
|