Fix R5: SealedStore plaintext tmp 0600 + SQLCipher deferred note #9
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user