From 4e65f6671aee90c3fb6cf3e78b721474865f9059 Mon Sep 17 00:00:00 2001 From: leora Date: Tue, 6 Oct 2026 06:14:41 +0800 Subject: [PATCH] Fix R5: SealedStore plaintext tmp is 0600 with fsync/cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DESIGN.md | 3 ++- README.md | 4 +++- sigil/sealed.py | 52 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_sealed.py | 27 +++++++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b5ff907..95025f3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -241,7 +241,8 @@ Leora's contracted prod pass (Quarry 18a5d762) closed three remaining review blo rewrite that in-DB checkpoints alone would miss after deletion. Leave it unset and the tip stays in-DB only. - **The service is unwritten.** TLS, CSRF, transport, routes. DB file seal is - available (`SealedStore`); SQLCipher page-level encryption is not. + available (`SealedStore`, plaintext working copy `0600`); SQLCipher + page-level encryption is deferred (not yet available). - **Rate limits are per-process**, so N processes means N times the quota. - **No key recovery.** Rotate and old sessions die. Deliberate. - **Humans remain unattributable** to themselves, which is accepted and is a diff --git a/README.md b/README.md index 4ab7eef..874fc1f 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,9 @@ Still open, and these are the reasons not to ship it yet: - **The service around this is unwritten.** TLS, CSRF, session transport and the HTTP routes. DB encryption at rest is available via `SealedStore` / `SIGIL_SEAL_PASS` (AES-256-GCM file seal; not SQLCipher page-level). The - library refuses to mint authority from a name, so a route that forgets to + plaintext working copy is mode `0600` in a private temp dir while open; + SQLCipher remains deferred for hostile co-resident hosts. The library + refuses to mint authority from a name, so a route that forgets to authenticate fails closed, but that is only as good as the integration. - **Off-box anchor is opt-in.** Set `SIGIL_ANCHOR` (or `Store(..., anchor_path=)`) and every checkpoint also writes a signed tip outside the SQLite file. diff --git a/sigil/sealed.py b/sigil/sealed.py index 2e3b486..a8ceac0 100644 --- a/sigil/sealed.py +++ b/sigil/sealed.py @@ -11,6 +11,11 @@ working copy is plaintext in memory / a temp file while open). It is a real working seal: the file on disk is ciphertext, and tests assert a stolen seal has no SQLite header and no recoverable agent rows without the passphrase. +The plaintext working copy is created mode 0600 inside a private temp dir. +SQLCipher (or equivalent page-level encryption) remains a deferred prod +follow-up for multi-tenant / hostile co-resident hosts — never treat seal-on +as "encrypted DB" while the process is running. + Format on disk (version 1): b"SIGILSEAL1" + 16-byte salt + 12-byte nonce + AESGCM(ciphertext) @@ -83,6 +88,30 @@ def is_sealed_file(path: str) -> bool: return handle.read(len(MAGIC)) == MAGIC +def _write_private_file(path: str, data: bytes) -> None: + """Write `data` to `path` with mode 0600, fsync, unlink on failure.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(path, flags, 0o600) + try: + with os.fdopen(fd, "wb") as handle: + fd = -1 # ownership transferred + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(path, 0o600) + except Exception: + if fd >= 0: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(path) + except OSError: + pass + raise + + class SealedStore(Store): """Store whose on-disk file is AES-GCM sealed. @@ -107,6 +136,8 @@ class SealedStore(Store): self.seal_path = path self._passphrase = passphrase self._tmpdir = tempfile.TemporaryDirectory(prefix="sigil-seal-") + # TemporaryDirectory is already 0700; pin the dir mode explicitly. + os.chmod(self._tmpdir.name, 0o700) self._plain_path = os.path.join(self._tmpdir.name, "store.db") if os.path.exists(path): with open(path, "rb") as handle: @@ -117,9 +148,16 @@ class SealedStore(Store): "refusing to treat a cleartext DB as sealed" ) plain = unseal_bytes(blob, passphrase) - with open(self._plain_path, "wb") as handle: - handle.write(plain) + _write_private_file(self._plain_path, plain) + else: + # Touch an empty 0600 file so SQLite does not create a world-readable DB. + _write_private_file(self._plain_path, b"") super().__init__(self._plain_path, anchor_path=anchor_path) + # Re-pin after SQLite open (WAL/SHM sidecars inherit umask otherwise). + try: + os.chmod(self._plain_path, 0o600) + except OSError: + pass def flush(self) -> str: """Checkpoint WAL, read plaintext bytes, write sealed blob to seal_path.""" @@ -130,14 +168,24 @@ class SealedStore(Store): dest_path = os.path.join(self._tmpdir.name, "export.db") if os.path.exists(dest_path): os.unlink(dest_path) + # Pre-create 0600 so SQLite backup does not leave a world-readable export. + _write_private_file(dest_path, b"") dest = sqlite3.connect(dest_path) try: self.conn.backup(dest) dest.commit() finally: dest.close() + try: + os.chmod(dest_path, 0o600) + except OSError: + pass with open(dest_path, "rb") as handle: plain = handle.read() + try: + os.unlink(dest_path) + except OSError: + pass blob = seal_bytes(plain, self._passphrase) directory = os.path.dirname(os.path.abspath(self.seal_path)) if directory: diff --git a/tests/test_sealed.py b/tests/test_sealed.py index 8adc13b..9fe2375 100644 --- a/tests/test_sealed.py +++ b/tests/test_sealed.py @@ -124,3 +124,30 @@ class TestSealedStore(unittest.TestCase): 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() -- 2.47.3