Skip to content

Commit 01ed3ad

Browse files
committed
feat(secrets): add FileBasedSecretsManager with JSONL storage
Add a file-based secrets manager that persists secrets to a JSONL file with in-memory caching and auto-save on program exit via atexit. Supports atomic writes using temp file + rename pattern. Signed-off-by: Daniel Bluhm <dbluhm@pm.me>
1 parent 5dec651 commit 01ed3ad

2 files changed

Lines changed: 156 additions & 2 deletions

File tree

didcomm_messaging/crypto/backend/basic.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
"""Basic Crypto Implementations."""
22

3-
from typing import Optional
3+
import atexit
4+
import json
5+
import shutil
6+
from pathlib import Path
7+
from typing import Callable, Dict, Optional
8+
49
from didcomm_messaging.crypto.base import S, SecretsManager
510

611

@@ -18,3 +23,77 @@ async def get_secret_by_kid(self, kid: str) -> Optional[S]:
1823
async def add_secret(self, secret: S) -> None:
1924
"""Add a secret to the secrets manager."""
2025
self.secrets[secret.kid] = secret
26+
27+
28+
class FileBasedSecretsManager(SecretsManager[S]):
29+
"""File-based Secrets Manager with in-memory caching and auto-save.
30+
31+
Secrets are stored in memory for fast access and persisted to a JSONL file.
32+
The file is saved automatically on program exit via atexit, and can also
33+
be flushed explicitly using the flush() method.
34+
35+
Requires serializer and deserializer callbacks to convert between SecretKey
36+
objects and their JSON-serializable representation.
37+
"""
38+
39+
def __init__(
40+
self,
41+
path: str,
42+
serializer: Callable[[S], Dict],
43+
deserializer: Callable[[str, Dict], S],
44+
secrets: Optional[dict] = None,
45+
):
46+
"""Initialize the FileBasedSecretsManager.
47+
48+
Args:
49+
path: Full path to the JSONL file for storing secrets.
50+
serializer: Callback to serialize a SecretKey to a dict.
51+
deserializer: Callback to deserialize a dict to a SecretKey.
52+
Takes (kid, serialized_dict) as arguments.
53+
secrets: Optional initial secrets to load (file takes precedence).
54+
"""
55+
self._path = Path(path)
56+
self._serializer = serializer
57+
self._deserializer = deserializer
58+
self._secrets: Dict[str, S] = secrets or {}
59+
60+
if self._path.exists():
61+
with open(self._path) as f:
62+
for line in f:
63+
line = line.strip()
64+
if not line:
65+
continue
66+
data = json.loads(line)
67+
kid = data.get("kid")
68+
if kid:
69+
self._secrets[kid] = self._deserializer(kid, data)
70+
71+
atexit.register(self._sync)
72+
73+
@property
74+
def path(self) -> str:
75+
"""Return the path to the secrets file."""
76+
return str(self._path)
77+
78+
async def get_secret_by_kid(self, kid: str) -> Optional[S]:
79+
"""Get a secret by its kid."""
80+
return self._secrets.get(kid)
81+
82+
async def add_secret(self, secret: S) -> None:
83+
"""Add a secret to the secrets manager."""
84+
self._secrets[secret.kid] = secret
85+
86+
async def flush(self) -> None:
87+
"""Explicitly save secrets to the file."""
88+
self._sync()
89+
90+
def _sync(self) -> None:
91+
"""Write secrets to file (called on atexit and flush)."""
92+
tmp_path = self._path.with_suffix(".tmp")
93+
self._path.parent.mkdir(parents=True, exist_ok=True)
94+
with open(tmp_path, "w") as f:
95+
for kid, secret in self._secrets.items():
96+
data = self._serializer(secret)
97+
data["kid"] = kid
98+
f.write(json.dumps(data) + "\n")
99+
shutil.move(tmp_path, self._path)

tests/test_secrets.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1+
import json
2+
import os
3+
import tempfile
4+
15
import pytest
26
from didcomm_messaging.crypto import SecretKey
37

4-
from didcomm_messaging.crypto.backend.basic import InMemorySecretsManager
8+
from didcomm_messaging.crypto.backend.basic import (
9+
FileBasedSecretsManager,
10+
InMemorySecretsManager,
11+
)
512

613

714
class MockSecretKey(SecretKey):
@@ -12,6 +19,9 @@ def __init__(self, kid) -> None:
1219
def kid(self):
1320
return self._kid
1421

22+
def __repr__(self):
23+
return f"MockSecretKey({self._kid!r})"
24+
1525

1626
@pytest.fixture()
1727
def in_memory_secrets_manager():
@@ -32,3 +42,68 @@ async def test_in_memory_secrets(
3242
await in_memory_secrets_manager.add_secret(secret)
3343

3444
assert await in_memory_secrets_manager.get_secret_by_kid(secret.kid) == secret
45+
46+
47+
@pytest.fixture()
48+
def temp_secrets_file():
49+
fd, path = tempfile.mkstemp(suffix=".jsonl")
50+
os.close(fd)
51+
yield path
52+
if os.path.exists(path):
53+
os.remove(path)
54+
if os.path.exists(path + ".tmp"):
55+
os.remove(path + ".tmp")
56+
57+
58+
def serializer(secret: MockSecretKey):
59+
return {"multikey": f"multikey:{secret.kid}"}
60+
61+
62+
def deserializer(kid: str, data: dict):
63+
return MockSecretKey(kid=kid)
64+
65+
66+
@pytest.mark.asyncio
67+
async def test_file_based_secrets(temp_secrets_file):
68+
manager = FileBasedSecretsManager(temp_secrets_file, serializer, deserializer)
69+
70+
secret = MockSecretKey(kid="did:example:alice#key-1")
71+
await manager.add_secret(secret)
72+
73+
result = await manager.get_secret_by_kid(secret.kid)
74+
assert result == secret
75+
76+
await manager.flush()
77+
78+
with open(temp_secrets_file) as f:
79+
lines = f.readlines()
80+
81+
assert len(lines) == 1
82+
data = json.loads(lines[0])
83+
assert data["kid"] == secret.kid
84+
assert data["multikey"] == f"multikey:{secret.kid}"
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_file_based_secrets_loads_existing(temp_secrets_file):
89+
initial_data = [{"kid": "did:example:alice#key-1", "multikey": "multikey:existing"}]
90+
with open(temp_secrets_file, "w") as f:
91+
for item in initial_data:
92+
f.write(json.dumps(item) + "\n")
93+
94+
manager = FileBasedSecretsManager(temp_secrets_file, serializer, deserializer)
95+
96+
secret = await manager.get_secret_by_kid("did:example:alice#key-1")
97+
assert secret is not None
98+
assert secret.kid == "did:example:alice#key-1"
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_file_based_secrets_empty_file(temp_secrets_file):
103+
"""Test that empty file doesn't cause error."""
104+
with open(temp_secrets_file, "w") as f:
105+
f.write("")
106+
107+
manager = FileBasedSecretsManager(temp_secrets_file, serializer, deserializer)
108+
result = await manager.get_secret_by_kid("any-key")
109+
assert result is None

0 commit comments

Comments
 (0)