sealedlog 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.3
2
+ Name: sealedlog
3
+ Version: 0.1.0
4
+ Summary: Encrypted, append-only, line-oriented JSON log
5
+ Author: Louis Maddox
6
+ Author-email: Louis Maddox <louismmx@gmail.com>
7
+ Requires-Dist: pynacl>=1.5
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # sealedlog
12
+
13
+ An encrypted, append-only, line-oriented log for JSON records — think SQLite
14
+ for encrypted append-only JSON logs, minus the SQL. Small, embeddable, no
15
+ daemon, no external services: just a file format and the code to read and
16
+ write it correctly.
17
+
18
+ - Appending a record is a byte-append to the file — no rewriting, no
19
+ reordering. Git diffs and merges cleanly on files built this way.
20
+ - Each line decrypts independently. A corrupted or truncated line doesn't
21
+ block reading the lines before or after it.
22
+ - Lines are bound to the logical stream they were written for. A line copied
23
+ from one stream into another fails to authenticate, even under the correct
24
+ key.
25
+ - The key comes from a passphrase via Argon2id, and a wrong passphrase is
26
+ detected immediately and unambiguously.
27
+
28
+ See [docs/FORMAT.md](docs/FORMAT.md) for the on-disk format and threat model.
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from pathlib import Path
34
+ from sealedlog import SealedLog, Vault
35
+
36
+ vault = Vault.create("correct horse battery staple", namespace="myapp")
37
+ key = vault.unlock("correct horse battery staple", namespace="myapp")
38
+
39
+ log = SealedLog(Path("events.jsonl.enc"), key, "orders", namespace="myapp")
40
+ log.append({"order_id": 1, "total": 42})
41
+
42
+ for record in log:
43
+ print(record)
44
+ ```
45
+
46
+ `namespace` is your application's own identifier — it, the stream ID, and the
47
+ library's format version are all folded into the authenticated data for every
48
+ line, so two different applications never accidentally produce
49
+ cross-compatible ciphertexts.
50
+
51
+ ## Non-goals
52
+
53
+ `sealedlog` doesn't know what a "user" or "owner" is, doesn't validate record
54
+ schemas, doesn't fold or merge records, doesn't decide where files live, and
55
+ doesn't coordinate concurrent writers to the same file. All of that is
56
+ application-level policy built on top of a plain sequence of records.
57
+
58
+ ## Development
59
+
60
+ ```sh
61
+ uv sync --all-groups
62
+ uv run ruff format --check .
63
+ uv run ruff check .
64
+ uv run ty check
65
+ uv run pytest
66
+ ```
@@ -0,0 +1,56 @@
1
+ # sealedlog
2
+
3
+ An encrypted, append-only, line-oriented log for JSON records — think SQLite
4
+ for encrypted append-only JSON logs, minus the SQL. Small, embeddable, no
5
+ daemon, no external services: just a file format and the code to read and
6
+ write it correctly.
7
+
8
+ - Appending a record is a byte-append to the file — no rewriting, no
9
+ reordering. Git diffs and merges cleanly on files built this way.
10
+ - Each line decrypts independently. A corrupted or truncated line doesn't
11
+ block reading the lines before or after it.
12
+ - Lines are bound to the logical stream they were written for. A line copied
13
+ from one stream into another fails to authenticate, even under the correct
14
+ key.
15
+ - The key comes from a passphrase via Argon2id, and a wrong passphrase is
16
+ detected immediately and unambiguously.
17
+
18
+ See [docs/FORMAT.md](docs/FORMAT.md) for the on-disk format and threat model.
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ from pathlib import Path
24
+ from sealedlog import SealedLog, Vault
25
+
26
+ vault = Vault.create("correct horse battery staple", namespace="myapp")
27
+ key = vault.unlock("correct horse battery staple", namespace="myapp")
28
+
29
+ log = SealedLog(Path("events.jsonl.enc"), key, "orders", namespace="myapp")
30
+ log.append({"order_id": 1, "total": 42})
31
+
32
+ for record in log:
33
+ print(record)
34
+ ```
35
+
36
+ `namespace` is your application's own identifier — it, the stream ID, and the
37
+ library's format version are all folded into the authenticated data for every
38
+ line, so two different applications never accidentally produce
39
+ cross-compatible ciphertexts.
40
+
41
+ ## Non-goals
42
+
43
+ `sealedlog` doesn't know what a "user" or "owner" is, doesn't validate record
44
+ schemas, doesn't fold or merge records, doesn't decide where files live, and
45
+ doesn't coordinate concurrent writers to the same file. All of that is
46
+ application-level policy built on top of a plain sequence of records.
47
+
48
+ ## Development
49
+
50
+ ```sh
51
+ uv sync --all-groups
52
+ uv run ruff format --check .
53
+ uv run ruff check .
54
+ uv run ty check
55
+ uv run pytest
56
+ ```
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "sealedlog"
3
+ version = "0.1.0"
4
+ description = "Encrypted, append-only, line-oriented JSON log"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Louis Maddox", email = "louismmx@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "pynacl>=1.5",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["uv_build>=0.11.13,<0.13.0"]
16
+ build-backend = "uv_build"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "ruff>=0.6",
21
+ "ty>=0.0.1a1",
22
+ "pytest>=8",
23
+ ]
24
+
25
+ [tool.ruff]
26
+ line-length = 100
27
+ target-version = "py312"
28
+
29
+ [tool.ruff.lint]
30
+ select = ["E", "F", "I", "UP", "B"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,26 @@
1
+ """sealedlog: an encrypted, append-only, line-oriented JSON log.
2
+
3
+ Each line is independently authenticated and bound to the logical stream it
4
+ was written for, so a corrupted line doesn't block reading its neighbors and
5
+ a line copied between streams fails to authenticate. See docs/FORMAT.md for
6
+ the on-disk format and threat model.
7
+ """
8
+
9
+ from sealedlog import aead
10
+ from sealedlog.errors import AuthenticationError, SealError, WrongPassphraseError
11
+ from sealedlog.log import LineFailure, SealedLog
12
+ from sealedlog.vault import Vault
13
+
14
+ seal = aead.seal
15
+ open_ = aead.open_
16
+
17
+ __all__ = [
18
+ "AuthenticationError",
19
+ "LineFailure",
20
+ "SealedLog",
21
+ "SealError",
22
+ "Vault",
23
+ "WrongPassphraseError",
24
+ "open_",
25
+ "seal",
26
+ ]
@@ -0,0 +1,27 @@
1
+ """AAD construction: binds a sealed line to the library format version, the
2
+ caller's namespace, and the stream it belongs to.
3
+
4
+ Fields are length-prefixed rather than joined with a delimiter, so no choice
5
+ of namespace or stream_id — including one containing the delimiter a naive
6
+ scheme would use — can make two distinct (namespace, stream_id) pairs collide
7
+ on the same AAD bytes. See docs/FORMAT.md for the full rationale.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ FORMAT_VERSION = 1
13
+ VERIFIER_STREAM_ID = "verifier"
14
+ VERIFIER_PLAINTEXT = b"sealedlog-verify"
15
+
16
+
17
+ def build_aad(namespace: str, stream_id: str) -> bytes:
18
+ ns = namespace.encode("utf-8")
19
+ sid = stream_id.encode("utf-8")
20
+ return (
21
+ b"sealedlog"
22
+ + FORMAT_VERSION.to_bytes(2, "big")
23
+ + len(ns).to_bytes(4, "big")
24
+ + ns
25
+ + len(sid).to_bytes(4, "big")
26
+ + sid
27
+ )
@@ -0,0 +1,47 @@
1
+ """Stateless byte-level AEAD primitive: XChaCha20-Poly1305 with a fresh random
2
+ nonce per call. No file I/O, no notion of a "stream" or a "key derivation" —
3
+ just seal bytes under a key and associated data, and open them again.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import binascii
10
+ import os
11
+
12
+ import nacl.bindings as sodium
13
+ import nacl.exceptions
14
+
15
+ from sealedlog.errors import AuthenticationError
16
+
17
+ KEY_SIZE = sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES
18
+ NONCE_SIZE = sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
19
+
20
+
21
+ def seal(key: bytes, aad: bytes, plaintext: bytes) -> str:
22
+ """Encrypt `plaintext` under `key`, bound to `aad`. Returns
23
+ base64(nonce‖ciphertext‖tag).
24
+ """
25
+ nonce = os.urandom(NONCE_SIZE)
26
+ ct = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, aad, nonce, key)
27
+ return base64.b64encode(nonce + ct).decode("ascii")
28
+
29
+
30
+ def open_(key: bytes, aad: bytes, sealed: str) -> bytes:
31
+ """Decrypt a sealed line, verifying it was sealed under `key` and `aad`.
32
+
33
+ Raises AuthenticationError on any authentication failure or malformed
34
+ input (bad base64, too short to contain a nonce) — one error type,
35
+ regardless of which of those it was.
36
+ """
37
+ try:
38
+ raw = base64.b64decode(sealed, validate=True)
39
+ except (ValueError, binascii.Error) as e:
40
+ raise AuthenticationError(f"not valid base64: {e}") from e
41
+ if len(raw) < NONCE_SIZE:
42
+ raise AuthenticationError("sealed line too short")
43
+ nonce, ct = raw[:NONCE_SIZE], raw[NONCE_SIZE:]
44
+ try:
45
+ return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(ct, aad, nonce, key)
46
+ except nacl.exceptions.CryptoError as e:
47
+ raise AuthenticationError(f"failed to authenticate line: {e}") from e
@@ -0,0 +1,17 @@
1
+ """Exception hierarchy for sealedlog."""
2
+
3
+
4
+ class SealError(Exception):
5
+ """Base for all sealedlog errors."""
6
+
7
+
8
+ class WrongPassphraseError(SealError):
9
+ """Passphrase does not match the vault's verifier."""
10
+
11
+
12
+ class AuthenticationError(SealError):
13
+ """A line failed to authenticate under its expected AAD, or was malformed
14
+ (not valid base64, too short to contain a nonce). Deliberately one type for
15
+ both: callers shouldn't have to distinguish "corrupted" from "tampered" from
16
+ "truncated" to handle a bad line.
17
+ """
@@ -0,0 +1,68 @@
1
+ """Append-only encrypted JSONL file, keyed by a stream_id.
2
+
3
+ Single writer per file: sealedlog does no locking or coordination, and
4
+ assumes the caller doesn't run two writers against the same path at once.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import Iterator
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from sealedlog import aead
15
+ from sealedlog._aad import build_aad
16
+ from sealedlog.errors import AuthenticationError
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class LineFailure:
21
+ lineno: int
22
+ error: str
23
+
24
+
25
+ class SealedLog:
26
+ def __init__(self, path: Path, key: bytes, stream_id: str, *, namespace: str) -> None:
27
+ self.path = path
28
+ self._key = key
29
+ self._aad = build_aad(namespace, stream_id)
30
+
31
+ def append(self, record: dict) -> None:
32
+ """JSON-encodes and appends one sealed line. Byte-append only —
33
+ never rewrites or reorders existing lines.
34
+ """
35
+ plaintext = json.dumps(record, separators=(",", ":")).encode("utf-8")
36
+ sealed = aead.seal(self._key, self._aad, plaintext)
37
+ self.path.parent.mkdir(parents=True, exist_ok=True)
38
+ with self.path.open("a", encoding="utf-8") as f:
39
+ f.write(sealed + "\n")
40
+
41
+ def __iter__(self) -> Iterator[dict]:
42
+ """Lazily decrypts and yields records in file order. Raises
43
+ AuthenticationError on the first bad line — use verify() to survive
44
+ corruption instead.
45
+ """
46
+ for line in self._read_lines():
47
+ yield json.loads(aead.open_(self._key, self._aad, line))
48
+
49
+ def verify(self) -> list[LineFailure]:
50
+ """Attempts every line; never raises. Returns the failures instead of
51
+ stopping at the first, so a caller can report all of them in one pass.
52
+ """
53
+ failures: list[LineFailure] = []
54
+ for lineno, line in enumerate(self._read_lines(), start=1):
55
+ try:
56
+ aead.open_(self._key, self._aad, line)
57
+ except AuthenticationError as e:
58
+ failures.append(LineFailure(lineno=lineno, error=str(e)))
59
+ return failures
60
+
61
+ def _read_lines(self) -> Iterator[str]:
62
+ if not self.path.exists():
63
+ return
64
+ with self.path.open("r", encoding="utf-8") as f:
65
+ for line in f:
66
+ line = line.strip()
67
+ if line:
68
+ yield line
File without changes
@@ -0,0 +1,84 @@
1
+ """Passphrase -> key, via Argon2id, with a verifier so a wrong passphrase
2
+ fails immediately instead of producing garbage downstream.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import os
9
+ from dataclasses import dataclass
10
+
11
+ import nacl.pwhash
12
+
13
+ from sealedlog import aead
14
+ from sealedlog._aad import VERIFIER_PLAINTEXT, VERIFIER_STREAM_ID, build_aad
15
+ from sealedlog.errors import AuthenticationError, WrongPassphraseError
16
+
17
+ SALT_SIZE = nacl.pwhash.argon2id.SALTBYTES
18
+ OPSLIMIT_DEFAULT = nacl.pwhash.argon2id.OPSLIMIT_MODERATE
19
+ MEMLIMIT_DEFAULT = nacl.pwhash.argon2id.MEMLIMIT_MODERATE
20
+
21
+
22
+ def derive_key(passphrase: str, salt: bytes, opslimit: int, memlimit: int) -> bytes:
23
+ return nacl.pwhash.argon2id.kdf(
24
+ aead.KEY_SIZE, passphrase.encode("utf-8"), salt, opslimit=opslimit, memlimit=memlimit
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Vault:
30
+ """KDF params, salt, and a sealed verifier. Safe to store as plaintext
31
+ alongside the encrypted log(s) it unlocks — it reveals nothing without
32
+ the passphrase.
33
+ """
34
+
35
+ salt: bytes
36
+ opslimit: int
37
+ memlimit: int
38
+ verifier: str
39
+
40
+ @classmethod
41
+ def create(
42
+ cls,
43
+ passphrase: str,
44
+ *,
45
+ namespace: str,
46
+ opslimit: int = OPSLIMIT_DEFAULT,
47
+ memlimit: int = MEMLIMIT_DEFAULT,
48
+ ) -> Vault:
49
+ salt = os.urandom(SALT_SIZE)
50
+ key = derive_key(passphrase, salt, opslimit, memlimit)
51
+ verifier = aead.seal(key, build_aad(namespace, VERIFIER_STREAM_ID), VERIFIER_PLAINTEXT)
52
+ return cls(salt=salt, opslimit=opslimit, memlimit=memlimit, verifier=verifier)
53
+
54
+ def unlock(self, passphrase: str, *, namespace: str) -> bytes:
55
+ """Derive the key and check it against the verifier.
56
+
57
+ Raises WrongPassphraseError if the passphrase doesn't match — before
58
+ any real data gets touched.
59
+ """
60
+ key = derive_key(passphrase, self.salt, self.opslimit, self.memlimit)
61
+ try:
62
+ opened = aead.open_(key, build_aad(namespace, VERIFIER_STREAM_ID), self.verifier)
63
+ except AuthenticationError as e:
64
+ raise WrongPassphraseError("passphrase does not match this vault") from e
65
+ if opened != VERIFIER_PLAINTEXT:
66
+ raise WrongPassphraseError("passphrase does not match this vault")
67
+ return key
68
+
69
+ def to_dict(self) -> dict:
70
+ return {
71
+ "salt": base64.b64encode(self.salt).decode("ascii"),
72
+ "opslimit": self.opslimit,
73
+ "memlimit": self.memlimit,
74
+ "verifier": self.verifier,
75
+ }
76
+
77
+ @classmethod
78
+ def from_dict(cls, d: dict) -> Vault:
79
+ return cls(
80
+ salt=base64.b64decode(d["salt"]),
81
+ opslimit=d["opslimit"],
82
+ memlimit=d["memlimit"],
83
+ verifier=d["verifier"],
84
+ )