Tokens/codes/secrets were already digests; a stolen cleartext SQLite file still leaked the agent roster, revocation state and audit history. SealedStore / SIGIL_SEAL_PASS wraps on-disk bytes in AES-256-GCM (PBKDF2-HMAC-SHA256, 480k iters). Working copy is plaintext only while open (temp file); flush/close re-seals. Incremental path short of SQLCipher page-level encryption. 11 new tests in test_sealed.py (stolen seal has no SQLite header / no agent rows; wrong passphrase fails; works with off-box anchor). Suite 130 green. Part of Quarry 18a5d762. Independent crypto review + platform glue remain.
127 lines
4.6 KiB
Python
127 lines
4.6 KiB
Python
"""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()
|