Fix R5: SealedStore plaintext tmp is 0600 with fsync/cleanup
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.
This commit is contained in:
parent
40c5ee0a3d
commit
6e86aeb2af
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user