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.
- sealedlog-0.1.0/PKG-INFO +66 -0
- sealedlog-0.1.0/README.md +56 -0
- sealedlog-0.1.0/pyproject.toml +33 -0
- sealedlog-0.1.0/src/sealedlog/__init__.py +26 -0
- sealedlog-0.1.0/src/sealedlog/_aad.py +27 -0
- sealedlog-0.1.0/src/sealedlog/aead.py +47 -0
- sealedlog-0.1.0/src/sealedlog/errors.py +17 -0
- sealedlog-0.1.0/src/sealedlog/log.py +68 -0
- sealedlog-0.1.0/src/sealedlog/py.typed +0 -0
- sealedlog-0.1.0/src/sealedlog/vault.py +84 -0
sealedlog-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
)
|