sigil/sigil/sealed.py
leora 73f802a5e6 Sealed store: AES-256-GCM encryption at rest for the DB file
Tokens/codes/secrets were already digests; a stolen cleartext SQLite file
still leaked the agent roster, revocation state and audit history.
SealedStore / SIGIL_SEAL_PASS wraps on-disk bytes in AES-256-GCM
(PBKDF2-HMAC-SHA256, 480k iters). Working copy is plaintext only while
open (temp file); flush/close re-seals. Incremental path short of
SQLCipher page-level encryption.

11 new tests in test_sealed.py (stolen seal has no SQLite header / no
agent rows; wrong passphrase fails; works with off-box anchor). Suite
130 green.

Part of Quarry 18a5d762. Independent crypto review + platform glue remain.
2026-10-06 06:28:01 +08:00

180 lines
6.2 KiB
Python

"""Sealed store: encryption at rest for the Sigil database file.
Tokens, codes and bootstrap secrets are already stored as digests, so a stolen
cleartext SQLite file does not yield live credentials. It does still yield the
full agent roster, revocation state, and audit history. A sealed store wraps
the on-disk bytes in AES-256-GCM so that material is unreadable without the
passphrase.
This is an incremental path short of SQLCipher (no page-level encryption, the
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.
Format on disk (version 1):
b"SIGILSEAL1" + 16-byte salt + 12-byte nonce + AESGCM(ciphertext)
key = PBKDF2-HMAC-SHA256(passphrase, salt, 480_000 iterations, 32 bytes)
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
from typing import Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from .store import Store
MAGIC = b"SIGILSEAL1"
SALT_LEN = 16
NONCE_LEN = 12
KEY_LEN = 32
PBKDF2_ITERS = 480_000
MIN_PASSPHRASE = 12
def derive_key(passphrase: str, salt: bytes) -> bytes:
if len(passphrase) < MIN_PASSPHRASE:
raise ValueError(
f"seal passphrase must be at least {MIN_PASSPHRASE} characters"
)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=KEY_LEN,
salt=salt,
iterations=PBKDF2_ITERS,
)
return kdf.derive(passphrase.encode("utf-8"))
def seal_bytes(plaintext: bytes, passphrase: str, *, salt: Optional[bytes] = None) -> bytes:
"""Encrypt plaintext DB bytes into a sealed blob."""
salt = salt or os.urandom(SALT_LEN)
key = derive_key(passphrase, salt)
nonce = os.urandom(NONCE_LEN)
ct = AESGCM(key).encrypt(nonce, plaintext, MAGIC)
return MAGIC + salt + nonce + ct
def unseal_bytes(blob: bytes, passphrase: str) -> bytes:
"""Decrypt a sealed blob back to plaintext DB bytes."""
if not blob.startswith(MAGIC):
raise ValueError("not a Sigil sealed store (bad magic)")
salt = blob[len(MAGIC):len(MAGIC) + SALT_LEN]
nonce_start = len(MAGIC) + SALT_LEN
nonce = blob[nonce_start:nonce_start + NONCE_LEN]
ct = blob[nonce_start + NONCE_LEN:]
key = derive_key(passphrase, salt)
try:
return AESGCM(key).decrypt(nonce, ct, MAGIC)
except Exception as exc:
raise ValueError("wrong passphrase or corrupted sealed store") from exc
def is_sealed_file(path: str) -> bool:
if not os.path.exists(path):
return False
with open(path, "rb") as handle:
return handle.read(len(MAGIC)) == MAGIC
class SealedStore(Store):
"""Store whose on-disk file is AES-GCM sealed.
Open decrypts into a temp SQLite file (or creates an empty one). Call
`flush()` or `close()` to re-encrypt and write the seal back to `path`.
The passphrase never touches the SQLite file; only the seal blob does.
"""
def __init__(
self,
path: str,
passphrase: str,
*,
anchor_path: Optional[str] = None,
):
if path == ":memory:":
raise ValueError("SealedStore needs a filesystem path to seal")
if len(passphrase) < MIN_PASSPHRASE:
raise ValueError(
f"seal passphrase must be at least {MIN_PASSPHRASE} characters"
)
self.seal_path = path
self._passphrase = passphrase
self._tmpdir = tempfile.TemporaryDirectory(prefix="sigil-seal-")
self._plain_path = os.path.join(self._tmpdir.name, "store.db")
if os.path.exists(path):
with open(path, "rb") as handle:
blob = handle.read()
if not blob.startswith(MAGIC):
raise ValueError(
f"{path} exists but is not a Sigil sealed 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)
super().__init__(self._plain_path, anchor_path=anchor_path)
def flush(self) -> str:
"""Checkpoint WAL, read plaintext bytes, write sealed blob to seal_path."""
with self._write_lock:
self.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
self.conn.commit()
# Copy via SQLite backup so we don't race readers mid-page.
dest_path = os.path.join(self._tmpdir.name, "export.db")
if os.path.exists(dest_path):
os.unlink(dest_path)
dest = sqlite3.connect(dest_path)
try:
self.conn.backup(dest)
dest.commit()
finally:
dest.close()
with open(dest_path, "rb") as handle:
plain = handle.read()
blob = seal_bytes(plain, self._passphrase)
directory = os.path.dirname(os.path.abspath(self.seal_path))
if directory:
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(
prefix=".sigil-seal-", suffix=".tmp", dir=directory or "."
)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(blob)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, self.seal_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
os.chmod(self.seal_path, 0o600)
return self.seal_path
def close(self) -> None:
"""Flush the seal and release the temp working copy."""
try:
self.flush()
finally:
try:
self.conn.close()
except Exception:
pass
self._tmpdir.cleanup()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()
return False