Plaintext working copy and export backup were creatable world-readable under a 0700 temp dir. Write via os.open(mode=0600), fsync, unlink on failure; pin temp dir 0700; remove export after flush. Document SQLCipher as deferred — seal-on is not an encrypted DB while the process is open.
154 lines
5.7 KiB
Python
154 lines
5.7 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()
|
|
|
|
|
|
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()
|