Fix R5: SealedStore plaintext tmp 0600 + SQLCipher deferred note #9

Merged
leora merged 1 commits from leora/fix-r5-sealed-tmp-hardening into sigil-initial 2026-10-06 06:14:43 +08:00
4 changed files with 82 additions and 4 deletions
Showing only changes of commit 4e65f6671a - Show all commits

View File

@ -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 rewrite that in-DB checkpoints alone would miss after deletion. Leave it unset
and the tip stays in-DB only. and the tip stays in-DB only.
- **The service is unwritten.** TLS, CSRF, transport, routes. DB file seal is - **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. - **Rate limits are per-process**, so N processes means N times the quota.
- **No key recovery.** Rotate and old sessions die. Deliberate. - **No key recovery.** Rotate and old sessions die. Deliberate.
- **Humans remain unattributable** to themselves, which is accepted and is a - **Humans remain unattributable** to themselves, which is accepted and is a

View File

@ -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 - **The service around this is unwritten.** TLS, CSRF, session transport and the
HTTP routes. DB encryption at rest is available via `SealedStore` / HTTP routes. DB encryption at rest is available via `SealedStore` /
`SIGIL_SEAL_PASS` (AES-256-GCM file seal; not SQLCipher page-level). The `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. 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=)`) - **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. and every checkpoint also writes a signed tip outside the SQLite file.

View File

@ -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 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. 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): Format on disk (version 1):
b"SIGILSEAL1" + 16-byte salt + 12-byte nonce + AESGCM(ciphertext) 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 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): class SealedStore(Store):
"""Store whose on-disk file is AES-GCM sealed. """Store whose on-disk file is AES-GCM sealed.
@ -107,6 +136,8 @@ class SealedStore(Store):
self.seal_path = path self.seal_path = path
self._passphrase = passphrase self._passphrase = passphrase
self._tmpdir = tempfile.TemporaryDirectory(prefix="sigil-seal-") 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") self._plain_path = os.path.join(self._tmpdir.name, "store.db")
if os.path.exists(path): if os.path.exists(path):
with open(path, "rb") as handle: with open(path, "rb") as handle:
@ -117,9 +148,16 @@ class SealedStore(Store):
"refusing to treat a cleartext DB as sealed" "refusing to treat a cleartext DB as sealed"
) )
plain = unseal_bytes(blob, passphrase) plain = unseal_bytes(blob, passphrase)
with open(self._plain_path, "wb") as handle: _write_private_file(self._plain_path, plain)
handle.write(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) 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: def flush(self) -> str:
"""Checkpoint WAL, read plaintext bytes, write sealed blob to seal_path.""" """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") dest_path = os.path.join(self._tmpdir.name, "export.db")
if os.path.exists(dest_path): if os.path.exists(dest_path):
os.unlink(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) dest = sqlite3.connect(dest_path)
try: try:
self.conn.backup(dest) self.conn.backup(dest)
dest.commit() dest.commit()
finally: finally:
dest.close() dest.close()
try:
os.chmod(dest_path, 0o600)
except OSError:
pass
with open(dest_path, "rb") as handle: with open(dest_path, "rb") as handle:
plain = handle.read() plain = handle.read()
try:
os.unlink(dest_path)
except OSError:
pass
blob = seal_bytes(plain, self._passphrase) blob = seal_bytes(plain, self._passphrase)
directory = os.path.dirname(os.path.abspath(self.seal_path)) directory = os.path.dirname(os.path.abspath(self.seal_path))
if directory: if directory:

View File

@ -124,3 +124,30 @@ class TestSealedStore(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.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()