"""Sealed store: encryption at rest for the Sigil database file. Finding: digests stop a stolen DB from yielding live credentials, but the file still holds the agent roster, revocation state and audit history in the clear. SealedStore wraps on-disk bytes in AES-256-GCM so that material is unreadable without the passphrase. Short of SQLCipher (no page-level encryption while open); real working seal of the sensitive file. """ from __future__ import annotations import os import sqlite3 import tempfile import unittest from sigil import identity, keys from sigil.sealed import ( MAGIC, SealedStore, is_sealed_file, seal_bytes, unseal_bytes, ) from sigil.store import Store from tests import helpers PASS = "test-seal-passphrase-ok" class TestSealBytes(unittest.TestCase): def test_round_trip(self): blob = seal_bytes(b"SQLite format 3\x00hello", PASS) self.assertTrue(blob.startswith(MAGIC)) self.assertEqual(unseal_bytes(blob, PASS), b"SQLite format 3\x00hello") def test_wrong_passphrase_refused(self): blob = seal_bytes(b"secret-db-bytes", PASS) with self.assertRaises(ValueError): unseal_bytes(blob, "wrong-passphrase!!") def test_short_passphrase_refused(self): with self.assertRaises(ValueError): seal_bytes(b"x", "short") def test_corrupt_magic_refused(self): with self.assertRaises(ValueError): unseal_bytes(b"NOTSEALED" + b"\x00" * 40, PASS) class TestSealedStore(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.path = os.path.join(self.tmp.name, "sigil.seal") def test_flush_writes_ciphertext_without_sqlite_header(self): store = SealedStore(self.path, PASS) helpers.enrol(store, "bigpickle") store.flush() with open(self.path, "rb") as handle: raw = handle.read() self.assertTrue(raw.startswith(MAGIC)) self.assertNotIn(b"SQLite format 3", raw) self.assertTrue(is_sealed_file(self.path)) # Agent id must not appear in the clear on disk. self.assertNotIn(b"bigpickle", raw) store.close() def test_reopen_with_passphrase_recovers_agents(self): with SealedStore(self.path, PASS) as store: private, _ = helpers.enrol(store, "bigpickle") store.flush() with SealedStore(self.path, PASS) as store: row = store.get_agent("bigpickle") self.assertIsNotNone(row) self.assertEqual(row["fingerprint"], keys.fingerprint( keys.public_from_private(private) )) def test_reopen_with_wrong_passphrase_fails(self): with SealedStore(self.path, PASS) as store: helpers.enrol(store, "bigpickle") store.flush() with self.assertRaises(ValueError): SealedStore(self.path, "definitely-wrong-passphrase") def test_stolen_seal_is_not_a_sqlite_database(self): with SealedStore(self.path, PASS) as store: helpers.enrol(store, "bigpickle") identity.challenge(store, "bigpickle") store.flush() with self.assertRaises(sqlite3.DatabaseError): sqlite3.connect(self.path).execute("SELECT name FROM sqlite_master") def test_refuses_to_open_cleartext_db_as_sealed(self): clear = os.path.join(self.tmp.name, "clear.db") Store(clear) # creates a normal sqlite file with self.assertRaises(ValueError) as ctx: SealedStore(clear, PASS) self.assertIn("not a Sigil sealed store", str(ctx.exception)) def test_seal_permissions_are_owner_only(self): with SealedStore(self.path, PASS) as store: helpers.enrol(store, "bigpickle") store.flush() mode = os.stat(self.path).st_mode & 0o777 self.assertEqual(mode, 0o600) def test_sealed_store_works_with_offbox_anchor(self): tip = os.path.join(self.tmp.name, "chain.tip") with SealedStore(self.path, PASS, anchor_path=tip) as store: private, _ = helpers.enrol(store, "bigpickle") identity.challenge(store, "bigpickle") result = store.checkpoint(private, "bigpickle", "sealed-daily") self.assertEqual(result.get("anchor_path"), tip) self.assertTrue(os.path.exists(tip)) self.assertTrue(store.verify_external_anchors()["ok"]) store.flush() self.assertTrue(is_sealed_file(self.path)) self.assertTrue(os.path.exists(tip)) if __name__ == "__main__": unittest.main() class TestPlaintextWorkingCopyHardening(unittest.TestCase): """R5: plaintext tmp must be 0600; SQLCipher remains deferred.""" def test_plaintext_working_copy_is_owner_only(self): import tempfile from sigil.sealed import SealedStore from sigil import identity, keys d = tempfile.mkdtemp() path = os.path.join(d, "sealed.db") store = SealedStore(path, "test-passphrase-ok") try: mode = os.stat(store._plain_path).st_mode & 0o777 self.assertEqual(mode, 0o600, oct(mode)) dir_mode = os.stat(store._tmpdir.name).st_mode & 0o777 self.assertEqual(dir_mode, 0o700, oct(dir_mode)) secret = identity.seed_bootstrap(store, "alice") _, pub = keys.generate() identity.enrol(store, "alice", keys.export_public_b64(pub), secret) store.flush() # export.db must not linger world-readable after flush export = os.path.join(store._tmpdir.name, "export.db") self.assertFalse(os.path.exists(export)) finally: store.close()